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 ( + + + + {t("common.create_project")} + {t("common.project_creation_description")} + + + +
+ + ( + + {t("common.project_name")} + + + + {error?.message && {error.message}} + + )} + /> + + {canDoRoleManagement && organizationTeams.length > 0 && ( + ( + + {t("common.team")} + + field.onChange(teamIds)} + placeholder={t("common.select_teams")} + /> + + {error?.message && {error.message}} + + )} + /> + )} + + + + + + +
+
+
+
+ ); +}; 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); }