diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx
index 959e7fcff7..42c1c0495c 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx
@@ -9,8 +9,12 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
-import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
+import {
+ getOrganizationProjectsLimit,
+ getRoleManagementPermission,
+} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
+import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
+ getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
+vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
+ getTeamsByOrganizationId: vi.fn(),
+}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
- MainNavigation: () =>
MainNavigation
,
+ MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
+
+ MainNavigation
+
{JSON.stringify(organizationTeams || [])}
+
{canDoRoleManagement?.toString() || "false"}
+
+ ),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => TopControlBar
,
@@ -156,6 +170,17 @@ const mockProjectPermission = {
role: "admin",
} as any;
+const mockOrganizationTeams = [
+ {
+ id: "team-1",
+ name: "Development Team",
+ },
+ {
+ id: "team-2",
+ name: "Marketing Team",
+ },
+];
+
const mockSession: Session = {
user: {
id: "user-1",
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
+ vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
+ vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
+ test("passes canDoRoleManagement props to MainNavigation", async () => {
+ vi.resetModules();
+ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
+ getEnterpriseLicense: vi.fn().mockResolvedValue({
+ active: false,
+ isPendingDowngrade: false,
+ features: { isMultiOrgEnabled: false },
+ lastChecked: new Date(),
+ fallbackLevel: "live",
+ }),
+ }));
+ const { EnvironmentLayout } = await import(
+ "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
+ );
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
+ expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
+ });
+
+ test("handles empty organizationTeams array", async () => {
+ vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
+ vi.resetModules();
+ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
+ getEnterpriseLicense: vi.fn().mockResolvedValue({
+ active: false,
+ isPendingDowngrade: false,
+ features: { isMultiOrgEnabled: false },
+ lastChecked: new Date(),
+ fallbackLevel: "live",
+ }),
+ }));
+ const { EnvironmentLayout } = await import(
+ "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
+ );
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
+ });
+
+ test("handles null organizationTeams", async () => {
+ vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
+ vi.resetModules();
+ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
+ getEnterpriseLicense: vi.fn().mockResolvedValue({
+ active: false,
+ isPendingDowngrade: false,
+ features: { isMultiOrgEnabled: false },
+ lastChecked: new Date(),
+ fallbackLevel: "live",
+ }),
+ }));
+ const { EnvironmentLayout } = await import(
+ "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
+ );
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
+ });
+
+ test("handles canDoRoleManagement false", async () => {
+ vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
+ vi.resetModules();
+ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
+ getEnterpriseLicense: vi.fn().mockResolvedValue({
+ active: false,
+ isPendingDowngrade: false,
+ features: { isMultiOrgEnabled: false },
+ lastChecked: new Date(),
+ fallbackLevel: "live",
+ }),
+ }));
+ const { EnvironmentLayout } = await import(
+ "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
+ );
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
+ });
+
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
index d5d336c362..90f71d8113 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
@@ -13,7 +13,10 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
-import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
+import {
+ getOrganizationProjectsLimit,
+ getRoleManagementPermission,
+} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
- const [projects, environments] = await Promise.all([
+ const [projects, environments, canDoRoleManagement] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
+ getRoleManagementPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -117,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
+ canDoRoleManagement={canDoRoleManagement}
/>
({
open ? Create Org Modal
: null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
- ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
+ ProjectSwitcher: ({
+ isCollapsed,
+ organizationTeams,
+ canDoRoleManagement,
+ }: {
+ isCollapsed: boolean;
+ organizationTeams: TOrganizationTeam[];
+ canDoRoleManagement: boolean;
+ }) => (
Project Switcher
+
{organizationTeams?.length || 0}
+
{canDoRoleManagement.toString()}
),
}));
@@ -146,6 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
+ canDoRoleManagement: true,
};
describe("MainNavigation", () => {
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
+
+ test("passes canDoRoleManagement props to ProjectSwitcher", () => {
+ render();
+
+ expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
+ expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
+ });
+
+ test("handles no organizationTeams", () => {
+ render();
+
+ expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
+ });
+
+ test("handles canDoRoleManagement false", () => {
+ render();
+
+ expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
+ });
});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
index 8a955cbf34..6cd5a4d44f 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
+ canDoRoleManagement: boolean;
}
export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
+ canDoRoleManagement,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -323,6 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
+ canDoRoleManagement={canDoRoleManagement}
/>
)}
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 338d16159f..fa240aa66a 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -169,6 +169,7 @@
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"create_new_organization": "Neue Organisation erstellen",
+ "create_project": "Projekt erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
"created": "Erstellt",
@@ -303,8 +304,10 @@
"product_manager": "Produktmanager",
"profile": "Profil",
"project_configuration": "Projektkonfiguration",
+ "project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
"project_name": "Projektname",
+ "project_name_placeholder": "z.B. Formbricks",
"project_not_found": "Projekt nicht gefunden",
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
"projects": "Projekte",
@@ -336,6 +339,7 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_survey": "Umfrage auswählen",
+ "select_teams": "Teams auswählen",
"selected": "Ausgewählt",
"selected_questions": "Ausgewählte Fragen",
"selection": "Auswahl",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 1dfbe6bee8..96e44a4bc2 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -169,6 +169,7 @@
"copy_code": "Copy code",
"copy_link": "Copy Link",
"create_new_organization": "Create new organization",
+ "create_project": "Create project",
"create_segment": "Create segment",
"create_survey": "Create survey",
"created": "Created",
@@ -303,8 +304,10 @@
"product_manager": "Product Manager",
"profile": "Profile",
"project_configuration": "Project's Configuration",
+ "project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
"project_name": "Project Name",
+ "project_name_placeholder": "e.g. Formbricks",
"project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found",
"projects": "Projects",
@@ -336,6 +339,7 @@
"select": "Select",
"select_all": "Select all",
"select_survey": "Select Survey",
+ "select_teams": "Select teams",
"selected": "Selected",
"selected_questions": "Selected questions",
"selection": "Selection",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index d13634ae6c..4b3d37cfec 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -169,6 +169,7 @@
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"create_new_organization": "Créer une nouvelle organisation",
+ "create_project": "Créer un projet",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
"created": "Créé",
@@ -303,8 +304,10 @@
"product_manager": "Chef de produit",
"profile": "Profil",
"project_configuration": "Configuration du projet",
+ "project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
"project_name": "Nom du projet",
+ "project_name_placeholder": "p.ex. Formbricks",
"project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets",
@@ -336,6 +339,7 @@
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_survey": "Sélectionner l'enquête",
+ "select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
"selected_questions": "Questions sélectionnées",
"selection": "Sélection",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index ae50810032..9c6e63eb6e 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -169,6 +169,7 @@
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"create_new_organization": "Criar nova organização",
+ "create_project": "Criar projeto",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
"created": "Criado",
@@ -303,8 +304,10 @@
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"project_configuration": "Configuração do Projeto",
+ "project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
"project_name": "Nome do Projeto",
+ "project_name_placeholder": "por exemplo, Formbricks",
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
@@ -336,6 +339,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_survey": "Selecionar Pesquisa",
+ "select_teams": "Selecionar times",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "seleção",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index 17b0b6e45b..dbb6de4bd9 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -169,6 +169,7 @@
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"create_new_organization": "Criar nova organização",
+ "create_project": "Criar projeto",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
"created": "Criado",
@@ -303,8 +304,10 @@
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"project_configuration": "Configuração do Projeto",
+ "project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
"project_name": "Nome do Projeto",
+ "project_name_placeholder": "por exemplo, Formbricks",
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
@@ -336,6 +339,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_survey": "Selecionar Inquérito",
+ "select_teams": "Selecionar equipas",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "Seleção",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 7b63c8af83..9427c99dd2 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -169,6 +169,7 @@
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"create_new_organization": "建立新組織",
+ "create_project": "建立專案",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
"created": "已建立",
@@ -303,8 +304,10 @@
"product_manager": "產品經理",
"profile": "個人資料",
"project_configuration": "專案組態",
+ "project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
"project_name": "專案名稱",
+ "project_name_placeholder": "例如 Formbricks",
"project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限",
"projects": "專案",
@@ -336,6 +339,7 @@
"select": "選擇",
"select_all": "全選",
"select_survey": "選擇問卷",
+ "select_teams": "選擇 團隊",
"selected": "已選取",
"selected_questions": "選取的問題",
"selection": "選取",
diff --git a/apps/web/modules/projects/components/create-project-modal/index.test.tsx b/apps/web/modules/projects/components/create-project-modal/index.test.tsx
new file mode 100644
index 0000000000..be3e6dcbd4
--- /dev/null
+++ b/apps/web/modules/projects/components/create-project-modal/index.test.tsx
@@ -0,0 +1,436 @@
+import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
+import { getFormattedErrorMessage } from "@/lib/utils/helper";
+import { getTeamsByOrganizationIdAction } from "@/modules/projects/settings/actions";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { toast } from "react-hot-toast";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { CreateProjectModal } from "./index";
+
+// Mock dependencies
+vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({
+ createProjectAction: vi.fn(),
+}));
+
+vi.mock("@/modules/projects/settings/actions", () => ({
+ getTeamsByOrganizationIdAction: vi.fn(),
+}));
+
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(),
+}));
+
+const mockPush = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}));
+
+vi.mock("react-hot-toast", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+// Mock UI components
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({ open, onOpenChange, children }: any) =>
+ open ? (
+ onOpenChange(false)}>
+ {children}
+
+ ) : null,
+ DialogContent: ({ children }: any) => {children}
,
+ DialogHeader: ({ children }: any) => {children}
,
+ DialogTitle: ({ children }: any) => {children}
,
+ DialogDescription: ({ children }: any) => {children}
,
+ DialogBody: ({ children }: any) => {children}
,
+ DialogFooter: ({ children }: any) => {children}
,
+}));
+
+// Create a mutable form mock that can be modified per test
+let currentFormMock: any;
+
+const createFormMock = (options: { shouldCallOnSubmit?: boolean; isSubmitting?: boolean } = {}) => ({
+ handleSubmit: vi.fn((onSubmit) => (e: any) => {
+ e.preventDefault();
+ if (options.shouldCallOnSubmit !== false) {
+ onSubmit({ name: "Test Project", teamIds: [] });
+ }
+ }),
+ formState: { isSubmitting: options.isSubmitting || false },
+ reset: vi.fn(),
+ watch: vi.fn(),
+ getValues: vi.fn(() => ({ teamIds: [] })),
+ setValue: vi.fn(),
+ control: {},
+});
+
+vi.mock("react-hook-form", () => ({
+ useForm: () => currentFormMock,
+}));
+
+vi.mock("@/modules/ui/components/form", () => ({
+ FormProvider: ({ children }: any) => {children}
,
+ FormField: ({ render, name }: any) => {
+ const field = {
+ value: name === "name" ? "Test Project" : [],
+ onChange: vi.fn(),
+ };
+ return render({ field, fieldState: {} });
+ },
+ FormItem: ({ children }: any) => {children}
,
+ FormLabel: ({ children }: any) => ,
+ FormControl: ({ children }: any) => {children}
,
+ FormError: ({ children }: any) => {children},
+}));
+
+vi.mock("@/modules/ui/components/input", () => ({
+ Input: (props: any) => ,
+}));
+
+vi.mock("@/modules/ui/components/multi-select", () => ({
+ MultiSelect: ({ value, options, onChange, placeholder }: any) => (
+
+ ),
+}));
+
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, type, loading, variant, ...props }: any) => (
+
+ ),
+}));
+
+describe("CreateProjectModal", () => {
+ const mockOrganizationTeams = [
+ { id: "team-1", name: "Development Team" },
+ { id: "team-2", name: "Marketing Team" },
+ ];
+
+ const mockProject = {
+ id: "project-123",
+ name: "Test Project",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ organizationId: "org-123",
+ recontactDays: 7,
+ inAppSurveyBranding: true,
+ linkSurveyBranding: true,
+ config: { channel: "website" as const, industry: "saas" as const },
+ placement: "bottomRight" as const,
+ clickOutsideClose: true,
+ darkOverlay: false,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ styling: { allowStyleOverwrite: true },
+ logo: null,
+ languages: [],
+ environments: [
+ {
+ id: "env-123",
+ type: "production" as const,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ projectId: "project-123",
+ appSetupCompleted: false,
+ },
+ ],
+ };
+
+ const defaultProps = {
+ open: true,
+ setOpen: vi.fn(),
+ organizationId: "org-123",
+ canDoRoleManagement: true,
+ };
+
+ beforeEach(() => {
+ // Reset all mocks
+ vi.clearAllMocks();
+
+ // Reset form mock to default
+ currentFormMock = createFormMock();
+
+ // Mock successful teams fetch
+ vi.mocked(getTeamsByOrganizationIdAction).mockResolvedValue({
+ data: mockOrganizationTeams,
+ } as any);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders modal when open is true", async () => {
+ render();
+
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("common.create_project");
+ expect(screen.getByTestId("dialog-description")).toHaveTextContent("common.project_creation_description");
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ test("does not render when open is false", () => {
+ render();
+
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
+ });
+
+ test("fetches organization teams on mount", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getTeamsByOrganizationIdAction).toHaveBeenCalledWith({
+ organizationId: "org-123",
+ });
+ });
+ });
+
+ test("shows team selection when canDoRoleManagement is true and teams exist", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("multi-select")).toBeInTheDocument();
+ });
+
+ const multiSelect = screen.getByTestId("multi-select");
+ expect(multiSelect).toHaveAttribute("data-placeholder", "common.select_teams");
+
+ const options = screen.getAllByRole("option");
+ expect(options).toHaveLength(2);
+ expect(options[0]).toHaveTextContent("Development Team");
+ expect(options[1]).toHaveTextContent("Marketing Team");
+ });
+
+ test("hides team selection when canDoRoleManagement is false", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("multi-select")).not.toBeInTheDocument();
+ });
+ });
+
+ test("hides team selection when no teams exist", async () => {
+ vi.mocked(getTeamsByOrganizationIdAction).mockResolvedValue({
+ data: [],
+ } as any);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("multi-select")).not.toBeInTheDocument();
+ });
+ });
+
+ test("handles teams fetch error", async () => {
+ const errorMessage = "Failed to fetch teams";
+ vi.mocked(getTeamsByOrganizationIdAction).mockResolvedValue({
+ serverError: "Failed to fetch teams",
+ } as any);
+ vi.mocked(getFormattedErrorMessage).mockReturnValue(errorMessage);
+
+ render();
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith(errorMessage);
+ });
+ });
+
+ test("submits form with project name only when no teams selected", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(createProjectAction).mockResolvedValue({ data: mockProject } as any);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId("button-submit");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(createProjectAction).toHaveBeenCalledWith({
+ organizationId: "org-123",
+ data: {
+ name: "Test Project",
+ teamIds: [],
+ },
+ });
+ });
+ });
+
+ test("submits form with selected teams", async () => {
+ const user = userEvent.setup();
+
+ // Update form mock to return teams
+ currentFormMock = createFormMock();
+ currentFormMock.handleSubmit = vi.fn((onSubmit) => (e: any) => {
+ e.preventDefault();
+ onSubmit({ name: "Test Project", teamIds: ["team-1", "team-2"] });
+ });
+
+ vi.mocked(createProjectAction).mockResolvedValue({ data: mockProject } as any);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ expect(screen.getByTestId("multi-select")).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId("button-submit");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(createProjectAction).toHaveBeenCalledWith({
+ organizationId: "org-123",
+ data: {
+ name: "Test Project",
+ teamIds: ["team-1", "team-2"],
+ },
+ });
+ });
+ });
+
+ test("shows success message and redirects on successful creation", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(createProjectAction).mockResolvedValue({ data: mockProject } as any);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId("button-submit");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(createProjectAction).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(toast.success).toHaveBeenCalledWith("Project created successfully");
+ expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
+ expect(mockPush).toHaveBeenCalledWith("/environments/env-123/surveys");
+ });
+ });
+
+ test("shows error message on creation failure", async () => {
+ const user = userEvent.setup();
+ const errorMessage = "Project creation failed";
+
+ vi.mocked(createProjectAction).mockResolvedValue({
+ serverError: "Creation failed",
+ } as any);
+ vi.mocked(getFormattedErrorMessage).mockReturnValue(errorMessage);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId("button-submit");
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith(errorMessage);
+ });
+ });
+
+ test("closes modal and resets form when cancel button clicked", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ const cancelButton = screen.getByTestId("button-button");
+ await user.click(cancelButton);
+
+ expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
+ });
+
+ test("shows loading state during form submission", async () => {
+ // Mock loading state
+ currentFormMock = createFormMock({ isSubmitting: true });
+
+ vi.mocked(createProjectAction).mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve({ data: mockProject }), 100))
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ // Check that loading state is shown
+ const submitButton = screen.getByTestId("button-submit");
+ expect(submitButton).toHaveAttribute("data-loading", "true");
+ });
+
+ test("handles form validation errors", async () => {
+ const user = userEvent.setup();
+
+ // Mock form with validation error - don't call onSubmit
+ currentFormMock = createFormMock({ shouldCallOnSubmit: false });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("input")).toBeInTheDocument();
+ });
+
+ const submitButton = screen.getByTestId("button-submit");
+ await user.click(submitButton);
+
+ // Form should not submit if validation fails
+ expect(createProjectAction).not.toHaveBeenCalled();
+ });
+
+ test("closes modal when dialog background is clicked", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const dialog = screen.getByTestId("dialog");
+ await user.click(dialog);
+
+ expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/apps/web/modules/projects/components/create-project-modal/index.tsx b/apps/web/modules/projects/components/create-project-modal/index.tsx
new file mode 100644
index 0000000000..4d1ac0cefb
--- /dev/null
+++ b/apps/web/modules/projects/components/create-project-modal/index.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
+import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
+import { getFormattedErrorMessage } from "@/lib/utils/helper";
+import { getTeamsByOrganizationIdAction } from "@/modules/projects/settings/actions";
+import { Button } from "@/modules/ui/components/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/modules/ui/components/dialog";
+import {
+ FormControl,
+ FormError,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormProvider,
+} from "@/modules/ui/components/form";
+import { Input } from "@/modules/ui/components/input";
+import { MultiSelect } from "@/modules/ui/components/multi-select";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useTranslate } from "@tolgee/react";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "react-hot-toast";
+import { z } from "zod";
+import { ZProject } from "@formbricks/types/project";
+
+const ZCreateProjectForm = z.object({
+ name: ZProject.shape.name,
+ teamIds: z.array(z.string()).optional(),
+});
+
+type TCreateProjectForm = z.infer;
+
+interface CreateProjectModalProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ organizationId: string;
+ canDoRoleManagement: boolean;
+}
+
+export const CreateProjectModal = ({
+ open,
+ setOpen,
+ organizationId,
+ canDoRoleManagement,
+}: CreateProjectModalProps) => {
+ const { t } = useTranslate();
+ const router = useRouter();
+
+ const [organizationTeams, setOrganizationTeams] = useState([]);
+
+ useEffect(() => {
+ const fetchOrganizationTeams = async () => {
+ const response = await getTeamsByOrganizationIdAction({ organizationId });
+ if (response?.data) {
+ setOrganizationTeams(response.data);
+ } else {
+ const errorMessage = getFormattedErrorMessage(response);
+ toast.error(errorMessage);
+ }
+ };
+ fetchOrganizationTeams();
+ }, [organizationId]);
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateProjectForm),
+ defaultValues: {
+ name: "",
+ teamIds: [],
+ },
+ });
+
+ const { isSubmitting } = form.formState;
+
+ const organizationTeamsOptions = organizationTeams.map((team) => ({
+ label: team.name,
+ value: team.id,
+ }));
+
+ const onSubmit = async (data: TCreateProjectForm) => {
+ const createProjectResponse = await createProjectAction({
+ organizationId,
+ data: {
+ name: data.name,
+ teamIds: data.teamIds || [],
+ },
+ });
+
+ if (createProjectResponse?.data) {
+ // Get production environment
+ const productionEnvironment = createProjectResponse.data.environments.find(
+ (environment) => environment.type === "production"
+ );
+
+ if (productionEnvironment) {
+ toast.success("Project created successfully");
+ setOpen(false);
+ form.reset();
+ // Redirect to the new project's surveys page
+ router.push(`/environments/${productionEnvironment.id}/surveys`);
+ }
+ } else {
+ const errorMessage = getFormattedErrorMessage(createProjectResponse);
+ toast.error(errorMessage);
+ }
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ form.reset();
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/modules/projects/components/project-switcher/index.test.tsx b/apps/web/modules/projects/components/project-switcher/index.test.tsx
index c8cf003753..628b78753c 100644
--- a/apps/web/modules/projects/components/project-switcher/index.test.tsx
+++ b/apps/web/modules/projects/components/project-switcher/index.test.tsx
@@ -1,3 +1,4 @@
+import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -49,6 +50,20 @@ vi.mock("@/modules/projects/components/project-limit-modal", () => ({
) : null,
}));
+vi.mock("@/modules/projects/components/create-project-modal", () => ({
+ CreateProjectModal: ({ open, setOpen, organizationId, organizationTeams, canDoRoleManagement }: any) =>
+ open ? (
+
+
+
{organizationId}
+
{organizationTeams?.length || 0}
+
{canDoRoleManagement.toString()}
+
+ ) : null,
+}));
+
describe("ProjectSwitcher", () => {
afterEach(() => {
cleanup();
@@ -66,21 +81,34 @@ describe("ProjectSwitcher", () => {
} as TProject;
const projects: TProject[] = [project, { ...project, id: "proj2", name: "Project 2" }];
+ const mockOrganizationTeams: TOrganizationTeam[] = [
+ {
+ id: "team-1",
+ name: "Development Team",
+ },
+ {
+ id: "team-2",
+ name: "Marketing Team",
+ },
+ ];
+
+ const defaultProps = {
+ isCollapsed: false,
+ isTextVisible: false,
+ organization,
+ project,
+ projects,
+ organizationProjectsLimit: 5,
+ isFormbricksCloud: false,
+ isLicenseActive: false,
+ environmentId: "env1",
+ isOwnerOrManager: true,
+ organizationTeams: mockOrganizationTeams,
+ canDoRoleManagement: true,
+ };
+
test("renders dropdown and project name", () => {
- render(
-
- );
+ render();
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
expect(screen.getByTitle("Project 1")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
@@ -90,40 +118,14 @@ describe("ProjectSwitcher", () => {
});
test("opens ProjectLimitModal when project limit reached and add project is clicked", async () => {
- render(
-
- );
+ render();
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
});
test("closes ProjectLimitModal when close button is clicked", async () => {
- render(
-
- );
+ render();
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
const closeButton = screen.getByTestId("close-modal");
@@ -132,20 +134,7 @@ describe("ProjectSwitcher", () => {
});
test("renders correct modal buttons and project limit", async () => {
- render(
-
- );
+ render();
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("modal-buttons")).toHaveTextContent(
@@ -154,24 +143,28 @@ describe("ProjectSwitcher", () => {
expect(screen.getByTestId("modal-project-limit")).toHaveTextContent("2");
});
- test("handleAddProject navigates if under limit", async () => {
- render(
-
- );
+ test("opens CreateProjectModal if projects exist and under limit", async () => {
+ render();
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
- expect(mockPush).toHaveBeenCalled();
- expect(mockPush).toHaveBeenCalledWith("/organizations/org1/projects/new/mode");
+ expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
+ expect(screen.getByTestId("modal-organization-id")).toHaveTextContent("org1");
+ expect(screen.getByTestId("modal-can-do-role-management")).toHaveTextContent("true");
+ });
+
+ test("closes CreateProjectModal when close button is clicked", async () => {
+ render();
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ const closeButton = screen.getByTestId("close-create-modal");
+ await userEvent.click(closeButton);
+ expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
+ });
+
+ test("passes correct props to CreateProjectModal", async () => {
+ render();
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ expect(screen.getByTestId("modal-can-do-role-management")).toHaveTextContent("false");
});
});
diff --git a/apps/web/modules/projects/components/project-switcher/index.tsx b/apps/web/modules/projects/components/project-switcher/index.tsx
index 0a723ef9a0..4ef6dfa477 100644
--- a/apps/web/modules/projects/components/project-switcher/index.tsx
+++ b/apps/web/modules/projects/components/project-switcher/index.tsx
@@ -2,6 +2,7 @@
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
+import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import {
DropdownMenu,
@@ -31,6 +32,7 @@ interface ProjectSwitcherProps {
isLicenseActive: boolean;
environmentId: string;
isOwnerOrManager: boolean;
+ canDoRoleManagement: boolean;
}
export const ProjectSwitcher = ({
@@ -44,8 +46,10 @@ export const ProjectSwitcher = ({
isLicenseActive,
environmentId,
isOwnerOrManager,
+ canDoRoleManagement,
}: ProjectSwitcherProps) => {
const [openLimitModal, setOpenLimitModal] = useState(false);
+ const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const router = useRouter();
@@ -55,12 +59,13 @@ export const ProjectSwitcher = ({
router.push(`/projects/${projectId}/`);
};
- const handleAddProject = (organizationId: string) => {
+ const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
- router.push(`/organizations/${organizationId}/projects/new/mode`);
+
+ setOpenCreateProjectModal(true);
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
@@ -202,9 +207,7 @@ export const ProjectSwitcher = ({
{isOwnerOrManager && (
<>
- handleAddProject(organization.id)}
- icon={}>
+ }>
{t("common.add_project")}
>
@@ -219,6 +222,14 @@ export const ProjectSwitcher = ({
projectLimit={organizationProjectsLimit}
/>
)}
+ {openCreateProjectModal && (
+
+ )}
>
);
};
diff --git a/apps/web/modules/projects/settings/actions.ts b/apps/web/modules/projects/settings/actions.ts
index 33b6c3a945..d5a162cfb5 100644
--- a/apps/web/modules/projects/settings/actions.ts
+++ b/apps/web/modules/projects/settings/actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getOrganization } from "@/lib/organization/service";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -79,3 +80,24 @@ export const updateProjectAction = authenticatedActionClient.schema(ZUpdateProje
}
)
);
+
+const ZGetTeamsByOrganizationIdAction = z.object({
+ organizationId: ZId,
+});
+
+export const getTeamsByOrganizationIdAction = authenticatedActionClient
+ .schema(ZGetTeamsByOrganizationIdAction)
+ .action(async ({ ctx, parsedInput }) => {
+ await checkAuthorizationUpdated({
+ userId: ctx.user.id,
+ organizationId: parsedInput.organizationId,
+ access: [
+ {
+ type: "organization",
+ roles: ["owner", "manager"],
+ },
+ ],
+ });
+ const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
+ return teams;
+ });
diff --git a/apps/web/modules/projects/settings/lib/project.ts b/apps/web/modules/projects/settings/lib/project.ts
index a93f4a24e5..c51321ce26 100644
--- a/apps/web/modules/projects/settings/lib/project.ts
+++ b/apps/web/modules/projects/settings/lib/project.ts
@@ -119,16 +119,9 @@ export const createProject = async (
return updatedProject;
} catch (error) {
- if (
- error instanceof Prisma.PrismaClientKnownRequestError &&
- error.code === PrismaErrorType.UniqueConstraintViolation
- ) {
- throw new InvalidInputError("A project with this name already exists in your organization");
- }
-
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
- throw new InvalidInputError("A project with this name already exists in this organization");
+ throw new InvalidInputError("A project with this name already exists in your organization");
}
throw new DatabaseError(error.message);
}