Compare commits

..

5 Commits

Author SHA1 Message Date
Dhruwang Jariwala
60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04:00
pandeymangg
aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Dhruwang Jariwala
a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey
029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt
81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
1272 changed files with 37498 additions and 39126 deletions

View File

@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################

View File

@@ -44,7 +44,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<div className="flex items-center">
<DropdownMenu>
@@ -105,7 +105,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>

View File

@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
return redirect(`/workspaces/${firstWorkspace.id}/`);
}
return <>{children}</>;

View File

@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
const { isMember } = getAccessFlags(membership?.role);
return (
<div className="flex min-h-full min-w-full flex-row">
@@ -35,20 +34,17 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">

View File

@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;

View File

@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}

View File

@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
title={t("workspace.settings.billing.select_plan_header_title")}
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>

View File

@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,26 +81,21 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
if (createWorkspaceResponse?.data) {
if (globalThis.window !== undefined) {
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
router.push(`/workspaces/${workspaceId}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
router.push(`/workspaces/${workspaceId}/surveys`);
} else if (workspaceMode === "cx") {
router.push(`/workspaces/${workspaceId}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +104,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +127,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +179,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +237,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"

View File

@@ -2,30 +2,34 @@ import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -39,7 +43,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -57,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"

View File

@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
workspaceId: string;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
workspaceId,
publicDomain,
appSetupCompleted,
channel,
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation();
const router = useRouter();
const handleFinishOnboarding = async () => {
router.push(`/environments/${environment.id}/surveys`);
router.push(`/workspaces/${workspaceId}/surveys`);
};
useEffect(() => {
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
workspaceId={workspaceId}
publicDomain={publicDomain}
channel={channel}
appSetupCompleted={appSetupCompleted}
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
)}>
{appSetupCompleted ? (
<div>
<p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")}
{t("workspace.connect.connection_successful_message")}
</p>
</div>
) : (
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.waiting_for_your_signal")}
{t("workspace.connect.waiting_for_your_signal")}
</p>
</div>
)}
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}>
{appSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
<ArrowRight />
</Button>
</div>

View File

@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
];
interface OnboardingSetupInstructionsProps {
environmentId: string;
workspaceId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
workspaceId,
publicDomain,
channel,
appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
const npmSnippetForWebsiteSurveys = `
// other imports
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">

View File

@@ -1,56 +1,50 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: ConnectPageProps) => {
const params = await props.params;
const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
workspaceId={params.workspaceId}
publicDomain={publicDomain}
appSetupCompleted={environment.appSetupCompleted}
appSetupCompleted={workspace.appSetupCompleted}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>

View File

@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`);
}
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this environment");
throw new AuthorizationError("User is not authorized to access this workspace");
}
return <div className="flex-1 bg-slate-50">{children}</div>;

View File

@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
workspaceId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
workspaceId: workspaceId,
surveyBody: augmentedTemplate,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: t("environments.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"),
title: t("workspace.xm-templates.nps"),
description: t("workspace.xm-templates.nps_description"),
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: t("environments.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"),
title: t("workspace.xm-templates.five_star_rating"),
description: t("workspace.xm-templates.five_star_rating_description"),
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: t("environments.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"),
title: t("workspace.xm-templates.csat"),
description: t("workspace.xm-templates.csat_description"),
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
{
title: t("environments.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"),
title: t("workspace.xm-templates.ces"),
description: t("workspace.xm-templates.ces_description"),
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: t("environments.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"),
title: t("workspace.xm-templates.smileys"),
description: t("workspace.xm-templates.smileys_description"),
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
{
title: t("environments.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"),
title: t("workspace.xm-templates.enps"),
description: t("workspace.xm-templates.enps_description"),
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,

View File

@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockProject: TProject = {
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
environments: [],
appSetupCompleted: false,
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});

View File

@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};

View File

@@ -2,11 +2,9 @@ import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -14,15 +12,15 @@ import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
@@ -31,29 +29,24 @@ const Page = async (props: XMTemplatePageProps) => {
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>

View File

@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorEnvironmentLayout;

View File

@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspace } from "@/lib/workspace/service";
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
const SurveyEditorWorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorWorkspaceLayout;

View File

@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
if (storedWorkspaceId) {
setResolvedWorkspaceId(storedWorkspaceId);
}
}, []);
@@ -41,12 +39,7 @@ export const ConfirmationPage = () => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>

View File

@@ -1,4 +1,4 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
export const LoadingCard = ({

View File

@@ -1,18 +0,0 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;
}
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [environmentId]);
return null;
};
export default EnvironmentStorageHandler;

View File

@@ -1,56 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EnvironmentSwitchProps {
environment: TEnvironment;
environments: TEnvironment[];
}
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const toggleEnvSwitch = () => {
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
setIsLoading(true);
setIsEnvSwitchChecked(!isEnvSwitchChecked);
handleEnvironmentChange(newEnvironmentType);
};
return (
<div
className={cn(
"flex items-center space-x-2 rounded-lg p-2",
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
)}>
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
{t("common.dev_env")}
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
id="development-mode"
disabled={isLoading}
checked={isEnvSwitchChecked}
onCheckedChange={toggleEnvSwitch}
/>
</div>
);
};

View File

@@ -1,95 +0,0 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
href: string;
isActive: boolean;
isCollapsed: boolean;
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
}
export const NavigationLink = ({
href,
isActive,
isCollapsed = false,
children,
linkText,
isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return (
<>
{isCollapsed ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
{disabled ? (
<div className="flex items-center">{children}</div>
) : (
<Link href={href}>{children}</Link>
)}
</li>
</TooltipTrigger>
<TooltipContent side="right">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
{disabled ? (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{children}
{label}
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || linkText}
</PopoverContent>
</Popover>
) : (
<Link href={href} className="flex items-center">
{children}
{label}
</Link>
)}
</li>
)}
</>
);
};

View File

@@ -1,89 +0,0 @@
"use client";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslation();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};

View File

@@ -1,81 +0,0 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -1,340 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${currentEnvironmentId}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<div key={setting.id}>
{areProjectSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};

View File

@@ -1,68 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};

View File

@@ -1,38 +0,0 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
);
};
export default EnvLayout;

View File

@@ -1,22 +0,0 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
}
return redirect(`/environments/${params.environmentId}/surveys`);
};
export default EnvironmentPage;

View File

@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
export default Page;

View File

@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};
export default Page;

View File

@@ -1,3 +0,0 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;

View File

@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;

View File

@@ -1,3 +0,0 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading;

View File

@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;

View File

@@ -1,3 +0,0 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;

View File

@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;

View File

@@ -1,4 +0,0 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;

View File

@@ -1,3 +0,0 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default ProjectLookSettingsLoading;

View File

@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;

View File

@@ -1,3 +0,0 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default ProjectSettingsPage;

View File

@@ -1,3 +0,0 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading;

View File

@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;

View File

@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;

View File

@@ -0,0 +1,3 @@
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;

View File

@@ -0,0 +1,3 @@
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
export default AppConnectionPage;

View File

@@ -0,0 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
export default GeneralSettingsLoading;

View File

@@ -0,0 +1,3 @@
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
export default GeneralSettingsPage;

View File

@@ -8,15 +8,14 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
getOrganizationIdFromWorkspaceId,
getWorkspaceIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZCreateOrUpdateIntegrationAction = z.object({
environmentId: ZId,
workspaceId: ZId,
integrationData: ZIntegrationInput,
});
@@ -24,7 +23,7 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
.inputSchema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -35,15 +34,15 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
workspaceId: parsedInput.workspaceId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
const result = await createOrUpdateIntegration(parsedInput.workspaceId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
@@ -73,8 +72,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],

View File

@@ -17,9 +17,9 @@ import {
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -93,7 +93,7 @@ type EditModeProps =
type AddIntegrationModalProps = {
open: boolean;
setOpenWithStates: (v: boolean) => void;
environmentId: string;
workspaceId: string;
airtableArray: TIntegrationItem[];
surveys: TSurvey[];
airtableIntegration: TIntegrationAirtable;
@@ -103,8 +103,8 @@ const NoBaseFoundError = () => {
const { t } = useTranslation();
return (
<Alert>
<AlertTitle>{t("environments.integrations.airtable.no_bases_found")}</AlertTitle>
<AlertDescription>{t("environments.integrations.airtable.please_create_a_base")}</AlertDescription>
<AlertTitle>{t("workspace.integrations.airtable.no_bases_found")}</AlertTitle>
<AlertDescription>{t("workspace.integrations.airtable.please_create_a_base")}</AlertDescription>
</Alert>
);
};
@@ -172,7 +172,7 @@ const renderElementSelection = ({
export const AddIntegrationModal = ({
open,
setOpenWithStates,
environmentId,
workspaceId,
airtableArray,
surveys,
airtableIntegration,
@@ -227,19 +227,19 @@ export const AddIntegrationModal = ({
const submitHandler = async (data: IntegrationModalInputs) => {
try {
if (!data.base || data.base === "") {
throw new Error(t("environments.integrations.airtable.please_select_a_base"));
throw new Error(t("workspace.integrations.airtable.please_select_a_base"));
}
if (!data.table || data.table === "") {
throw new Error(t("environments.integrations.airtable.please_select_a_table"));
throw new Error(t("workspace.integrations.airtable.please_select_a_table"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (data.elements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
}
const currentTable = tables.find((item) => item.id === data.table);
@@ -270,7 +270,7 @@ export const AddIntegrationModal = ({
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
@@ -278,9 +278,9 @@ export const AddIntegrationModal = ({
return;
}
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
handleClose();
} catch (e) {
@@ -289,7 +289,7 @@ export const AddIntegrationModal = ({
};
const handleTable = async (baseId: string) => {
const data = await fetchTables(environmentId, baseId);
const data = await fetchTables(workspaceId, baseId);
if (data.tables) {
setTables(data.tables);
@@ -312,7 +312,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
const result = await createOrUpdateIntegrationAction({ workspaceId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
@@ -320,7 +320,7 @@ export const AddIntegrationModal = ({
handleClose();
router.refresh();
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
} catch (e) {
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
@@ -336,13 +336,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
alt={t("workspace.integrations.airtable.airtable_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")}
{t("workspace.integrations.airtable.sync_responses_with_airtable")}
</DialogDescription>
</div>
</div>
@@ -364,7 +364,7 @@ export const AddIntegrationModal = ({
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<Label htmlFor="table">{t("workspace.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
@@ -427,7 +427,7 @@ export const AddIntegrationModal = ({
</div>
) : (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
{t("workspace.integrations.create_survey_warning")}
</p>
)}

View File

@@ -5,13 +5,13 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
interface AirtableWrapperProps {
environmentId: string;
workspaceId: string;
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
@@ -21,7 +21,7 @@ interface AirtableWrapperProps {
}
export const AirtableWrapper = ({
environmentId,
workspaceId,
airtableArray,
airtableIntegration,
surveys,
@@ -34,7 +34,7 @@ export const AirtableWrapper = ({
);
const handleAirtableAuthorization = async () => {
authorize(environmentId, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -44,7 +44,7 @@ export const AirtableWrapper = ({
return isConnected && airtableIntegration ? (
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
workspaceId={workspaceId}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}

View File

@@ -33,7 +33,7 @@ export const BaseSelectDropdown = ({
const { t } = useTranslation();
return (
<div className="flex w-full flex-col">
<Label htmlFor="base">{t("environments.integrations.airtable.airtable_base")}</Label>
<Label htmlFor="base">{t("workspace.integrations.airtable.airtable_base")}</Label>
<div className="mt-1 flex">
<Controller
control={control}

View File

@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -19,7 +19,7 @@ import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environmentId: string;
workspaceId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
@@ -27,12 +27,12 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("workspace.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
@@ -53,7 +53,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -77,7 +77,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
{t("environments.integrations.connected_with_email", {
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
@@ -87,7 +87,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
setDefaultValues(null);
handleModal(true);
}}>
{t("environments.integrations.airtable.link_new_table")}
{t("workspace.integrations.airtable.link_new_table")}
</Button>
</div>
@@ -130,21 +130,21 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptyState text={t("workspace.integrations.airtable.no_integrations_yet")} />
</div>
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.airtable.airtable_integration")}
deleteWhat={t("workspace.integrations.airtable.airtable_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
@@ -153,7 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
airtableArray={airtableArray}
open={isModalOpen}
setOpenWithStates={handleModal}
environmentId={environmentId}
workspaceId={workspaceId}
surveys={surveys}
airtableIntegration={airtableIntegration}
{...data}

View File

@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
// Mock fetch
global.fetch = vi.fn();
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const baseId = "test-base-id";
const apiHost = "http://localhost:3000";
@@ -36,11 +36,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const tables = await fetchTables(environmentId, baseId);
const tables = await fetchTables(workspaceId, baseId);
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
cache: "no-store",
});
expect(tables).toEqual(mockTables);
@@ -56,11 +56,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
});
expect(authUrl).toBe(mockAuthUrl);
});
@@ -73,11 +73,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
});

View File

@@ -1,20 +1,20 @@
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => {
export const fetchTables = async (workspaceId: string, baseId: string) => {
const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
cache: "no-store",
});
const resJson = await res.json();
return resJson.data as Promise<TIntegrationAirtableTables>;
};
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {

View File

@@ -1,28 +1,28 @@
import { redirect } from "next/navigation";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(workspace.id),
getIntegrations(workspace.id),
getUserLocale(session.user.id),
]);
@@ -32,7 +32,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
airtableArray = await getAirtableTables(workspace.id);
}
if (isReadOnly) {
return redirect("./");
@@ -40,14 +40,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full">
<AirtableWrapper
isEnabled={isEnabled}
airtableIntegration={airtableIntegration}
airtableArray={airtableArray}
environmentId={environment.id}
workspaceId={workspace.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}

View File

@@ -10,10 +10,10 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
workspaceId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
@@ -21,21 +21,21 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: parsedInput.workspaceId,
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
const integration = await getIntegrationByType(parsedInput.workspaceId, "googleSheets");
if (!integration) {
return { data: false };
}
@@ -46,7 +46,7 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),
workspaceId: z.string(),
spreadsheetId: z.string(),
});
@@ -55,15 +55,15 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: parsedInput.workspaceId,
minPermission: "readWrite",
},
],

View File

@@ -12,13 +12,13 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
@@ -44,7 +44,7 @@ import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps {
environmentId: string;
workspaceId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
@@ -53,7 +53,7 @@ interface AddIntegrationModalProps {
}
export const AddIntegrationModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -125,9 +125,9 @@ export const AddIntegrationModal = ({
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
toast.error(t("workspace.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
toast.error(t("workspace.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
@@ -136,19 +136,19 @@ export const AddIntegrationModal = ({
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
throw new Error(t("environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
throw new Error(t("workspace.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
environmentId,
workspaceId,
spreadsheetId,
});
@@ -180,7 +180,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
@@ -188,9 +188,9 @@ export const AddIntegrationModal = ({
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
@@ -227,14 +227,14 @@ export const AddIntegrationModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -253,13 +253,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
alt={t("workspace.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
{t("workspace.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
</div>
</div>
@@ -269,7 +269,7 @@ export const AddIntegrationModal = ({
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<Label>{t("environments.integrations.google_sheets.spreadsheet_url")}</Label>
<Label>{t("workspace.integrations.google_sheets.spreadsheet_url")}</Label>
<Input
value={spreadsheetUrl}
onChange={(e) => setSpreadsheetUrl(e.target.value)}
@@ -286,7 +286,7 @@ export const AddIntegrationModal = ({
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
</p>
</div>
</div>
@@ -361,7 +361,7 @@ export const AddIntegrationModal = ({
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
: t("workspace.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form>

View File

@@ -1,16 +1,15 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -18,7 +17,7 @@ import { AddIntegrationModal } from "./AddIntegrationModal";
interface GoogleSheetWrapperProps {
isEnabled: boolean;
environment: TEnvironment;
workspaceId: string;
surveys: TSurvey[];
googleSheetIntegration?: TIntegrationGoogleSheets;
webAppUrl: string;
@@ -27,7 +26,7 @@ interface GoogleSheetWrapperProps {
export const GoogleSheetWrapper = ({
isEnabled,
environment,
workspaceId,
surveys,
googleSheetIntegration,
webAppUrl,
@@ -44,18 +43,18 @@ export const GoogleSheetWrapper = ({
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
const response = await validateGoogleSheetsConnectionAction({ workspaceId });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
}, [workspaceId, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -67,7 +66,7 @@ export const GoogleSheetWrapper = ({
{isConnected && googleSheetIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
workspaceId={workspaceId}
surveys={surveys}
open={isModalOpen}
setOpen={setIsModalOpen}

View File

@@ -9,7 +9,7 @@ import {
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
@@ -53,7 +53,7 @@ export const ManageIntegration = ({
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -77,10 +77,10 @@ export const ManageIntegration = ({
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
{t("workspace.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
{t("workspace.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
@@ -88,7 +88,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
{t("workspace.integrations.connected_with_email", {
email: googleSheetIntegration.config.email,
})}
</span>
@@ -98,11 +98,11 @@ export const ManageIntegration = ({
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
{t("workspace.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
{t("workspace.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -111,12 +111,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.google_sheets.link_new_sheet")}
{t("workspace.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptyState text={t("workspace.integrations.google_sheets.no_integrations_yet")} />
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -124,7 +124,7 @@ export const ManageIntegration = ({
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.google_sheets.google_sheet_name")}
{t("workspace.integrations.google_sheets.google_sheet_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
@@ -149,15 +149,15 @@ export const ManageIntegration = ({
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.google_sheets.google_connection")}
deleteWhat={t("workspace.integrations.google_sheets.google_connection")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.google_sheets.google_connection_deletion_description")}
text={t("workspace.integrations.google_sheets.google_connection_deletion_description")}
isDeleting={isDeleting}
/>
</div>

View File

@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId };
const expectedHeaders = { workspaceId: workspaceId };
afterEach(() => {
vi.clearAllMocks();
@@ -30,7 +30,7 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
@@ -47,7 +47,7 @@ describe("authorize", () => {
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",

View File

@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {

View File

@@ -11,7 +11,7 @@ const Loading = () => {
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.google_sheets.link_new_sheet")}
{t("workspace.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -19,7 +19,7 @@ const Loading = () => {
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 text-center">{t("common.survey")}</div>
<div className="col-span-4 text-center">
{t("environments.integrations.google_sheets.google_sheet_name")}
{t("workspace.integrations.google_sheets.google_sheet_name")}
</div>
<div className="col-span-2 text-center">{t("common.questions")}</div>
<div className="col-span-2 text-center">{t("common.updated_at")}</div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
@@ -12,21 +12,21 @@ import {
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(workspace.id),
getIntegrations(workspace.id),
getUserLocale(session.user.id),
]);
@@ -39,12 +39,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
isEnabled={isEnabled}
environment={environment}
workspaceId={workspace.id}
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}

View File

@@ -35,7 +35,7 @@ vi.mock("react", async (importOriginal) => {
};
});
const environmentId = "test-environment-id";
const workspaceId = "test-environment-id";
// Use 'as any' to bypass complex type matching for mock data
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
@@ -58,7 +58,7 @@ const mockTransformedSurveys: TSurvey[] = [
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file
environmentId: environmentId,
workspaceId: workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
@@ -80,7 +80,7 @@ const mockTransformedSurveys: TSurvey[] = [
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app",
environmentId: environmentId,
workspaceId: workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
@@ -98,14 +98,14 @@ describe("getSurveys", () => {
return { ...found } as TSurvey;
});
const surveys = await getSurveys(environmentId);
const surveys = await getSurveys(workspaceId);
expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
expect(validateInputs).toHaveBeenCalledWith([workspaceId, expect.any(Object)]); // Adjusted expectation
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId,
status: {
not: "completed",
},
@@ -129,7 +129,7 @@ describe("getSurveys", () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
await expect(getSurveys(workspaceId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
// React cache is already mocked globally - no need to check it here
});
@@ -139,7 +139,7 @@ describe("getSurveys", () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
await expect(getSurveys(workspaceId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
// React cache is already mocked globally - no need to check it here
});

View File

@@ -10,13 +10,13 @@ import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
export const getSurveys = reactCache(async (workspaceId: string): Promise<TSurvey[]> => {
validateInputs([workspaceId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
workspaceId,
status: {
not: "completed",
},

View File

@@ -14,7 +14,7 @@ vi.mock("@formbricks/database", () => ({
},
}));
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
@@ -26,16 +26,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier);
const count = await getWebhookCountBySource(workspaceId, sourceZapier);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[workspaceId, expect.any(Object)],
[sourceZapier, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId: workspaceId,
source: sourceZapier,
},
});
@@ -45,16 +45,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId);
const count = await getWebhookCountBySource(workspaceId);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[workspaceId, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId: workspaceId,
source: undefined,
},
});
@@ -67,7 +67,7 @@ describe("getWebhookCountBySource", () => {
});
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
await expect(getWebhookCountBySource(workspaceId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
});
@@ -75,7 +75,7 @@ describe("getWebhookCountBySource", () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
await expect(getWebhookCountBySource(workspaceId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getWebhookCountBySource = async (
environmentId: string,
workspaceId: string,
source?: Webhook["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
validateInputs([workspaceId, ZId], [source, z.string().optional()]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
workspaceId,
source,
},
});

View File

@@ -15,12 +15,12 @@ import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import {
MappingRow,
TMapping,
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -39,7 +39,7 @@ import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps {
environmentId: string;
workspaceId: string;
surveys: TSurvey[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -49,7 +49,7 @@ interface AddIntegrationModalProps {
}
export const AddIntegrationModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -175,18 +175,18 @@ export const AddIntegrationModal = ({
const linkDatabase = async () => {
try {
if (!selectedDatabase) {
throw new Error(t("environments.integrations.notion.please_select_a_database"));
throw new Error(t("workspace.integrations.notion.please_select_a_database"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
throw new Error(t("workspace.integrations.notion.please_select_at_least_one_mapping"));
}
if (mapping.filter((m) => m.error).length > 0) {
throw new Error(t("environments.integrations.notion.please_resolve_mapping_errors"));
throw new Error(t("workspace.integrations.notion.please_resolve_mapping_errors"));
}
if (
@@ -194,7 +194,7 @@ export const AddIntegrationModal = ({
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
) {
throw new Error(
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
t("workspace.integrations.notion.please_complete_mapping_fields_with_notion_property")
);
}
@@ -219,7 +219,7 @@ export const AddIntegrationModal = ({
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
@@ -227,9 +227,9 @@ export const AddIntegrationModal = ({
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
@@ -245,14 +245,14 @@ export const AddIntegrationModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -283,13 +283,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
alt={t("workspace.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
{t("workspace.integrations.notion.notion_integration_description")}
</DialogDescription>
</div>
</div>
@@ -301,7 +301,7 @@ export const AddIntegrationModal = ({
<div>
<div className="mb-4">
<DropdownSelector
label={t("environments.integrations.notion.select_a_database")}
label={t("workspace.integrations.notion.select_a_database")}
items={databases.map((d) => ({
id: d.id,
name: (d as any).title?.[0]?.plain_text,
@@ -314,13 +314,13 @@ export const AddIntegrationModal = ({
{selectedDatabase && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>{t("common.warning")}:</strong>{" "}
{t("environments.integrations.notion.duplicate_connection_warning")}
{t("workspace.integrations.notion.duplicate_connection_warning")}
</p>
)}
<p className="m-1 text-xs text-slate-500">
{databases.length === 0 &&
t(
"environments.integrations.notion.create_at_least_one_database_to_setup_this_integration"
"workspace.integrations.notion.create_at_least_one_database_to_setup_this_integration"
)}
</p>
</div>
@@ -333,13 +333,13 @@ export const AddIntegrationModal = ({
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
</p>
</div>
{selectedDatabase && selectedSurvey && (
<div>
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
{t("workspace.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((m, idx) => (
@@ -388,7 +388,7 @@ export const AddIntegrationModal = ({
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
{selectedIntegration ? t("common.update") : t("workspace.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form>

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -50,7 +50,7 @@ export const ManageIntegration = ({
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -72,7 +72,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
{t("workspace.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
@@ -82,10 +82,10 @@ export const ManageIntegration = ({
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")}
{t("workspace.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
<TooltipContent>{t("workspace.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
@@ -93,12 +93,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.notion.link_new_database")}
{t("workspace.integrations.notion.link_new_database")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<EmptyState text={t("workspace.integrations.notion.no_databases_found")} />
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.notion.database_name")}
{t("workspace.integrations.notion.database_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
@@ -129,15 +129,15 @@ export const ManageIntegration = ({
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.notion.notion_integration")}
deleteWhat={t("workspace.integrations.notion.notion_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>

View File

@@ -8,7 +8,7 @@ import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/constants";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button";
@@ -48,7 +48,7 @@ const MappingErrorMessage = ({
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
{t("workspace.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
@@ -60,7 +60,7 @@ const MappingErrorMessage = ({
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
{t("workspace.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
@@ -194,7 +194,7 @@ export const MappingRow = ({
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
placeholder={t("workspace.integrations.notion.select_a_survey_question")}
items={filteredElementItems}
selectedItem={mapping?.[idx]?.element}
setSelectedItem={handleElementSelect}
@@ -204,7 +204,7 @@ export const MappingRow = ({
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
placeholder={t("workspace.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column}
setSelectedItem={handleColumnSelect}

View File

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
@@ -9,8 +8,8 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion";
@@ -18,7 +17,7 @@ import { authorize } from "../lib/notion";
interface NotionWrapperProps {
notionIntegration: TIntegrationNotion | undefined;
enabled: boolean;
environment: TEnvironment;
workspaceId: string;
webAppUrl: string;
surveys: TSurvey[];
databasesArray: TIntegrationNotionDatabase[];
@@ -28,7 +27,7 @@ interface NotionWrapperProps {
export const NotionWrapper = ({
notionIntegration,
enabled,
environment,
workspaceId,
webAppUrl,
surveys,
databasesArray,
@@ -43,7 +42,7 @@ export const NotionWrapper = ({
>(null);
const handleNotionAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -55,7 +54,7 @@ export const NotionWrapper = ({
{isConnected && notionIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
workspaceId={workspaceId}
surveys={surveys}
open={isModalOpen}
setOpen={setIsModalOpen}

View File

@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
const expectedHeaders = { environmentId: environmentId };
const expectedHeaders = { workspaceId: workspaceId };
afterEach(() => {
vi.clearAllMocks();
@@ -30,7 +30,7 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
@@ -47,7 +47,7 @@ describe("authorize", () => {
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",

View File

@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {

View File

@@ -11,14 +11,14 @@ const Loading = () => {
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.notion.link_database")}
{t("workspace.integrations.notion.link_database")}
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 text-center">{t("common.survey")}</div>
<div className="col-span-2 text-center">{t("environments.integrations.notion.database_name")}</div>
<div className="col-span-2 text-center">{t("workspace.integrations.notion.database_name")}</div>
<div className="col-span-2 text-center">{t("common.updated_at")}</div>
</div>
<div className="grid-cols-7">

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
@@ -14,12 +14,12 @@ import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const enabled = !!(
@@ -29,17 +29,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getSurveys(workspace.id),
getIntegrationByType(workspace.id, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
databasesArray = (await getNotionDatabases(workspace.id)) ?? [];
}
if (isReadOnly) {
@@ -49,11 +49,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={"./"} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
<PageHeader pageTitle={t("workspace.integrations.notion.notion_integration")} />
<NotionWrapper
enabled={enabled}
surveys={surveys}
environment={environment}
workspaceId={workspace.id}
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}

View File

@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/webhook";
import ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -13,15 +13,13 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`;
@@ -29,11 +27,11 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
return `${count} ${type}s`;
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, isBilling, workspace } = await getWorkspaceAuth(params.workspaceId);
const [
integrations,
@@ -43,19 +41,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
n8nwebhookCount,
activePiecesWebhookCount,
] = await Promise.all([
getIntegrations(params.environmentId),
getWebhookCountBySource(params.environmentId, "user"),
getWebhookCountBySource(params.environmentId, "zapier"),
getWebhookCountBySource(params.environmentId, "make"),
getWebhookCountBySource(params.environmentId, "n8n"),
getWebhookCountBySource(params.environmentId, "activepieces"),
getIntegrations(workspace.id),
getWebhookCountBySource(workspace.id, "user"),
getWebhookCountBySource(workspace.id, "zapier"),
getWebhookCountBySource(workspace.id, "make"),
getWebhookCountBySource(workspace.id, "n8n"),
getWebhookCountBySource(workspace.id, "activepieces"),
]);
const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type);
if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
return redirect(`/workspaces/${params.workspaceId}/settings/billing`);
}
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
@@ -64,7 +62,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
const appSetupCompleted = !!environment?.appSetupCompleted;
const appSetupCompleted = !!workspace.appSetupCompleted;
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier",
@@ -74,63 +72,63 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Zapier",
description: t("environments.integrations.zapier_integration_description"),
description: t("workspace.integrations.zapier_integration_description"),
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
connected: zapierWebhookCount > 0,
statusText: getStatusText(zapierWebhookCount, t, "zap"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"),
connectHref: `/workspaces/${params.workspaceId}/integrations/webhooks`,
connectText: t("workspace.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
docsText: t("common.docs"),
docsNewTab: true,
label: "Webhooks",
description: t("environments.integrations.webhook_integration_description"),
description: t("workspace.integrations.webhook_integration_description"),
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
connected: userWebhookCount > 0,
statusText: getStatusText(userWebhookCount, t, "webhook"),
disabled: false,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
connectHref: `/workspaces/${params.workspaceId}/integrations/google-sheets`,
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
docsText: t("common.docs"),
docsNewTab: true,
label: "Google Sheets",
description: t("environments.integrations.google_sheet_integration_description"),
description: t("workspace.integrations.google_sheet_integration_description"),
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
connected: isGoogleSheetsIntegrationConnected,
statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
connectHref: `/workspaces/${params.workspaceId}/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
docsText: t("common.docs"),
docsNewTab: true,
label: "Airtable",
description: t("environments.integrations.airtable_integration_description"),
description: t("workspace.integrations.airtable_integration_description"),
icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
connected: isAirtableIntegrationConnected,
statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
connectHref: `/workspaces/${params.workspaceId}/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
docsText: t("common.docs"),
docsNewTab: true,
label: "Slack",
description: t("environments.integrations.slack_integration_description"),
description: t("workspace.integrations.slack_integration_description"),
icon: <Image src={SlackLogo} alt="Slack Logo" />,
connected: isSlackIntegrationConnected,
statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -144,7 +142,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectHref: "https://n8n.io",
connectNewTab: true,
label: "n8n",
description: t("environments.integrations.n8n_integration_description"),
description: t("workspace.integrations.n8n_integration_description"),
icon: <Image src={n8nLogo} alt="n8n Logo" />,
connected: n8nwebhookCount > 0,
statusText: getStatusText(n8nwebhookCount, t, t("common.integration")),
@@ -158,21 +156,21 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Make.com",
description: t("environments.integrations.make_integration_description"),
description: t("workspace.integrations.make_integration_description"),
icon: <Image src={MakeLogo} alt="Make Logo" />,
connected: makeWebhookCount > 0,
statusText: getStatusText(makeWebhookCount, t, t("common.integration")),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
connectHref: `/workspaces/${params.workspaceId}/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
docsText: t("common.docs"),
docsNewTab: true,
label: "Notion",
description: t("environments.integrations.notion_integration_description"),
description: t("workspace.integrations.notion_integration_description"),
icon: <Image src={notionLogo} alt="Notion Logo" />,
connected: isNotionIntegrationConnected,
statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -186,7 +184,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Activepieces",
description: t("environments.integrations.activepieces_integration_description"),
description: t("workspace.integrations.activepieces_integration_description"),
icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />,
connected: activePiecesWebhookCount > 0,
statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")),
@@ -198,11 +196,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
connectHref: `/workspaces/${params.workspaceId}/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",
description: t("environments.integrations.website_or_app_integration_description"),
description: t("workspace.integrations.website_or_app_integration_description"),
icon: <Image src={JsLogo} alt="Javascript Logo" />,
connected: appSetupCompleted,
statusText: appSetupCompleted ? t("common.connected") : t("common.not_connected"),
@@ -212,7 +210,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
<WorkspaceConfigNavigation activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => (

View File

@@ -5,10 +5,10 @@ import { ZId } from "@formbricks/types/common";
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
const ZGetSlackChannelsAction = z.object({
environmentId: ZId,
workspaceId: ZId,
});
export const getSlackChannelsAction = authenticatedActionClient
@@ -16,19 +16,19 @@ export const getSlackChannelsAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: parsedInput.workspaceId,
minPermission: "readWrite",
},
],
});
return await getSlackChannels(parsedInput.environmentId);
return await getSlackChannels(parsedInput.workspaceId);
});

View File

@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -36,7 +36,7 @@ import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
interface AddChannelMappingModalProps {
environmentId: string;
workspaceId: string;
surveys: TSurvey[];
open: boolean;
setOpen: (v: boolean) => void;
@@ -46,7 +46,7 @@ interface AddChannelMappingModalProps {
}
export const AddChannelMappingModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -112,14 +112,14 @@ export const AddChannelMappingModal = ({
const linkChannel = async () => {
try {
if (!selectedChannel) {
throw new Error(t("environments.integrations.slack.please_select_a_channel"));
throw new Error(t("workspace.integrations.slack.please_select_a_channel"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
}
setIsLinkingChannel(true);
const integrationData: TIntegrationSlackConfigData = {
@@ -146,7 +146,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
@@ -154,9 +154,9 @@ export const AddChannelMappingModal = ({
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
@@ -190,14 +190,14 @@ export const AddChannelMappingModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -227,13 +227,13 @@ export const AddChannelMappingModal = ({
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
alt={t("workspace.integrations.slack.slack_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
{t("workspace.integrations.slack.slack_integration_description")}
</DialogDescription>
</div>
</div>
@@ -244,7 +244,7 @@ export const AddChannelMappingModal = ({
<div>
<div className="mb-4">
<DropdownSelector
label={t("environments.integrations.slack.select_channel")}
label={t("workspace.integrations.slack.select_channel")}
items={channels}
selectedItem={selectedChannel}
setSelectedItem={setSelectedChannel}
@@ -256,18 +256,18 @@ export const AddChannelMappingModal = ({
className="text-xs">
<Button variant="ghost" size="sm" className="my-2" type="button">
<CircleHelpIcon className="h-4 w-4" />
{t("environments.integrations.slack.dont_see_your_channel")}
{t("workspace.integrations.slack.dont_see_your_channel")}
</Button>
</Link>
{selectedChannel && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>{t("common.note")}:</strong>{" "}
{t("environments.integrations.slack.already_connected_another_survey")}
{t("workspace.integrations.slack.already_connected_another_survey")}
</p>
)}
<p className="m-1 text-xs text-slate-500">
{channels.length === 0 &&
t("environments.integrations.slack.create_at_least_one_channel_error")}
t("workspace.integrations.slack.create_at_least_one_channel_error")}
</p>
</div>
<div>
@@ -279,7 +279,7 @@ export const AddChannelMappingModal = ({
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
</p>
</div>
</div>
@@ -346,7 +346,7 @@ export const AddChannelMappingModal = ({
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
{selectedIntegration ? t("common.update") : t("workspace.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form>

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -52,7 +52,7 @@ export const ManageIntegration = ({
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -74,12 +74,12 @@ export const ManageIntegration = ({
<div className="mb-4 flex w-full items-center justify-between space-x-4">
<p className="text-amber-700">
<Trans
i18nKey="environments.integrations.slack.slack_reconnect_button_description"
i18nKey="workspace.integrations.slack.slack_reconnect_button_description"
components={{ b: <b /> }}
/>
</p>
<Button onClick={handleSlackAuthorization} variant="secondary">
{t("environments.integrations.slack.slack_reconnect_button")}
{t("workspace.integrations.slack.slack_reconnect_button")}
</Button>
</div>
)}
@@ -87,7 +87,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.slack.connected_with_team", {
{t("workspace.integrations.slack.connected_with_team", {
team: slackIntegration.config.key.team?.name,
})}
</span>
@@ -98,12 +98,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.slack.link_channel")}
{t("workspace.integrations.slack.link_channel")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
<EmptyState text={t("workspace.integrations.slack.connect_your_first_slack_channel")} />
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -111,7 +111,7 @@ export const ManageIntegration = ({
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.slack.channel_name")}
{t("workspace.integrations.slack.channel_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
@@ -136,15 +136,15 @@ export const ManageIntegration = ({
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.slack.slack_integration")}
deleteWhat={t("workspace.integrations.slack.slack_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>

View File

@@ -1,21 +1,20 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/lib/slack";
import slackLogo from "@/images/slacklogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
interface SlackWrapperProps {
isEnabled: boolean;
environment: TEnvironment;
workspaceId: string;
surveys: TSurvey[];
slackIntegration?: TIntegrationSlack;
webAppUrl: string;
@@ -24,7 +23,7 @@ interface SlackWrapperProps {
export const SlackWrapper = ({
isEnabled,
environment,
workspaceId,
surveys,
slackIntegration,
webAppUrl,
@@ -39,7 +38,7 @@ export const SlackWrapper = ({
>(null);
const getSlackChannels = useCallback(async () => {
const getSlackChannelsResponse = await getSlackChannelsAction({ environmentId: environment.id });
const getSlackChannelsResponse = await getSlackChannelsAction({ workspaceId });
if (
getSlackChannelsResponse?.serverError &&
@@ -52,14 +51,14 @@ export const SlackWrapper = ({
if (getSlackChannelsResponse?.data) {
setSlackChannels(getSlackChannelsResponse.data);
}
}, [environment.id]);
}, [workspaceId]);
useEffect(() => {
getSlackChannels();
}, [getSlackChannels]);
const handleSlackAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -69,7 +68,7 @@ export const SlackWrapper = ({
return isConnected && slackIntegration ? (
<>
<AddChannelMappingModal
environmentId={environment.id}
workspaceId={workspaceId}
surveys={surveys}
open={isModalOpen}
setOpen={setIsModalOpen}

View File

@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
global.fetch = vi.fn();
describe("authorize", () => {
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/slack`;
const expectedAuthUrl = "http://slack.com/auth";
@@ -24,11 +24,11 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: expectedAuthUrl } }),
} as Response);
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
headers: { workspaceId },
});
expect(authUrl).toBe(expectedAuthUrl);
});
@@ -40,11 +40,11 @@ describe("authorize", () => {
text: async () => errorText,
} as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
headers: { workspaceId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config");
});

View File

@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET",
headers: { environmentId },
headers: { workspaceId },
});
if (!res.ok) {

Some files were not shown because too many files have changed in this diff Show More