chore: Don't force Project Onboarding for each project (#6299)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2025-07-25 11:40:30 +05:30
committed by GitHub
parent b1f78e7bf2
commit 0aaaaa54ee
16 changed files with 922 additions and 91 deletions
@@ -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: () => <div data-testid="main-navigation">MainNavigation</div>,
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
@@ -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: <div>Child Content</div>,
})
);
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: <div>Child Content</div>,
})
);
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: <div>Child Content</div>,
})
);
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: <div>Child Content</div>,
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
@@ -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}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar
@@ -1,4 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
canDoRoleManagement,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
</div>
),
}));
@@ -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(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
});
@@ -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}
/>
)}
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "選取",
@@ -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 ? (
<div data-testid="dialog" onClick={() => onOpenChange(false)}>
{children}
</div>
) : null,
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
// 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) => <div data-testid="form-provider">{children}</div>,
FormField: ({ render, name }: any) => {
const field = {
value: name === "name" ? "Test Project" : [],
onChange: vi.fn(),
};
return render({ field, fieldState: {} });
},
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
FormLabel: ({ children }: any) => <label data-testid="form-label">{children}</label>,
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
FormError: ({ children }: any) => <span data-testid="form-error">{children}</span>,
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: (props: any) => <input data-testid="input" {...props} />,
}));
vi.mock("@/modules/ui/components/multi-select", () => ({
MultiSelect: ({ value, options, onChange, placeholder }: any) => (
<select
data-testid="multi-select"
data-placeholder={placeholder}
multiple
value={value}
onChange={(e) => onChange(Array.from(e.target.selectedOptions, (option) => option.value))}>
{options.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, type, loading, variant, ...props }: any) => (
<button
data-testid={`button-${type || "button"}`}
data-variant={variant}
data-loading={loading}
onClick={onClick}
type={type}
{...props}>
{children}
</button>
),
}));
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("fetches organization teams on mount", async () => {
render(<CreateProjectModal {...defaultProps} />);
await waitFor(() => {
expect(getTeamsByOrganizationIdAction).toHaveBeenCalledWith({
organizationId: "org-123",
});
});
});
test("shows team selection when canDoRoleManagement is true and teams exist", async () => {
render(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} canDoRoleManagement={false} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
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(<CreateProjectModal {...defaultProps} />);
const dialog = screen.getByTestId("dialog");
await user.click(dialog);
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
});
});
@@ -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<typeof ZCreateProjectForm>;
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<TOrganizationTeam[]>([]);
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<TCreateProjectForm>({
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("common.create_project")}</DialogTitle>
<DialogDescription>{t("common.project_creation_description")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody className="relative z-20 space-y-4 overflow-visible">
<FormField
control={form.control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("common.project_name")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("common.project_name_placeholder")} autoFocus />
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
)}
/>
{canDoRoleManagement && organizationTeams.length > 0 && (
<FormField
control={form.control}
name="teamIds"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("common.team")}</FormLabel>
<FormControl>
<MultiSelect
value={field.value || []}
options={organizationTeamsOptions}
onChange={(teamIds) => field.onChange(teamIds)}
placeholder={t("common.select_teams")}
/>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
)}
/>
)}
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.create_project")}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
@@ -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 ? (
<div data-testid="create-project-modal">
<button onClick={() => setOpen(false)} data-testid="close-create-modal">
Close Create Modal
</button>
<div data-testid="modal-organization-id">{organizationId}</div>
<div data-testid="modal-organization-teams">{organizationTeams?.length || 0}</div>
<div data-testid="modal-can-do-role-management">{canDoRoleManagement.toString()}</div>
</div>
) : 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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
render(<ProjectSwitcher {...defaultProps} />);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
render(<ProjectSwitcher {...defaultProps} organizationProjectsLimit={2} />);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
render(<ProjectSwitcher {...defaultProps} organizationProjectsLimit={2} />);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={true}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
render(<ProjectSwitcher {...defaultProps} organizationProjectsLimit={2} isFormbricksCloud={true} />);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects.slice(0, 1)}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
test("opens CreateProjectModal if projects exist and under limit", async () => {
render(<ProjectSwitcher {...defaultProps} projects={[project]} organizationProjectsLimit={5} />);
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(<ProjectSwitcher {...defaultProps} projects={[project]} organizationProjectsLimit={5} />);
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(<ProjectSwitcher {...defaultProps} projects={[project]} canDoRoleManagement={false} />);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("modal-can-do-role-management")).toHaveTextContent("false");
});
});
@@ -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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAddProject(organization.id)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<DropdownMenuItem onClick={handleAddProject} icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.add_project")}</span>
</DropdownMenuItem>
</>
@@ -219,6 +222,14 @@ export const ProjectSwitcher = ({
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
canDoRoleManagement={canDoRoleManagement}
/>
)}
</>
);
};
@@ -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;
});
@@ -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);
}