Compare commits

...

9 Commits

Author SHA1 Message Date
Johannes 056a019738 env references to clean up 2026-04-19 12:35:53 +02:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
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
1230 changed files with 38014 additions and 36748 deletions
+9
View File
@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' 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 # # MAIL SETUP #
################ ################
@@ -44,7 +44,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
className={cn( 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" "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"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>
@@ -105,7 +105,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/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"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: { const LandingLayout = async (props: {
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
return notFound(); return notFound();
} }
const projects = await getUserProjects(session.user.id, params.organizationId); const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) { if (workspaces.length !== 0) {
const firstProject = projects[0]; const firstWorkspace = workspaces[0];
const environments = await getEnvironments(firstProject.id); return redirect(`/workspaces/${firstWorkspace.id}/`);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
} }
return <>{children}</>; return <>{children}</>;
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; 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 { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -35,12 +35,12 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1"> <div className="flex-1">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="p-6"> <div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */} {/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<ProjectAndOrgSwitch <WorkspaceAndOrgSwitch
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentOrganizationName={organization.name} currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0} organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false} isLicenseActive={false}
isOwnerOrManager={false} isOwnerOrManager={false}
@@ -48,7 +48,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isMember={isMember} isMember={isMember}
isBilling={isBilling} isBilling={isBilling}
isMembershipPending={isMembershipPending} isMembershipPending={isMembershipPending}
environments={[]}
/> />
</div> </div>
<div className="flex h-full flex-col items-center justify-center space-y-12"> <div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: { const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
); );
}; };
export default ProjectOnboardingLayout; export default WorkspaceOnboardingLayout;
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; 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 { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; 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 ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <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")} subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/> />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {workspaces.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service"; import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; 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: { const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([ const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id), getOrganizationWorkspacesLimit(organization.id),
getOrganizationProjectsCount(organization.id), getOrganizationWorkspacesCount(organization.id),
]); ]);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`); return redirect(`/`);
} }
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; 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 { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; 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 ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <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")} /> <Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {workspaces.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header <Header
title={t("environments.settings.billing.select_plan_header_title")} title={t("workspace.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")} subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
/> />
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} /> <SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div> </div>
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
TProjectConfigChannel, TWorkspaceConfigChannel,
TProjectConfigIndustry, TWorkspaceConfigIndustry,
TProjectMode, TWorkspaceMode,
TProjectUpdateInput, TWorkspaceUpdateInput,
ZProjectUpdateInput, ZWorkspaceUpdateInput,
} from "@formbricks/types/project"; } from "@formbricks/types/workspace";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions"; import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { previewSurvey } from "@/app/lib/templates"; import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants"; import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; 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 { 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 { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker"; import { ColorPicker } from "@/modules/ui/components/color-picker";
import { import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select"; import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey"; import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps { interface WorkspaceSettingsProps {
organizationId: string; organizationId: string;
projectMode: TProjectMode; workspaceMode: TWorkspaceMode;
channel: TProjectConfigChannel; channel: TWorkspaceConfigChannel;
industry: TProjectConfigIndustry; industry: TWorkspaceConfigIndustry;
defaultBrandColor: string; defaultBrandColor: string;
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
userProjectsCount: number; userWorkspacesCount: number;
publicDomain: string; publicDomain: string;
} }
export const ProjectSettings = ({ export const WorkspaceSettings = ({
organizationId, organizationId,
projectMode, workspaceMode,
channel, channel,
industry, industry,
defaultBrandColor, defaultBrandColor,
organizationTeams, organizationTeams,
isAccessControlAllowed = false, isAccessControlAllowed = false,
userProjectsCount, userWorkspacesCount,
publicDomain, publicDomain,
}: ProjectSettingsProps) => { }: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => { const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try { try {
// Build the full styling from the chosen brand color so all derived // Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted. // 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). // back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light); const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({ const createWorkspaceResponse = await createWorkspaceAction({
organizationId, organizationId,
data: { data: {
...data, ...data,
@@ -81,26 +81,21 @@ export const ProjectSettings = ({
}, },
}); });
if (createProjectResponse?.data) { if (createWorkspaceResponse?.data) {
// get production environment if (globalThis.window !== undefined) {
const productionEnvironment = createProjectResponse.data.environments.find( // Remove filters when creating a new workspace
(environment) => environment.type === "production" localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
} }
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") { if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`); router.push(`/workspaces/${workspaceId}/connect`);
} else if (channel === "link") { } else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`); router.push(`/workspaces/${workspaceId}/surveys`);
} else if (projectMode === "cx") { } else if (workspaceMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`); router.push(`/workspaces/${workspaceId}/xm-templates`);
} }
} else { } else {
const errorMessage = getFormattedErrorMessage(createProjectResponse); const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (error) { } catch (error) {
@@ -109,15 +104,15 @@ export const ProjectSettings = ({
} }
}; };
const form = useForm<TProjectUpdateInput>({ const form = useForm<TWorkspaceUpdateInput>({
defaultValues: { defaultValues: {
name: "", name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } }, styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [], teamIds: [],
}, },
resolver: zodResolver(ZProjectUpdateInput), resolver: zodResolver(ZWorkspaceUpdateInput),
}); });
const projectName = form.watch("name"); const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url"); const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor; const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]); 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="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"> <div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}> <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 <FormField
control={form.control} control={form.control}
name="styling.brandColor.light" name="styling.brandColor.light"
@@ -184,7 +179,7 @@ export const ProjectSettings = ({
)} )}
/> />
{isAccessControlAllowed && userProjectsCount > 0 && ( {isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="teamIds" name="teamIds"
@@ -242,7 +237,7 @@ export const ProjectSettings = ({
<SurveyInline <SurveyInline
appUrl={publicDomain} appUrl={publicDomain}
isPreviewMode={true} isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)} survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling} styling={previewStyling}
isBrandingEnabled={false} isBrandingEnabled={false}
languageCode="default" languageCode="default"
@@ -2,30 +2,34 @@ import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors"; 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 { 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 { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service"; import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps { interface WorkspaceSettingsPageProps {
params: Promise<{ params: Promise<{
organizationId: string; organizationId: string;
}>; }>;
searchParams: Promise<{ searchParams: Promise<{
channel?: TProjectConfigChannel; channel?: TWorkspaceConfigChannel;
industry?: TProjectConfigIndustry; industry?: TWorkspaceConfigIndustry;
mode?: TProjectMode; mode?: TWorkspaceMode;
}>; }>;
} }
const Page = async (props: ProjectSettingsPageProps) => { const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -39,7 +43,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null; const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null; const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys"; 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); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -57,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")} title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")} subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/> />
<ProjectSettings <WorkspaceSettings
organizationId={params.organizationId} organizationId={params.organizationId}
projectMode={mode} workspaceMode={mode}
channel={channel} channel={channel}
industry={industry} industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR} defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length} userWorkspacesCount={workspaces.length}
publicDomain={publicDomain} publicDomain={publicDomain}
/> />
{projects.length >= 1 && ( {workspaces.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps { interface ConnectWithFormbricksProps {
environment: TEnvironment; workspaceId: string;
publicDomain: string; publicDomain: string;
appSetupCompleted: boolean; appSetupCompleted: boolean;
channel: TProjectConfigChannel; channel: TWorkspaceConfigChannel;
} }
export const ConnectWithFormbricks = ({ export const ConnectWithFormbricks = ({
environment, workspaceId,
publicDomain, publicDomain,
appSetupCompleted, appSetupCompleted,
channel, channel,
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const handleFinishOnboarding = async () => { const handleFinishOnboarding = async () => {
router.push(`/environments/${environment.id}/surveys`); router.push(`/workspaces/${workspaceId}/surveys`);
}; };
useEffect(() => { useEffect(() => {
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10"> <div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4"> <div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions <OnboardingSetupInstructions
environmentId={environment.id} workspaceId={workspaceId}
publicDomain={publicDomain} publicDomain={publicDomain}
channel={channel} channel={channel}
appSetupCompleted={appSetupCompleted} appSetupCompleted={appSetupCompleted}
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
)}> )}>
{appSetupCompleted ? ( {appSetupCompleted ? (
<div> <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"> <p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")} {t("workspace.connect.connection_successful_message")}
</p> </p>
</div> </div>
) : ( ) : (
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <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> </p>
</div> </div>
)} )}
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding" id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"} variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}> onClick={handleFinishOnboarding}>
{appSetupCompleted {appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
<ArrowRight /> <ArrowRight />
</Button> </Button>
</div> </div>
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; 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 { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block"; import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons"; import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
]; ];
interface OnboardingSetupInstructionsProps { interface OnboardingSetupInstructionsProps {
environmentId: string; workspaceId: string;
publicDomain: string; publicDomain: string;
channel: TProjectConfigChannel; channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean; appSetupCompleted: boolean;
} }
export const OnboardingSetupInstructions = ({ export const OnboardingSetupInstructions = ({
environmentId, workspaceId,
publicDomain, publicDomain,
channel, channel,
appSetupCompleted, appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${publicDomain}"; var appUrl = "${publicDomain}";
var environmentId = "${environmentId}"; 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({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 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> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${publicDomain}"; var appUrl = "${publicDomain}";
var environmentId = "${environmentId}"; 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({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 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> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
const npmSnippetForAppSurveys = ` const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
environmentId: "${environmentId}", workspaceId: "${workspaceId}",
appUrl: "${publicDomain}", appUrl: "${publicDomain}",
}); });
} }
function App() { function App() {
// your own app // your own app
} }
export default App; export default App;
`; `;
const npmSnippetForWebsiteSurveys = ` const npmSnippetForWebsiteSurveys = `
// other imports // other imports
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
environmentId: "${environmentId}", workspaceId: "${workspaceId}",
appUrl: "${publicDomain}", appUrl: "${publicDomain}",
}); });
} }
function App() { function App() {
// your own app // your own app
} }
export default App; export default App;
`; `;
return ( return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
yarn add @formbricks/js yarn add @formbricks/js
</CodeBlock> </CodeBlock>
<p className="text-sm text-slate-700"> <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> </p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys} {channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? ( ) : activeTab === "html" ? (
<div className="prose prose-slate"> <div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700"> <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> </p>
<div> <div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
@@ -1,56 +1,50 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps { interface ConnectPageProps {
params: Promise<{ params: Promise<{
environmentId: string; workspaceId: string;
}>; }>;
} }
const Page = async (props: ConnectPageProps) => { const Page = async (props: ConnectPageProps) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) { const workspace = await getWorkspace(params.workspaceId);
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
} }
const project = await getProjectByEnvironmentId(environment.id); const channel = workspace.config.channel || null;
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
return ( return (
<div className="flex min-h-full flex-col items-center justify-center py-10"> <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"> <div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p> <p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p> <p className="text-sm text-slate-500"></p>
</div> </div>
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} workspaceId={params.workspaceId}
publicDomain={publicDomain} publicDomain={publicDomain}
appSetupCompleted={environment.appSetupCompleted} appSetupCompleted={workspace.appSetupCompleted}
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} /> <XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link> </Link>
</Button> </Button>
@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; 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"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>; params: Promise<{ workspaceId: string }>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const params = await props.params; const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId); const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
if (!isAuthorized) { 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>; return <div className="flex-1 bg-slate-50">{children}</div>;
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types"; import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils"; import { TWorkspace } from "@formbricks/types/workspace";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; 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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions"; import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps { interface XMTemplateListProps {
project: TProject; workspace: TWorkspace;
user: TUser; 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 [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
createdBy: user.id, createdBy: user.id,
}; };
const createSurveyResponse = await createSurveyAction({ const createSurveyResponse = await createSurveyAction({
environmentId: environmentId, workspaceId: workspaceId,
surveyBody: augmentedTemplate, surveyBody: augmentedTemplate,
}); });
if (createSurveyResponse?.data) { 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 { } else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse); const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage); toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => { const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx); setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx]; const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project); const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate); createSurvey(newTemplate);
}; };
const XMTemplateOptions = [ const XMTemplateOptions = [
{ {
title: t("environments.xm-templates.nps"), title: t("workspace.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"), description: t("workspace.xm-templates.nps_description"),
icon: ShoppingCartIcon, icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0), onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0, isLoading: activeTemplateId === 0,
}, },
{ {
title: t("environments.xm-templates.five_star_rating"), title: t("workspace.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"), description: t("workspace.xm-templates.five_star_rating_description"),
icon: StarIcon, icon: StarIcon,
onClick: () => handleTemplateClick(1), onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1, isLoading: activeTemplateId === 1,
}, },
{ {
title: t("environments.xm-templates.csat"), title: t("workspace.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"), description: t("workspace.xm-templates.csat_description"),
icon: ThumbsUpIcon, icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2), onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2, isLoading: activeTemplateId === 2,
}, },
{ {
title: t("environments.xm-templates.ces"), title: t("workspace.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"), description: t("workspace.xm-templates.ces_description"),
icon: ActivityIcon, icon: ActivityIcon,
onClick: () => handleTemplateClick(3), onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3, isLoading: activeTemplateId === 3,
}, },
{ {
title: t("environments.xm-templates.smileys"), title: t("workspace.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"), description: t("workspace.xm-templates.smileys_description"),
icon: SmileIcon, icon: SmileIcon,
onClick: () => handleTemplateClick(4), onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4, isLoading: activeTemplateId === 4,
}, },
{ {
title: t("environments.xm-templates.enps"), title: t("workspace.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"), description: t("workspace.xm-templates.enps_description"),
icon: UsersIcon, icon: UsersIcon,
onClick: () => handleTemplateClick(5), onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5, isLoading: activeTemplateId === 5,
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
// Mock data // Mock data
const mockProject: TProject = { const mockWorkspace: TWorkspace = {
id: "project1", id: "workspace1",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
name: "Test Project", name: "Test Workspace",
organizationId: "org1", organizationId: "org1",
styling: { styling: {
allowStyleOverwrite: true, allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockProject: TProject = {
placement: "bottomRight", placement: "bottomRight",
clickOutsideClose: true, clickOutsideClose: true,
overlay: "none", overlay: "none",
environments: [], appSetupCompleted: false,
languages: [], languages: [],
logo: null, logo: null,
}; };
const mockTemplate: TXMTemplate = { const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey", name: "$[workspaceName] Survey",
blocks: [ blocks: [
{ {
id: "block1", id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1", id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText, type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[projectName] Question" }, headline: { default: "$[workspaceName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup(); cleanup();
}); });
test("replaces projectName placeholder in template name", () => { test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject); const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Project Survey"); expect(result.name).toBe("Test Workspace Survey");
}); });
test("replaces projectName placeholder in element headline", () => { test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject); const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question"); expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
}); });
test("returns a new object without mutating the original template", () => { test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate); const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject); const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate); expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate); expect(mockTemplate).toEqual(originalTemplate);
}); });
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates"; import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template // replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => { export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template); const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({ const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block, ...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 };
}; };
@@ -2,11 +2,9 @@ import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; 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 { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -14,15 +12,15 @@ import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps { interface XMTemplatePageProps {
params: Promise<{ params: Promise<{
environmentId: string; workspaceId: string;
}>; }>;
} }
const Page = async (props: XMTemplatePageProps) => { const Page = async (props: XMTemplatePageProps) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new AuthenticationError(t("common.not_authenticated"));
} }
@@ -31,29 +29,24 @@ const Page = async (props: XMTemplatePageProps) => {
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); 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 workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <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")} /> <Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{projects.length >= 2 && ( {workspaces.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} /> <XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link> </Link>
</Button> </Button>
@@ -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;
@@ -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;
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId"; const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
export const ConfirmationPage = () => { export const ConfirmationPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null); const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
return; return;
} }
const storedEnvironmentId = globalThis.window.sessionStorage.getItem( const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY if (storedWorkspaceId) {
); setResolvedWorkspaceId(storedWorkspaceId);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
} }
}, []); }, []);
@@ -41,12 +39,7 @@ export const ConfirmationPage = () => {
</p> </p>
</div> </div>
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link <Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
</Button> </Button>
@@ -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"; import { cn } from "@/lib/cn";
export const LoadingCard = ({ export const LoadingCard = ({
@@ -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;
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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;
@@ -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;
@@ -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;
@@ -1,3 +0,0 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -1,3 +0,0 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading;
@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;
@@ -1,3 +0,0 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;
@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;
@@ -1,4 +0,0 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default ProjectLookSettingsLoading;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;
@@ -1,3 +0,0 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default ProjectSettingsPage;
@@ -1,3 +0,0 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading;
@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;
@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;
@@ -0,0 +1,3 @@
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -0,0 +1,3 @@
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -0,0 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
export default GeneralSettingsLoading;
@@ -0,0 +1,3 @@
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
export default GeneralSettingsPage;
@@ -8,15 +8,14 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId, getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId, getOrganizationIdFromWorkspaceId,
getProjectIdFromIntegrationId, getWorkspaceIdFromIntegrationId,
} from "@/lib/utils/helper"; } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZCreateOrUpdateIntegrationAction = z.object({ const ZCreateOrUpdateIntegrationAction = z.object({
environmentId: ZId, workspaceId: ZId,
integrationData: ZIntegrationInput, integrationData: ZIntegrationInput,
}); });
@@ -24,7 +23,7 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
.inputSchema(ZCreateOrUpdateIntegrationAction) .inputSchema(ZCreateOrUpdateIntegrationAction)
.action( .action(
withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => { withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -35,15 +34,15 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "projectTeam", type: "workspaceTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), workspaceId: parsedInput.workspaceId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = organizationId; 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.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result; ctx.auditLoggingCtx.newObject = result;
@@ -73,8 +72,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "projectTeam", type: "workspaceTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId), workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -17,9 +17,9 @@ import {
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; 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 { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown"; import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable"; import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg"; import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
@@ -93,7 +93,7 @@ type EditModeProps =
type AddIntegrationModalProps = { type AddIntegrationModalProps = {
open: boolean; open: boolean;
setOpenWithStates: (v: boolean) => void; setOpenWithStates: (v: boolean) => void;
environmentId: string; workspaceId: string;
airtableArray: TIntegrationItem[]; airtableArray: TIntegrationItem[];
surveys: TSurvey[]; surveys: TSurvey[];
airtableIntegration: TIntegrationAirtable; airtableIntegration: TIntegrationAirtable;
@@ -103,8 +103,8 @@ const NoBaseFoundError = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Alert> <Alert>
<AlertTitle>{t("environments.integrations.airtable.no_bases_found")}</AlertTitle> <AlertTitle>{t("workspace.integrations.airtable.no_bases_found")}</AlertTitle>
<AlertDescription>{t("environments.integrations.airtable.please_create_a_base")}</AlertDescription> <AlertDescription>{t("workspace.integrations.airtable.please_create_a_base")}</AlertDescription>
</Alert> </Alert>
); );
}; };
@@ -172,7 +172,7 @@ const renderElementSelection = ({
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
open, open,
setOpenWithStates, setOpenWithStates,
environmentId, workspaceId,
airtableArray, airtableArray,
surveys, surveys,
airtableIntegration, airtableIntegration,
@@ -227,19 +227,19 @@ export const AddIntegrationModal = ({
const submitHandler = async (data: IntegrationModalInputs) => { const submitHandler = async (data: IntegrationModalInputs) => {
try { try {
if (!data.base || data.base === "") { 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 === "") { 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) { 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) { 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); const currentTable = tables.find((item) => item.id === data.table);
@@ -270,7 +270,7 @@ export const AddIntegrationModal = ({
} }
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: airtableIntegrationData, integrationData: airtableIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
@@ -278,9 +278,9 @@ export const AddIntegrationModal = ({
return; return;
} }
if (isEditMode) { if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully")); toast.success(t("workspace.integrations.integration_updated_successfully"));
} else { } else {
toast.success(t("environments.integrations.integration_added_successfully")); toast.success(t("workspace.integrations.integration_added_successfully"));
} }
handleClose(); handleClose();
} catch (e) { } catch (e) {
@@ -289,7 +289,7 @@ export const AddIntegrationModal = ({
}; };
const handleTable = async (baseId: string) => { const handleTable = async (baseId: string) => {
const data = await fetchTables(environmentId, baseId); const data = await fetchTables(workspaceId, baseId);
if (data.tables) { if (data.tables) {
setTables(data.tables); setTables(data.tables);
@@ -312,7 +312,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData); const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1); integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData }); const result = await createOrUpdateIntegrationAction({ workspaceId, integrationData });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
@@ -320,7 +320,7 @@ export const AddIntegrationModal = ({
handleClose(); handleClose();
router.refresh(); router.refresh();
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Unknown error occurred"); toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} }
@@ -336,13 +336,13 @@ export const AddIntegrationModal = ({
fill fill
className="object-contain object-center" className="object-contain object-center"
src={AirtableLogo} src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")} alt={t("workspace.integrations.airtable.airtable_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <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> <DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")} {t("workspace.integrations.airtable.sync_responses_with_airtable")}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -364,7 +364,7 @@ export const AddIntegrationModal = ({
)} )}
<div className="flex w-full flex-col"> <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"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
@@ -427,7 +427,7 @@ export const AddIntegrationModal = ({
</div> </div>
) : ( ) : (
<p className="m-1 text-xs text-slate-500"> <p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")} {t("workspace.integrations.create_survey_warning")}
</p> </p>
)} )}
@@ -5,13 +5,13 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration"; import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable"; import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg"; import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration"; import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
interface AirtableWrapperProps { interface AirtableWrapperProps {
environmentId: string; workspaceId: string;
airtableArray: TIntegrationItem[]; airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable; airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[]; surveys: TSurvey[];
@@ -21,7 +21,7 @@ interface AirtableWrapperProps {
} }
export const AirtableWrapper = ({ export const AirtableWrapper = ({
environmentId, workspaceId,
airtableArray, airtableArray,
airtableIntegration, airtableIntegration,
surveys, surveys,
@@ -34,7 +34,7 @@ export const AirtableWrapper = ({
); );
const handleAirtableAuthorization = async () => { const handleAirtableAuthorization = async () => {
authorize(environmentId, webAppUrl).then((url: string) => { authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) { if (url) {
window.location.replace(url); window.location.replace(url);
} }
@@ -44,7 +44,7 @@ export const AirtableWrapper = ({
return isConnected && airtableIntegration ? ( return isConnected && airtableIntegration ? (
<ManageIntegration <ManageIntegration
airtableArray={airtableArray} airtableArray={airtableArray}
environmentId={environmentId} workspaceId={workspaceId}
airtableIntegration={airtableIntegration} airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected} setIsConnected={setIsConnected}
surveys={surveys} surveys={surveys}
@@ -33,7 +33,7 @@ export const BaseSelectDropdown = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex w-full flex-col"> <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"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; 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 { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal"; import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -19,7 +19,7 @@ import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps { interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable; airtableIntegration: TIntegrationAirtable;
environmentId: string; workspaceId: string;
setIsConnected: (data: boolean) => void; setIsConnected: (data: boolean) => void;
surveys: TSurvey[]; surveys: TSurvey[];
airtableArray: TIntegrationItem[]; airtableArray: TIntegrationItem[];
@@ -27,12 +27,12 @@ interface ManageIntegrationProps {
} }
export const ManageIntegration = (props: ManageIntegrationProps) => { export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props; const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const tableHeaders = [ const tableHeaders = [
t("common.survey"), t("common.survey"),
t("environments.integrations.airtable.table_name"), t("workspace.integrations.airtable.table_name"),
t("common.questions"), t("common.questions"),
t("common.updated_at"), t("common.updated_at"),
]; ];
@@ -53,7 +53,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
}); });
if (deleteIntegrationActionResult?.data) { if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false); setIsConnected(false);
} else { } else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult); const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -77,7 +77,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span> <span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500"> <span className="cursor-pointer text-slate-500">
{t("environments.integrations.connected_with_email", { {t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email, email: airtableIntegration.config.email,
})} })}
</span> </span>
@@ -87,7 +87,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
setDefaultValues(null); setDefaultValues(null);
handleModal(true); handleModal(true);
}}> }}>
{t("environments.integrations.airtable.link_new_table")} {t("workspace.integrations.airtable.link_new_table")}
</Button> </Button>
</div> </div>
@@ -130,21 +130,21 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div> </div>
) : ( ) : (
<div className="mt-4 w-full"> <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> </div>
)} )}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4"> <Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon /> <Trash2Icon />
{t("environments.integrations.delete_integration")} {t("workspace.integrations.delete_integration")}
</Button> </Button>
<DeleteDialog <DeleteDialog
open={isDeleteIntegrationModalOpen} open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen} setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.airtable.airtable_integration")} deleteWhat={t("workspace.integrations.airtable.airtable_integration")}
onDelete={handleDeleteIntegration} onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")} text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting} isDeleting={isDeleting}
/> />
@@ -153,7 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
airtableArray={airtableArray} airtableArray={airtableArray}
open={isModalOpen} open={isModalOpen}
setOpenWithStates={handleModal} setOpenWithStates={handleModal}
environmentId={environmentId} workspaceId={workspaceId}
surveys={surveys} surveys={surveys}
airtableIntegration={airtableIntegration} airtableIntegration={airtableIntegration}
{...data} {...data}
@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
// Mock fetch // Mock fetch
global.fetch = vi.fn(); global.fetch = vi.fn();
const environmentId = "test-env-id"; const workspaceId = "test-env-id";
const baseId = "test-base-id"; const baseId = "test-base-id";
const apiHost = "http://localhost:3000"; const apiHost = "http://localhost:3000";
@@ -36,11 +36,11 @@ describe("Airtable Library", () => {
}; };
vi.mocked(fetch).mockResolvedValue(mockResponse as Response); 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}`, { expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId: workspaceId },
cache: "no-store", cache: "no-store",
}); });
expect(tables).toEqual(mockTables); expect(tables).toEqual(mockTables);
@@ -56,11 +56,11 @@ describe("Airtable Library", () => {
}; };
vi.mocked(fetch).mockResolvedValue(mockResponse as Response); 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`, { expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId: workspaceId },
}); });
expect(authUrl).toBe(mockAuthUrl); expect(authUrl).toBe(mockAuthUrl);
}); });
@@ -73,11 +73,11 @@ describe("Airtable Library", () => {
}; };
vi.mocked(fetch).mockResolvedValue(mockResponse as Response); 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`, { expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId: workspaceId },
}); });
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config"); expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
}); });
@@ -1,20 +1,20 @@
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable"; 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}`, { const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId },
cache: "no-store", cache: "no-store",
}); });
const resJson = await res.json(); const resJson = await res.json();
return resJson.data as Promise<TIntegrationAirtableTables>; 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`, { const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId },
}); });
if (!res.ok) { if (!res.ok) {
@@ -1,28 +1,28 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service"; import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants"; import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; 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 params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID; 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([ const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId), getSurveys(workspace.id),
getIntegrations(params.environmentId), getIntegrations(workspace.id),
getUserLocale(session.user.id), getUserLocale(session.user.id),
]); ]);
@@ -32,7 +32,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
let airtableArray: TIntegrationItem[] = []; let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration?.config.key) { if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId); airtableArray = await getAirtableTables(workspace.id);
} }
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
@@ -40,14 +40,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} /> <GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} /> <PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full"> <div className="h-[75vh] w-full">
<AirtableWrapper <AirtableWrapper
isEnabled={isEnabled} isEnabled={isEnabled}
airtableIntegration={airtableIntegration} airtableIntegration={airtableIntegration}
airtableArray={airtableArray} airtableArray={airtableArray}
environmentId={environment.id} workspaceId={workspace.id}
surveys={surveys} surveys={surveys}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale ?? DEFAULT_LOCALE}
@@ -10,10 +10,10 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; 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({ const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId, workspaceId: ZId,
}); });
export const validateGoogleSheetsConnectionAction = authenticatedActionClient export const validateGoogleSheetsConnectionAction = authenticatedActionClient
@@ -21,21 +21,21 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [ access: [
{ {
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "projectTeam", type: "workspaceTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), workspaceId: parsedInput.workspaceId,
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
}); });
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets"); const integration = await getIntegrationByType(parsedInput.workspaceId, "googleSheets");
if (!integration) { if (!integration) {
return { data: false }; return { data: false };
} }
@@ -46,7 +46,7 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
const ZGetSpreadsheetNameByIdAction = z.object({ const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets, googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(), workspaceId: z.string(),
spreadsheetId: z.string(), spreadsheetId: z.string(),
}); });
@@ -55,15 +55,15 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [ access: [
{ {
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "projectTeam", type: "workspaceTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), workspaceId: parsedInput.workspaceId,
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -12,13 +12,13 @@ import {
} from "@formbricks/types/integration/google-sheet"; } from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; 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 { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions"; import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import { import {
constructGoogleSheetsUrl, constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl, extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl, 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 GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION, GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
@@ -44,7 +44,7 @@ import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps { interface AddIntegrationModalProps {
environmentId: string; workspaceId: string;
open: boolean; open: boolean;
surveys: TSurvey[]; surveys: TSurvey[];
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
@@ -53,7 +53,7 @@ interface AddIntegrationModalProps {
} }
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
environmentId, workspaceId,
surveys, surveys,
open, open,
setOpen, setOpen,
@@ -125,9 +125,9 @@ export const AddIntegrationModal = ({
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => { const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response); const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) { 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) { } 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 { } else {
toast.error(errorMessage); toast.error(errorMessage);
} }
@@ -136,19 +136,19 @@ export const AddIntegrationModal = ({
const linkSheet = async () => { const linkSheet = async () => {
try { try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) { 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) { 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) { 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); setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl); const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({ const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration, googleSheetIntegration,
environmentId, workspaceId,
spreadsheetId, spreadsheetId,
}); });
@@ -180,7 +180,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.push(integrationData); googleSheetIntegrationData.config.data.push(integrationData);
} }
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: googleSheetIntegrationData, integrationData: googleSheetIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
@@ -188,9 +188,9 @@ export const AddIntegrationModal = ({
return; return;
} }
if (selectedIntegration) { if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully")); toast.success(t("workspace.integrations.integration_updated_successfully"));
} else { } else {
toast.success(t("environments.integrations.integration_added_successfully")); toast.success(t("workspace.integrations.integration_added_successfully"));
} }
resetForm(); resetForm();
setOpen(false); setOpen(false);
@@ -227,14 +227,14 @@ export const AddIntegrationModal = ({
try { try {
setIsDeleting(true); setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: googleSheetIntegrationData, integrationData: googleSheetIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
} }
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred"); toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -253,13 +253,13 @@ export const AddIntegrationModal = ({
fill fill
className="object-contain object-center" className="object-contain object-center"
src={GoogleSheetLogo} src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")} alt={t("workspace.integrations.google_sheets.google_sheet_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <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> <DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")} {t("workspace.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -269,7 +269,7 @@ export const AddIntegrationModal = ({
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
<Label>{t("environments.integrations.google_sheets.spreadsheet_url")}</Label> <Label>{t("workspace.integrations.google_sheets.spreadsheet_url")}</Label>
<Input <Input
value={spreadsheetUrl} value={spreadsheetUrl}
onChange={(e) => setSpreadsheetUrl(e.target.value)} onChange={(e) => setSpreadsheetUrl(e.target.value)}
@@ -286,7 +286,7 @@ export const AddIntegrationModal = ({
disabled={surveys.length === 0} disabled={surveys.length === 0}
/> />
<p className="m-1 text-xs text-slate-500"> <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> </p>
</div> </div>
</div> </div>
@@ -361,7 +361,7 @@ export const AddIntegrationModal = ({
<Button type="submit" loading={isLinkingSheet}> <Button type="submit" loading={isLinkingSheet}>
{selectedIntegration {selectedIntegration
? t("common.update") ? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")} : t("workspace.integrations.google_sheets.link_google_sheet")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -1,16 +1,15 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { import {
TIntegrationGoogleSheets, TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData, TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet"; } from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions"; import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration"; import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google"; import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png"; import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants"; import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration"; import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -18,7 +17,7 @@ import { AddIntegrationModal } from "./AddIntegrationModal";
interface GoogleSheetWrapperProps { interface GoogleSheetWrapperProps {
isEnabled: boolean; isEnabled: boolean;
environment: TEnvironment; workspaceId: string;
surveys: TSurvey[]; surveys: TSurvey[];
googleSheetIntegration?: TIntegrationGoogleSheets; googleSheetIntegration?: TIntegrationGoogleSheets;
webAppUrl: string; webAppUrl: string;
@@ -27,7 +26,7 @@ interface GoogleSheetWrapperProps {
export const GoogleSheetWrapper = ({ export const GoogleSheetWrapper = ({
isEnabled, isEnabled,
environment, workspaceId,
surveys, surveys,
googleSheetIntegration, googleSheetIntegration,
webAppUrl, webAppUrl,
@@ -44,18 +43,18 @@ export const GoogleSheetWrapper = ({
const validateConnection = useCallback(async () => { const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return; if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id }); const response = await validateGoogleSheetsConnectionAction({ workspaceId });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) { if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true); setShowReconnectButton(true);
} }
}, [environment.id, isConnected, googleSheetIntegration]); }, [workspaceId, isConnected, googleSheetIntegration]);
useEffect(() => { useEffect(() => {
validateConnection(); validateConnection();
}, [validateConnection]); }, [validateConnection]);
const handleGoogleAuthorization = async () => { const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => { authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) { if (url) {
window.location.replace(url); window.location.replace(url);
} }
@@ -67,7 +66,7 @@ export const GoogleSheetWrapper = ({
{isConnected && googleSheetIntegration ? ( {isConnected && googleSheetIntegration ? (
<> <>
<AddIntegrationModal <AddIntegrationModal
environmentId={environment.id} workspaceId={workspaceId}
surveys={surveys} surveys={surveys}
open={isModalOpen} open={isModalOpen}
setOpen={setIsModalOpen} setOpen={setIsModalOpen}
@@ -9,7 +9,7 @@ import {
TIntegrationGoogleSheetsConfigData, TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet"; } from "@formbricks/types/integration/google-sheet";
import { TUserLocale } from "@formbricks/types/user"; 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 { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
@@ -53,7 +53,7 @@ export const ManageIntegration = ({
}); });
if (deleteIntegrationActionResult?.data) { if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false); setIsConnected(false);
} else { } else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult); const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -77,10 +77,10 @@ export const ManageIntegration = ({
{showReconnectButton && ( {showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full"> <Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription> <AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")} {t("workspace.integrations.google_sheets.reconnect_button_description")}
</AlertDescription> </AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}> <AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")} {t("workspace.integrations.google_sheets.reconnect_button")}
</AlertButton> </AlertButton>
</Alert> </Alert>
)} )}
@@ -88,7 +88,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center"> <div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span> <span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500"> <span className="text-slate-500">
{t("environments.integrations.connected_with_email", { {t("workspace.integrations.connected_with_email", {
email: googleSheetIntegration.config.email, email: googleSheetIntegration.config.email,
})} })}
</span> </span>
@@ -98,11 +98,11 @@ export const ManageIntegration = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}> <Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" /> <RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")} {t("workspace.integrations.google_sheets.reconnect_button")}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")} {t("workspace.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -111,12 +111,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null); setSelectedIntegration(null);
setOpenAddIntegrationModal(true); setOpenAddIntegrationModal(true);
}}> }}>
{t("environments.integrations.google_sheets.link_new_sheet")} {t("workspace.integrations.google_sheets.link_new_sheet")}
</Button> </Button>
</div> </div>
{!integrationArray || integrationArray.length === 0 ? ( {!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full"> <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>
) : ( ) : (
<div className="mt-4 flex w-full flex-col items-center justify-center"> <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="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("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block"> <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>
<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.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</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"> <Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon /> <Trash2Icon />
{t("environments.integrations.delete_integration")} {t("workspace.integrations.delete_integration")}
</Button> </Button>
<DeleteDialog <DeleteDialog
open={isDeleteIntegrationModalOpen} open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen} setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.google_sheets.google_connection")} deleteWhat={t("workspace.integrations.google_sheets.google_connection")}
onDelete={handleDeleteIntegration} onDelete={handleDeleteIntegration}
text={t("environments.integrations.google_sheets.google_connection_deletion_description")} text={t("workspace.integrations.google_sheets.google_connection_deletion_description")}
isDeleting={isDeleting} isDeleting={isDeleting}
/> />
</div> </div>
@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch); vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => { describe("authorize", () => {
const environmentId = "test-env-id"; const workspaceId = "test-env-id";
const apiHost = "http://test.com"; const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`; const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId }; const expectedHeaders = { workspaceId: workspaceId };
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -30,7 +30,7 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: mockAuthUrl } }), json: async () => ({ data: { authUrl: mockAuthUrl } }),
}); });
const authUrl = await authorize(environmentId, apiHost); const authUrl = await authorize(workspaceId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
@@ -47,7 +47,7 @@ describe("authorize", () => {
text: async () => errorText, 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, { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger"; 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`, { const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId },
}); });
if (!res.ok) { if (!res.ok) {
@@ -11,7 +11,7 @@ const Loading = () => {
<GoBackButton /> <GoBackButton />
<div className="mb-6 text-right"> <div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"> <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> </Button>
</div> </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="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("common.survey")}</div>
<div className="col-span-4 text-center"> <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>
<div className="col-span-2 text-center">{t("common.questions")}</div> <div className="col-span-2 text-center">{t("common.questions")}</div>
<div className="col-span-2 text-center">{t("common.updated_at")}</div> <div className="col-span-2 text-center">{t("common.updated_at")}</div>
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
@@ -12,21 +12,21 @@ import {
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; 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 params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); 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([ const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId), getSurveys(workspace.id),
getIntegrations(params.environmentId), getIntegrations(workspace.id),
getUserLocale(session.user.id), getUserLocale(session.user.id),
]); ]);
@@ -39,12 +39,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} /> <GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} /> <PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full"> <div className="h-[75vh] w-full">
<GoogleSheetWrapper <GoogleSheetWrapper
isEnabled={isEnabled} isEnabled={isEnabled}
environment={environment} workspaceId={workspace.id}
surveys={surveys} surveys={surveys}
googleSheetIntegration={googleSheetIntegration} googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
@@ -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 // Use 'as any' to bypass complex type matching for mock data
const mockPrismaSurveys = [ const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() }, { 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"], welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false }, hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file type: "app", // Changed type to web to match original file
environmentId: environmentId, workspaceId: workspaceId,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
languages: [], languages: [],
@@ -80,7 +80,7 @@ const mockTransformedSurveys: TSurvey[] = [
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false }, hiddenFields: { enabled: false },
type: "app", type: "app",
environmentId: environmentId, workspaceId: workspaceId,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
languages: [], languages: [],
@@ -98,14 +98,14 @@ describe("getSurveys", () => {
return { ...found } as TSurvey; return { ...found } as TSurvey;
}); });
const surveys = await getSurveys(environmentId); const surveys = await getSurveys(workspaceId);
expect(surveys).toEqual(mockTransformedSurveys); expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check // 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({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { where: {
environmentId, workspaceId,
status: { status: {
not: "completed", not: "completed",
}, },
@@ -129,7 +129,7 @@ describe("getSurveys", () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError); 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"); expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
// React cache is already mocked globally - no need to check it here // 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); 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(); expect(logger.error).not.toHaveBeenCalled();
// React cache is already mocked globally - no need to check it here // React cache is already mocked globally - no need to check it here
}); });
@@ -10,13 +10,13 @@ import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils"; import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => { export const getSurveys = reactCache(async (workspaceId: string): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]); validateInputs([workspaceId, ZId]);
try { try {
const surveysPrisma = await prisma.survey.findMany({ const surveysPrisma = await prisma.survey.findMany({
where: { where: {
environmentId, workspaceId,
status: { status: {
not: "completed", not: "completed",
}, },
@@ -14,7 +14,7 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
const environmentId = "test-environment-id"; const workspaceId = "test-workspace-id";
const sourceZapier = "zapier"; const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => { describe("getWebhookCountBySource", () => {
@@ -26,16 +26,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 5; const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount); vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier); const count = await getWebhookCountBySource(workspaceId, sourceZapier);
expect(count).toBe(mockCount); expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith( expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)], [workspaceId, expect.any(Object)],
[sourceZapier, expect.any(Object)] [sourceZapier, expect.any(Object)]
); );
expect(prisma.webhook.count).toHaveBeenCalledWith({ expect(prisma.webhook.count).toHaveBeenCalledWith({
where: { where: {
environmentId, workspaceId: workspaceId,
source: sourceZapier, source: sourceZapier,
}, },
}); });
@@ -45,16 +45,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 10; const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount); vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId); const count = await getWebhookCountBySource(workspaceId);
expect(count).toBe(mockCount); expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith( expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)], [workspaceId, expect.any(Object)],
[undefined, expect.any(Object)] [undefined, expect.any(Object)]
); );
expect(prisma.webhook.count).toHaveBeenCalledWith({ expect(prisma.webhook.count).toHaveBeenCalledWith({
where: { where: {
environmentId, workspaceId: workspaceId,
source: undefined, source: undefined,
}, },
}); });
@@ -67,7 +67,7 @@ describe("getWebhookCountBySource", () => {
}); });
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError); 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); expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
}); });
@@ -75,7 +75,7 @@ describe("getWebhookCountBySource", () => {
const genericError = new Error("Something went wrong"); const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError); 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); expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
export const getWebhookCountBySource = async ( export const getWebhookCountBySource = async (
environmentId: string, workspaceId: string,
source?: Webhook["source"] source?: Webhook["source"]
): Promise<number> => { ): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]); validateInputs([workspaceId, ZId], [source, z.string().optional()]);
try { try {
const count = await prisma.webhook.count({ const count = await prisma.webhook.count({
where: { where: {
environmentId, workspaceId,
source, source,
}, },
}); });
@@ -15,12 +15,12 @@ import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; 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 { import {
MappingRow, MappingRow,
TMapping, TMapping,
createEmptyMapping, 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 NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall"; 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"; import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps { interface AddIntegrationModalProps {
environmentId: string; workspaceId: string;
surveys: TSurvey[]; surveys: TSurvey[];
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -49,7 +49,7 @@ interface AddIntegrationModalProps {
} }
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
environmentId, workspaceId,
surveys, surveys,
open, open,
setOpen, setOpen,
@@ -175,18 +175,18 @@ export const AddIntegrationModal = ({
const linkDatabase = async () => { const linkDatabase = async () => {
try { try {
if (!selectedDatabase) { 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) { 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)) { 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) { 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 ( if (
@@ -194,7 +194,7 @@ export const AddIntegrationModal = ({
mapping.filter((m) => m.element.id && !m.column.id).length >= 1 mapping.filter((m) => m.element.id && !m.column.id).length >= 1
) { ) {
throw new Error( 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({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: notionIntegrationData, integrationData: notionIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
@@ -227,9 +227,9 @@ export const AddIntegrationModal = ({
return; return;
} }
if (selectedIntegration) { if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully")); toast.success(t("workspace.integrations.integration_updated_successfully"));
} else { } else {
toast.success(t("environments.integrations.integration_added_successfully")); toast.success(t("workspace.integrations.integration_added_successfully"));
} }
resetForm(); resetForm();
setOpen(false); setOpen(false);
@@ -245,14 +245,14 @@ export const AddIntegrationModal = ({
try { try {
setIsDeleting(true); setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: notionIntegrationData, integrationData: notionIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
} }
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred"); toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -283,13 +283,13 @@ export const AddIntegrationModal = ({
fill fill
className="object-contain object-center" className="object-contain object-center"
src={NotionLogo} src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")} alt={t("workspace.integrations.notion.notion_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <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> <DialogDescription>
{t("environments.integrations.notion.notion_integration_description")} {t("workspace.integrations.notion.notion_integration_description")}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -301,7 +301,7 @@ export const AddIntegrationModal = ({
<div> <div>
<div className="mb-4"> <div className="mb-4">
<DropdownSelector <DropdownSelector
label={t("environments.integrations.notion.select_a_database")} label={t("workspace.integrations.notion.select_a_database")}
items={databases.map((d) => ({ items={databases.map((d) => ({
id: d.id, id: d.id,
name: (d as any).title?.[0]?.plain_text, name: (d as any).title?.[0]?.plain_text,
@@ -314,13 +314,13 @@ export const AddIntegrationModal = ({
{selectedDatabase && hasMatchingId && ( {selectedDatabase && hasMatchingId && (
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
<strong>{t("common.warning")}:</strong>{" "} <strong>{t("common.warning")}:</strong>{" "}
{t("environments.integrations.notion.duplicate_connection_warning")} {t("workspace.integrations.notion.duplicate_connection_warning")}
</p> </p>
)} )}
<p className="m-1 text-xs text-slate-500"> <p className="m-1 text-xs text-slate-500">
{databases.length === 0 && {databases.length === 0 &&
t( 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> </p>
</div> </div>
@@ -333,13 +333,13 @@ export const AddIntegrationModal = ({
disabled={surveys.length === 0} disabled={surveys.length === 0}
/> />
<p className="m-1 text-xs text-slate-500"> <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> </p>
</div> </div>
{selectedDatabase && selectedSurvey && ( {selectedDatabase && selectedSurvey && (
<div> <div>
<Label> <Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")} {t("workspace.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label> </Label>
<div className="mt-1 space-y-2 overflow-y-auto"> <div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((m, idx) => ( {mapping.map((m, idx) => (
@@ -388,7 +388,7 @@ export const AddIntegrationModal = ({
type="submit" type="submit"
loading={isLinkingDatabase} loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}> 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> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user"; 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 { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -50,7 +50,7 @@ export const ManageIntegration = ({
}); });
if (deleteIntegrationActionResult?.data) { if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false); setIsConnected(false);
} else { } else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult); const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -72,7 +72,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center"> <div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span> <span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500"> <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, workspace: notionIntegration.config.key.workspace_name,
})} })}
</span> </span>
@@ -82,10 +82,10 @@ export const ManageIntegration = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}> <Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" /> <RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")} {t("workspace.integrations.notion.update_connection")}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent> <TooltipContent>{t("workspace.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<Button <Button
@@ -93,12 +93,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null); setSelectedIntegration(null);
setOpenAddIntegrationModal(true); setOpenAddIntegrationModal(true);
}}> }}>
{t("environments.integrations.notion.link_new_database")} {t("workspace.integrations.notion.link_new_database")}
</Button> </Button>
</div> </div>
{!integrationArray || integrationArray.length === 0 ? ( {!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full"> <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>
) : ( ) : (
<div className="mt-4 flex w-full flex-col items-center justify-center"> <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="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("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block"> <div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.notion.database_name")} {t("workspace.integrations.notion.database_name")}
</div> </div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div> <div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div> </div>
@@ -129,15 +129,15 @@ export const ManageIntegration = ({
)} )}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4"> <Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon /> <Trash2Icon />
{t("environments.integrations.delete_integration")} {t("workspace.integrations.delete_integration")}
</Button> </Button>
<DeleteDialog <DeleteDialog
open={isDeleteIntegrationModalOpen} open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen} setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.notion.notion_integration")} deleteWhat={t("workspace.integrations.notion.notion_integration")}
onDelete={handleDeleteIntegration} onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")} text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting} isDeleting={isDeleting}
/> />
</div> </div>
@@ -8,7 +8,7 @@ import {
ERRORS, ERRORS,
TYPE_MAPPING, TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION, 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 { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementTypes } from "@/modules/survey/lib/elements"; import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -48,7 +48,7 @@ const MappingErrorMessage = ({
return ( 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, col_name: col.name,
type: col.type, type: col.type,
})} })}
@@ -60,7 +60,7 @@ const MappingErrorMessage = ({
if (!element) return null; if (!element) return null;
return ( 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, que_name: elem.name,
question_label: element.label, question_label: element.label,
col_name: col.name, col_name: col.name,
@@ -194,7 +194,7 @@ export const MappingRow = ({
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="max-w-full flex-1"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("workspace.integrations.notion.select_a_survey_question")}
items={filteredElementItems} items={filteredElementItems}
selectedItem={mapping?.[idx]?.element} selectedItem={mapping?.[idx]?.element}
setSelectedItem={handleElementSelect} setSelectedItem={handleElementSelect}
@@ -204,7 +204,7 @@ export const MappingRow = ({
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")} placeholder={t("workspace.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()} items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column} selectedItem={mapping?.[idx]?.column}
setSelectedItem={handleColumnSelect} setSelectedItem={handleColumnSelect}
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { import {
TIntegrationNotion, TIntegrationNotion,
TIntegrationNotionConfigData, TIntegrationNotionConfigData,
@@ -9,8 +8,8 @@ import {
} from "@formbricks/types/integration/notion"; } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal"; import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration"; import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png"; import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration"; import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion"; import { authorize } from "../lib/notion";
@@ -18,7 +17,7 @@ import { authorize } from "../lib/notion";
interface NotionWrapperProps { interface NotionWrapperProps {
notionIntegration: TIntegrationNotion | undefined; notionIntegration: TIntegrationNotion | undefined;
enabled: boolean; enabled: boolean;
environment: TEnvironment; workspaceId: string;
webAppUrl: string; webAppUrl: string;
surveys: TSurvey[]; surveys: TSurvey[];
databasesArray: TIntegrationNotionDatabase[]; databasesArray: TIntegrationNotionDatabase[];
@@ -28,7 +27,7 @@ interface NotionWrapperProps {
export const NotionWrapper = ({ export const NotionWrapper = ({
notionIntegration, notionIntegration,
enabled, enabled,
environment, workspaceId,
webAppUrl, webAppUrl,
surveys, surveys,
databasesArray, databasesArray,
@@ -43,7 +42,7 @@ export const NotionWrapper = ({
>(null); >(null);
const handleNotionAuthorization = async () => { const handleNotionAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => { authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) { if (url) {
window.location.replace(url); window.location.replace(url);
} }
@@ -55,7 +54,7 @@ export const NotionWrapper = ({
{isConnected && notionIntegration ? ( {isConnected && notionIntegration ? (
<> <>
<AddIntegrationModal <AddIntegrationModal
environmentId={environment.id} workspaceId={workspaceId}
surveys={surveys} surveys={surveys}
open={isModalOpen} open={isModalOpen}
setOpen={setIsModalOpen} setOpen={setIsModalOpen}
@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch); vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => { describe("authorize", () => {
const environmentId = "test-env-id"; const workspaceId = "test-env-id";
const apiHost = "http://test.com"; const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/notion`; const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
const expectedHeaders = { environmentId: environmentId }; const expectedHeaders = { workspaceId: workspaceId };
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -30,7 +30,7 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: mockAuthUrl } }), json: async () => ({ data: { authUrl: mockAuthUrl } }),
}); });
const authUrl = await authorize(environmentId, apiHost); const authUrl = await authorize(workspaceId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
@@ -47,7 +47,7 @@ describe("authorize", () => {
text: async () => errorText, 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, { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger"; 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`, { const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
method: "GET", method: "GET",
headers: { environmentId: environmentId }, headers: { workspaceId },
}); });
if (!res.ok) { if (!res.ok) {
@@ -11,14 +11,14 @@ const Loading = () => {
<GoBackButton /> <GoBackButton />
<div className="mb-6 text-right"> <div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"> <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> </Button>
</div> </div>
<div className="rounded-lg border border-slate-200"> <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="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("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 className="col-span-2 text-center">{t("common.updated_at")}</div>
</div> </div>
<div className="grid-cols-7"> <div className="grid-cols-7">
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/NotionWrapper";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
NOTION_AUTH_URL, NOTION_AUTH_URL,
@@ -14,12 +14,12 @@ import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service"; import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service"; import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; 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 params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const enabled = !!( const enabled = !!(
@@ -29,17 +29,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI 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([ const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId), getSurveys(workspace.id),
getIntegrationByType(params.environmentId, "notion"), getIntegrationByType(workspace.id, "notion"),
getUserLocale(session.user.id), getUserLocale(session.user.id),
]); ]);
let databasesArray: TIntegrationNotionDatabase[] = []; let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? []; databasesArray = (await getNotionDatabases(workspace.id)) ?? [];
} }
if (isReadOnly) { if (isReadOnly) {
@@ -49,11 +49,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={"./"} /> <GoBackButton url={"./"} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} /> <PageHeader pageTitle={t("workspace.integrations.notion.notion_integration")} />
<NotionWrapper <NotionWrapper
enabled={enabled} enabled={enabled}
surveys={surveys} surveys={surveys}
environment={environment} workspaceId={workspace.id}
notionIntegration={notionIntegration as TIntegrationNotion} notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
databasesArray={databasesArray} databasesArray={databasesArray}
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration"; 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 ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg"; import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -17,11 +17,11 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server"; 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 { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; 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) => { const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`; if (count === 1) return `1 ${type}`;
@@ -29,11 +29,11 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
return `${count} ${type}s`; 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 params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, isBilling, workspace } = await getWorkspaceAuth(params.workspaceId);
const [ const [
integrations, integrations,
@@ -43,19 +43,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
n8nwebhookCount, n8nwebhookCount,
activePiecesWebhookCount, activePiecesWebhookCount,
] = await Promise.all([ ] = await Promise.all([
getIntegrations(params.environmentId), getIntegrations(workspace.id),
getWebhookCountBySource(params.environmentId, "user"), getWebhookCountBySource(workspace.id, "user"),
getWebhookCountBySource(params.environmentId, "zapier"), getWebhookCountBySource(workspace.id, "zapier"),
getWebhookCountBySource(params.environmentId, "make"), getWebhookCountBySource(workspace.id, "make"),
getWebhookCountBySource(params.environmentId, "n8n"), getWebhookCountBySource(workspace.id, "n8n"),
getWebhookCountBySource(params.environmentId, "activepieces"), getWebhookCountBySource(workspace.id, "activepieces"),
]); ]);
const isIntegrationConnected = (type: TIntegrationType) => const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type); integrations.some((integration) => integration.type === type);
if (isBilling) { if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD)); return redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
} }
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets"); const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
@@ -64,7 +64,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isN8nIntegrationConnected = isIntegrationConnected("n8n"); const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack"); const isSlackIntegrationConnected = isIntegrationConnected("slack");
const appSetupCompleted = !!environment?.appSetupCompleted; const appSetupCompleted = !!workspace.appSetupCompleted;
const integrationCards = [ const integrationCards = [
{ {
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier",
@@ -74,63 +74,63 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: true, connectNewTab: true,
label: "Zapier", label: "Zapier",
description: t("environments.integrations.zapier_integration_description"), description: t("workspace.integrations.zapier_integration_description"),
icon: <Image src={ZapierLogo} alt="Zapier Logo" />, icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
connected: zapierWebhookCount > 0, connected: zapierWebhookCount > 0,
statusText: getStatusText(zapierWebhookCount, t, "zap"), statusText: getStatusText(zapierWebhookCount, t, "zap"),
disabled: isReadOnly, disabled: isReadOnly,
}, },
{ {
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`, connectHref: `/workspaces/${params.workspaceId}/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"), connectText: t("workspace.integrations.manage_webhooks"),
connectNewTab: false, connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
label: "Webhooks", label: "Webhooks",
description: t("environments.integrations.webhook_integration_description"), description: t("workspace.integrations.webhook_integration_description"),
icon: <Image src={WebhookLogo} alt="Webhook Logo" />, icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
connected: userWebhookCount > 0, connected: userWebhookCount > 0,
statusText: getStatusText(userWebhookCount, t, "webhook"), statusText: getStatusText(userWebhookCount, t, "webhook"),
disabled: false, 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")}`, connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false, connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
label: "Google Sheets", 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" />, icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
connected: isGoogleSheetsIntegrationConnected, connected: isGoogleSheetsIntegrationConnected,
statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"), statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly, disabled: isReadOnly,
}, },
{ {
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`, connectHref: `/workspaces/${params.workspaceId}/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`, connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false, connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
label: "Airtable", label: "Airtable",
description: t("environments.integrations.airtable_integration_description"), description: t("workspace.integrations.airtable_integration_description"),
icon: <Image src={AirtableLogo} alt="Airtable Logo" />, icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
connected: isAirtableIntegrationConnected, connected: isAirtableIntegrationConnected,
statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"), statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly, disabled: isReadOnly,
}, },
{ {
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`, connectHref: `/workspaces/${params.workspaceId}/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`, connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false, connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
label: "Slack", label: "Slack",
description: t("environments.integrations.slack_integration_description"), description: t("workspace.integrations.slack_integration_description"),
icon: <Image src={SlackLogo} alt="Slack Logo" />, icon: <Image src={SlackLogo} alt="Slack Logo" />,
connected: isSlackIntegrationConnected, connected: isSlackIntegrationConnected,
statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"), statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -144,7 +144,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectHref: "https://n8n.io", connectHref: "https://n8n.io",
connectNewTab: true, connectNewTab: true,
label: "n8n", label: "n8n",
description: t("environments.integrations.n8n_integration_description"), description: t("workspace.integrations.n8n_integration_description"),
icon: <Image src={n8nLogo} alt="n8n Logo" />, icon: <Image src={n8nLogo} alt="n8n Logo" />,
connected: n8nwebhookCount > 0, connected: n8nwebhookCount > 0,
statusText: getStatusText(n8nwebhookCount, t, t("common.integration")), statusText: getStatusText(n8nwebhookCount, t, t("common.integration")),
@@ -158,21 +158,21 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: true, connectNewTab: true,
label: "Make.com", label: "Make.com",
description: t("environments.integrations.make_integration_description"), description: t("workspace.integrations.make_integration_description"),
icon: <Image src={MakeLogo} alt="Make Logo" />, icon: <Image src={MakeLogo} alt="Make Logo" />,
connected: makeWebhookCount > 0, connected: makeWebhookCount > 0,
statusText: getStatusText(makeWebhookCount, t, t("common.integration")), statusText: getStatusText(makeWebhookCount, t, t("common.integration")),
disabled: isReadOnly, disabled: isReadOnly,
}, },
{ {
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`, connectHref: `/workspaces/${params.workspaceId}/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`, connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false, connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion", docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
label: "Notion", label: "Notion",
description: t("environments.integrations.notion_integration_description"), description: t("workspace.integrations.notion_integration_description"),
icon: <Image src={notionLogo} alt="Notion Logo" />, icon: <Image src={notionLogo} alt="Notion Logo" />,
connected: isNotionIntegrationConnected, connected: isNotionIntegrationConnected,
statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"), statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -186,7 +186,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: true, connectNewTab: true,
label: "Activepieces", label: "Activepieces",
description: t("environments.integrations.activepieces_integration_description"), description: t("workspace.integrations.activepieces_integration_description"),
icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />, icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />,
connected: activePiecesWebhookCount > 0, connected: activePiecesWebhookCount > 0,
statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")), statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")),
@@ -198,11 +198,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart", docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
connectHref: `/environments/${params.environmentId}/workspace/app-connection`, connectHref: `/workspaces/${params.workspaceId}/app-connection`,
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: false, connectNewTab: false,
label: "Javascript SDK", 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" />, icon: <Image src={JsLogo} alt="Javascript Logo" />,
connected: appSetupCompleted, connected: appSetupCompleted,
statusText: appSetupCompleted ? t("common.connected") : t("common.not_connected"), statusText: appSetupCompleted ? t("common.connected") : t("common.not_connected"),
@@ -212,7 +212,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}> <PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" /> <WorkspaceConfigNavigation activeId="integrations" />
</PageHeader> </PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3"> <div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => ( {integrationCards.map((card) => (
@@ -5,10 +5,10 @@ import { ZId } from "@formbricks/types/common";
import { getSlackChannels } from "@/lib/slack/service"; import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; 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({ const ZGetSlackChannelsAction = z.object({
environmentId: ZId, workspaceId: ZId,
}); });
export const getSlackChannelsAction = authenticatedActionClient export const getSlackChannelsAction = authenticatedActionClient
@@ -16,19 +16,19 @@ export const getSlackChannelsAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [ access: [
{ {
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "projectTeam", type: "workspaceTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), workspaceId: parsedInput.workspaceId,
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
}); });
return await getSlackChannels(parsedInput.environmentId); return await getSlackChannels(parsedInput.workspaceId);
}); });
@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack"; } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; 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 SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall"; 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"; import { Label } from "@/modules/ui/components/label";
interface AddChannelMappingModalProps { interface AddChannelMappingModalProps {
environmentId: string; workspaceId: string;
surveys: TSurvey[]; surveys: TSurvey[];
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
@@ -46,7 +46,7 @@ interface AddChannelMappingModalProps {
} }
export const AddChannelMappingModal = ({ export const AddChannelMappingModal = ({
environmentId, workspaceId,
surveys, surveys,
open, open,
setOpen, setOpen,
@@ -112,14 +112,14 @@ export const AddChannelMappingModal = ({
const linkChannel = async () => { const linkChannel = async () => {
try { try {
if (!selectedChannel) { 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) { 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) { 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); setIsLinkingChannel(true);
const integrationData: TIntegrationSlackConfigData = { const integrationData: TIntegrationSlackConfigData = {
@@ -146,7 +146,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.push(integrationData); slackIntegrationData.config.data.push(integrationData);
} }
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: slackIntegrationData, integrationData: slackIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
@@ -154,9 +154,9 @@ export const AddChannelMappingModal = ({
return; return;
} }
if (selectedIntegration) { if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully")); toast.success(t("workspace.integrations.integration_updated_successfully"));
} else { } else {
toast.success(t("environments.integrations.integration_added_successfully")); toast.success(t("workspace.integrations.integration_added_successfully"));
} }
resetForm(); resetForm();
setOpen(false); setOpen(false);
@@ -190,14 +190,14 @@ export const AddChannelMappingModal = ({
try { try {
setIsDeleting(true); setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({ const result = await createOrUpdateIntegrationAction({
environmentId, workspaceId,
integrationData: slackIntegrationData, integrationData: slackIntegrationData,
}); });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
} }
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred"); toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -227,13 +227,13 @@ export const AddChannelMappingModal = ({
fill fill
className="object-contain object-center" className="object-contain object-center"
src={SlackLogo} src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")} alt={t("workspace.integrations.slack.slack_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <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> <DialogDescription>
{t("environments.integrations.slack.slack_integration_description")} {t("workspace.integrations.slack.slack_integration_description")}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@@ -244,7 +244,7 @@ export const AddChannelMappingModal = ({
<div> <div>
<div className="mb-4"> <div className="mb-4">
<DropdownSelector <DropdownSelector
label={t("environments.integrations.slack.select_channel")} label={t("workspace.integrations.slack.select_channel")}
items={channels} items={channels}
selectedItem={selectedChannel} selectedItem={selectedChannel}
setSelectedItem={setSelectedChannel} setSelectedItem={setSelectedChannel}
@@ -256,18 +256,18 @@ export const AddChannelMappingModal = ({
className="text-xs"> className="text-xs">
<Button variant="ghost" size="sm" className="my-2" type="button"> <Button variant="ghost" size="sm" className="my-2" type="button">
<CircleHelpIcon className="h-4 w-4" /> <CircleHelpIcon className="h-4 w-4" />
{t("environments.integrations.slack.dont_see_your_channel")} {t("workspace.integrations.slack.dont_see_your_channel")}
</Button> </Button>
</Link> </Link>
{selectedChannel && hasMatchingId && ( {selectedChannel && hasMatchingId && (
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
<strong>{t("common.note")}:</strong>{" "} <strong>{t("common.note")}:</strong>{" "}
{t("environments.integrations.slack.already_connected_another_survey")} {t("workspace.integrations.slack.already_connected_another_survey")}
</p> </p>
)} )}
<p className="m-1 text-xs text-slate-500"> <p className="m-1 text-xs text-slate-500">
{channels.length === 0 && {channels.length === 0 &&
t("environments.integrations.slack.create_at_least_one_channel_error")} t("workspace.integrations.slack.create_at_least_one_channel_error")}
</p> </p>
</div> </div>
<div> <div>
@@ -279,7 +279,7 @@ export const AddChannelMappingModal = ({
disabled={surveys.length === 0} disabled={surveys.length === 0}
/> />
<p className="m-1 text-xs text-slate-500"> <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> </p>
</div> </div>
</div> </div>
@@ -346,7 +346,7 @@ export const AddChannelMappingModal = ({
</Button> </Button>
)} )}
<Button type="submit" loading={isLinkingChannel}> <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> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack"; import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user"; 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 { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -52,7 +52,7 @@ export const ManageIntegration = ({
}); });
if (deleteIntegrationActionResult?.data) { if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully")); toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false); setIsConnected(false);
} else { } else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult); 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"> <div className="mb-4 flex w-full items-center justify-between space-x-4">
<p className="text-amber-700"> <p className="text-amber-700">
<Trans <Trans
i18nKey="environments.integrations.slack.slack_reconnect_button_description" i18nKey="workspace.integrations.slack.slack_reconnect_button_description"
components={{ b: <b /> }} components={{ b: <b /> }}
/> />
</p> </p>
<Button onClick={handleSlackAuthorization} variant="secondary"> <Button onClick={handleSlackAuthorization} variant="secondary">
{t("environments.integrations.slack.slack_reconnect_button")} {t("workspace.integrations.slack.slack_reconnect_button")}
</Button> </Button>
</div> </div>
)} )}
@@ -87,7 +87,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center"> <div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span> <span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500"> <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, team: slackIntegration.config.key.team?.name,
})} })}
</span> </span>
@@ -98,12 +98,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null); setSelectedIntegration(null);
setOpenAddIntegrationModal(true); setOpenAddIntegrationModal(true);
}}> }}>
{t("environments.integrations.slack.link_channel")} {t("workspace.integrations.slack.link_channel")}
</Button> </Button>
</div> </div>
{!integrationArray || integrationArray.length === 0 ? ( {!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full"> <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>
) : ( ) : (
<div className="mt-4 flex w-full flex-col items-center justify-center"> <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="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("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block"> <div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.slack.channel_name")} {t("workspace.integrations.slack.channel_name")}
</div> </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.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</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"> <Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon /> <Trash2Icon />
{t("environments.integrations.delete_integration")} {t("workspace.integrations.delete_integration")}
</Button> </Button>
<DeleteDialog <DeleteDialog
open={isDeleteIntegrationModalOpen} open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen} setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.slack.slack_integration")} deleteWhat={t("workspace.integrations.slack.slack_integration")}
onDelete={handleDeleteIntegration} onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")} text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting} isDeleting={isDeleting}
/> />
</div> </div>
@@ -1,21 +1,20 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack"; import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions"; import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal"; import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration"; import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack"; import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/lib/slack";
import slackLogo from "@/images/slacklogo.png"; import slackLogo from "@/images/slacklogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration"; import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
interface SlackWrapperProps { interface SlackWrapperProps {
isEnabled: boolean; isEnabled: boolean;
environment: TEnvironment; workspaceId: string;
surveys: TSurvey[]; surveys: TSurvey[];
slackIntegration?: TIntegrationSlack; slackIntegration?: TIntegrationSlack;
webAppUrl: string; webAppUrl: string;
@@ -24,7 +23,7 @@ interface SlackWrapperProps {
export const SlackWrapper = ({ export const SlackWrapper = ({
isEnabled, isEnabled,
environment, workspaceId,
surveys, surveys,
slackIntegration, slackIntegration,
webAppUrl, webAppUrl,
@@ -39,7 +38,7 @@ export const SlackWrapper = ({
>(null); >(null);
const getSlackChannels = useCallback(async () => { const getSlackChannels = useCallback(async () => {
const getSlackChannelsResponse = await getSlackChannelsAction({ environmentId: environment.id }); const getSlackChannelsResponse = await getSlackChannelsAction({ workspaceId });
if ( if (
getSlackChannelsResponse?.serverError && getSlackChannelsResponse?.serverError &&
@@ -52,14 +51,14 @@ export const SlackWrapper = ({
if (getSlackChannelsResponse?.data) { if (getSlackChannelsResponse?.data) {
setSlackChannels(getSlackChannelsResponse.data); setSlackChannels(getSlackChannelsResponse.data);
} }
}, [environment.id]); }, [workspaceId]);
useEffect(() => { useEffect(() => {
getSlackChannels(); getSlackChannels();
}, [getSlackChannels]); }, [getSlackChannels]);
const handleSlackAuthorization = async () => { const handleSlackAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => { authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) { if (url) {
window.location.replace(url); window.location.replace(url);
} }
@@ -69,7 +68,7 @@ export const SlackWrapper = ({
return isConnected && slackIntegration ? ( return isConnected && slackIntegration ? (
<> <>
<AddChannelMappingModal <AddChannelMappingModal
environmentId={environment.id} workspaceId={workspaceId}
surveys={surveys} surveys={surveys}
open={isModalOpen} open={isModalOpen}
setOpen={setIsModalOpen} setOpen={setIsModalOpen}
@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
global.fetch = vi.fn(); global.fetch = vi.fn();
describe("authorize", () => { describe("authorize", () => {
const environmentId = "test-env-id"; const workspaceId = "test-env-id";
const apiHost = "http://test.com"; const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/slack`; const expectedUrl = `${apiHost}/api/v1/integrations/slack`;
const expectedAuthUrl = "http://slack.com/auth"; const expectedAuthUrl = "http://slack.com/auth";
@@ -24,11 +24,11 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: expectedAuthUrl } }), json: async () => ({ data: { authUrl: expectedAuthUrl } }),
} as Response); } as Response);
const authUrl = await authorize(environmentId, apiHost); const authUrl = await authorize(workspaceId, apiHost);
expect(fetch).toHaveBeenCalledWith(expectedUrl, { expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
headers: { environmentId }, headers: { workspaceId },
}); });
expect(authUrl).toBe(expectedAuthUrl); expect(authUrl).toBe(expectedAuthUrl);
}); });
@@ -40,11 +40,11 @@ describe("authorize", () => {
text: async () => errorText, text: async () => errorText,
} as Response); } 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, { expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET", method: "GET",
headers: { environmentId }, headers: { workspaceId },
}); });
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config"); expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config");
}); });
@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger"; 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`, { const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET", method: "GET",
headers: { environmentId }, headers: { workspaceId },
}); });
if (!res.ok) { if (!res.ok) {
@@ -1,27 +1,27 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; 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 params = await props.params;
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, slackIntegration, locale] = await Promise.all([ const [surveys, slackIntegration, locale] = await Promise.all([
getSurveys(params.environmentId), getSurveys(workspace.id),
getIntegrationByType(params.environmentId, "slack"), getIntegrationByType(workspace.id, "slack"),
getUserLocale(session.user.id), getUserLocale(session.user.id),
]); ]);
@@ -31,12 +31,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} /> <GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} /> <PageHeader pageTitle={t("workspace.integrations.slack.slack_integration")} />
<div className="h-[75vh] w-full"> <div className="h-[75vh] w-full">
<SlackWrapper <SlackWrapper
isEnabled={isEnabled} isEnabled={isEnabled}
environment={environment} workspaceId={workspace.id}
surveys={surveys} surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack} slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
@@ -0,0 +1,3 @@
import { LanguagesLoading } from "@/modules/workspaces/settings/languages/loading";
export default LanguagesLoading;

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