feat: replace deprecated modals with new one (5824) (#5903)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
This commit is contained in:
Jakob Schott
2025-07-04 13:44:36 +02:00
committed by GitHub
parent 75362eac7a
commit fef30c54b2
103 changed files with 5067 additions and 3692 deletions
@@ -1,5 +1,5 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { cleanup, render } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
vi.mock("./ActionActivityTab", () => ({
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
},
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn();
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders ModalWithTabs with correct props", () => {
test("renders correctly when open", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled();
const props = mockedModalWithTabs.mock.calls[0][0];
test("does not render when open is false", () => {
render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
// Check basic props
expect(props.open).toBe(true);
expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// Check icon data-testid based on the mock for the default 'code' type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Initially shows activity tab (first tab is active)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
// Check tabs structure
expect(props.tabs).toHaveLength(2);
expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Check if the correct mocked components are used as children
// Access the mocked functions directly
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
// Now shows settings tab content
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
// Click activity tab again
const activityTab = screen.getByText("Activity");
await user.click(activityTab);
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
// Back to activity tab content
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
// Check props passed to child components
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.actionClass).toBe(mockActionClass);
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
test("renders correct icon based on action type", () => {
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
// Expect the 'nocode-icon' based on the updated mock and action type
expect(props.icon).toBeDefined();
test("handles action without description", () => {
const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
});
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
});
test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.isReadOnly).toBe(true);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: true,
}),
undefined
);
});
});
@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
},
];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return (
<>
<ModalWithTabs
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={actionClass.description || ""}
description={typeDescription()}
/>
</>
);
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)}
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
<Button
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger">
<TrashIcon />
{t("common.delete")}
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal" {...props}>
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
}));
vi.mock("@tolgee/react", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
);
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("opens the modal when the 'Add Action' button is clicked", async () => {
test("opens the dialog when the 'Add Action' button is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function);
});
test("closes the modal when the close button (simulated) is clicked", async () => {
test("closes the dialog when the close button (simulated) is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
// Simulate closing via the mocked Dialog's close button
const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeDialogButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});
@@ -2,7 +2,14 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")}
<PlusIcon />
</Button>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MousePointerClickIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.actions.track_new_user_action")}
</div>
<div className="text-sm text-slate-500">
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<DialogDescription>
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
};
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{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>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
);
};
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({
open,
setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={handleClose} noPadding>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.airtable.link_airtable_table")}
</div>
<div className="text-sm text-slate-500">
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")}
</div>
</DialogDescription>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="flex rounded-lg p-6">
<div className="flex w-full flex-col gap-y-4 pt-5">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<DialogBody className="overflow-visible">
<div className="flex w-full flex-col gap-y-4">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
</div>
</div>
) : (
<NoBaseFoundError />
)}
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
) : null}
{!surveys.length ? (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
) : null}
{survey && selectedSurvey && (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
render={({ field }) => (
<Select
required
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
)}
<Button type="submit">{t("common.save")}</Button>
{survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div>
</div>
</div>
</form>
</Modal>
</DialogBody>
<DialogFooter>
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{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>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.google_sheets.link_google_sheet")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(trashButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});
@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center">
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="w-[340px] max-w-full">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/>
</div>
</div>
<button
type="button"
className={`rounded-md p-1 hover:bg-slate-300 ${
idx === mapping.length - 1 ? "visible" : "invisible"
}`}
onClick={addRow}>
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
<button
type="button"
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.notion.link_notion_database")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.notion.link_database")}
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{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>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react";
import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
);
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.slack.link_slack_channel")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, title }: any) =>
// Mock the Dialog component
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<div data-testid="dialog" role="dialog">
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{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>,
}));
// Mock the PasswordInput component
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
test("renders dialog content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {
@@ -1,8 +1,16 @@
"use client";
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 } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
};
return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
)),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
}));
const mockResponses = [
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("should render the modal when a response is selected", () => {
test("should render the dialog when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
});
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled();
});
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
test("should render ChevronLeft and ChevronRight icons", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
}
};
const handleClose = () => {
setSelectedResponseId(null);
const handleClose = (open: boolean) => {
setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
};
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
return (
<Modal
hideCloseButton
open={open}
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<DialogBody>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
</div>
</Modal>
</DialogBody>
<DialogFooter>
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
}),
}));
// Mock Modal
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
// Mock Dialog
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
}));
// Mock useTranslate
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react";
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} size="lg">
{showPublishModal && surveyUrl ? (
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogBody>
{showPublishModal && surveyUrl ? (
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="text-primary h-20 w-20" />
<div>
<p className="text-primary text-lg font-medium">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname",
"last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden",
@@ -633,6 +634,7 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
"airtable_logo": "Airtable-Logo",
"connect_with_airtable": "Mit Airtable verbinden",
"link_airtable_table": "Airtable Tabelle verknüpfen",
"link_new_table": "Neue Tabelle verknüpfen",
@@ -722,6 +724,7 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Sende Antworten direkt an Slack.",
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
"slack_logo": "Slack-Logo",
"slack_reconnect_button": "Erneut verbinden",
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
},
@@ -906,8 +909,7 @@
"tag_already_exists": "Tag existiert bereits",
"tag_deleted": "Tag gelöscht",
"tag_updated": "Tag aktualisiert",
"tags_merged": "Tags zusammengeführt",
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
"tags_merged": "Tags zusammengeführt"
},
"teams": {
"manage_teams": "Teams verwalten",
@@ -1065,6 +1067,7 @@
"create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
"delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"first_name": "First Name",
"last_name": "Last Name",
"no_responses_found": "No responses found",
@@ -633,6 +634,7 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Sync responses directly with Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration is not configured",
"airtable_logo": "Airtable logo",
"connect_with_airtable": "Connect with Airtable",
"link_airtable_table": "Link Airtable Table",
"link_new_table": "Link new table",
@@ -722,6 +724,7 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Send responses directly to Slack.",
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
"slack_logo": "Slack logo",
"slack_reconnect_button": "Reconnect",
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
},
@@ -906,8 +909,7 @@
"tag_already_exists": "Tag already exists",
"tag_deleted": "Tag deleted",
"tag_updated": "Tag updated",
"tags_merged": "Tags merged",
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
"tags_merged": "Tags merged"
},
"teams": {
"manage_teams": "Manage teams",
@@ -1065,6 +1067,7 @@
"create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
"delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom",
"last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée",
@@ -633,6 +634,7 @@
"airtable_integration": "Intégration Airtable",
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
"airtable_logo": "Logo Airtable",
"connect_with_airtable": "Se connecter à Airtable",
"link_airtable_table": "Lier la table Airtable",
"link_new_table": "Lier nouvelle table",
@@ -722,6 +724,7 @@
"slack_integration": "Intégration Slack",
"slack_integration_description": "Envoyez les réponses directement sur Slack.",
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
"slack_logo": "logo Slack",
"slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
},
@@ -906,8 +909,7 @@
"tag_already_exists": "Le tag existe déjà",
"tag_deleted": "Tag supprimé",
"tag_updated": "Étiquette mise à jour",
"tags_merged": "Étiquettes fusionnées",
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
"tags_merged": "Étiquettes fusionnées"
},
"teams": {
"manage_teams": "Gérer les équipes",
@@ -1065,6 +1067,7 @@
"create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -633,6 +634,7 @@
"airtable_integration": "Integração com Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "Logo do Airtable",
"connect_with_airtable": "Conectar com o Airtable",
"link_airtable_table": "Vincular Tabela do Airtable",
"link_new_table": "Vincular nova tabela",
@@ -722,6 +724,7 @@
"slack_integration": "Integração com o Slack",
"slack_integration_description": "Manda as respostas direto pro Slack.",
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logotipo do Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
},
@@ -906,8 +909,7 @@
"tag_already_exists": "Tag já existe",
"tag_deleted": "Tag apagada",
"tag_updated": "Tag atualizada",
"tags_merged": "Tags mescladas",
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
"tags_merged": "Tags mescladas"
},
"teams": {
"manage_teams": "Gerenciar Equipes",
@@ -1065,6 +1067,7 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -633,6 +634,7 @@
"airtable_integration": "Integração com o Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "logotipo Airtable",
"connect_with_airtable": "Ligar ao Airtable",
"link_airtable_table": "Ligar Tabela Airtable",
"link_new_table": "Ligar nova tabela",
@@ -722,6 +724,7 @@
"slack_integration": "Integração com Slack",
"slack_integration_description": "Enviar respostas diretamente para o Slack.",
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logótipo Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
},
@@ -906,8 +909,7 @@
"tag_already_exists": "A etiqueta já existe",
"tag_deleted": "Etiqueta eliminada",
"tag_updated": "Etiqueta atualizada",
"tags_merged": "Etiquetas fundidas",
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
"tags_merged": "Etiquetas fundidas"
},
"teams": {
"manage_teams": "Gerir equipas",
@@ -1065,6 +1067,7 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
+5 -2
View File
@@ -597,6 +597,7 @@
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
@@ -633,6 +634,7 @@
"airtable_integration": "Airtable 整合",
"airtable_integration_description": "直接與 Airtable 同步回應。",
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
"airtable_logo": "Airtable 標誌",
"connect_with_airtable": "連線 Airtable",
"link_airtable_table": "連結 Airtable 表格",
"link_new_table": "連結新表格",
@@ -722,6 +724,7 @@
"slack_integration": "Slack 整合",
"slack_integration_description": "直接將回應傳送至 Slack。",
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
"slack_logo": "Slack 標誌",
"slack_reconnect_button": "重新連線",
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
},
@@ -906,8 +909,7 @@
"tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新",
"tags_merged": "標籤已合併",
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
"tags_merged": "標籤已合併"
},
"teams": {
"manage_teams": "管理團隊",
@@ -1065,6 +1067,7 @@
"create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -150,9 +150,10 @@ export const SingleResponseCard = ({
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
deleteWhat={t("common.response")}
onDelete={handleDeleteResponse}
isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}
/>
</div>
{user && pageType === "response" && (
@@ -2,6 +2,7 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
@@ -48,18 +49,21 @@ export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: De
return (
<>
<button
<Button
variant="destructive"
size="icon"
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
<TrashIcon />
</Button>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
isDeleting={isDeletingPerson}
text={t("environments.contacts.delete_contact_confirmation")}
/>
</>
);
@@ -6,12 +6,21 @@ import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
import { useTranslate } from "@tolgee/react";
import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, CircleAlertIcon, FileUpIcon, PlusIcon, XIcon } from "lucide-react";
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -286,190 +295,155 @@ export const UploadContactsCSVButton = ({
{t("common.upload")} CSV
<PlusIcon />
</Button>
<Modal
open={open}
setOpen={setOpen}
noPadding
closeOnOutsideClick={false}
className="overflow-auto"
size="xl"
hideCloseButton>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
resetState(true);
}}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<FileUpIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">{t("common.upload")} CSV</div>
<div className="text-sm text-slate-500">
{t("environments.contacts.upload_contacts_modal_description")}
</div>
</div>
</div>
</div>
</div>
</div>
{error ? (
<div
className="mx-6 my-4 flex items-center gap-2 rounded-md border-2 border-red-200 bg-red-50 p-4"
ref={errorContainerRef}>
<CircleAlertIcon className="text-red-600" />
<p className="text-red-600">{error}</p>
</div>
) : null}
<div className="flex flex-col gap-8 px-6 py-4">
<div className="flex flex-col gap-2">
<div className="no-scrollbar max-h-[400px] overflow-auto rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{!csvResponse.length ? (
<div>
<label
htmlFor="file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
<DialogHeader>
<FileUpIcon />
<DialogTitle>{t("common.upload")} CSV</DialogTitle>
<DialogDescription>
{t("environments.contacts.upload_contacts_modal_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-6">
{error ? (
<Alert variant="error" size="small">
{error}
</Alert>
) : null}
<div className="flex flex-col gap-2">
{csvColumns.map((column, index) => {
return (
<UploadContactsAttributes
key={index}
csvColumn={column}
attributeMap={attributeMap}
setAttributeMap={setAttributeMap}
contactAttributeKeys={contactAttributeKeys}
/>
);
})}
<div className="no-scrollbar rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{!csvResponse.length ? (
<div>
<label
htmlFor="file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<div className="flex flex-col gap-2">
{csvColumns.map((column, index) => {
return (
<UploadContactsAttributes
key={index}
csvColumn={column}
attributeMap={attributeMap}
setAttributeMap={setAttributeMap}
contactAttributeKeys={contactAttributeKeys}
/>
);
})}
</div>
</div>
) : null}
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div>
</div>
) : null}
</DialogBody>
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div>
</div>
<div className="sticky bottom-0 w-full bg-white">
<div className="flex justify-end rounded-b-lg p-4">
<DialogFooter>
{csvResponse.length > 0 ? (
<Button
size="sm"
variant="secondary"
onClick={() => {
resetState();
}}
className="mr-2">
}}>
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button>
) : null}
<Button
size="sm"
onClick={handleUpload}
loading={loading}
disabled={loading || !csvResponse.length}>
<Button onClick={handleUpload} loading={loading} disabled={loading || !csvResponse.length}>
{t("environments.contacts.upload_contacts_modal_upload_btn")}
</Button>
</div>
</div>
</Modal>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -6,47 +6,66 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
closeOnOutsideClick,
setOpen,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
closeOnOutsideClick?: boolean;
setOpen?: (open: boolean) => void;
}) => {
return open ? ( // NOSONAR // This is a mock
<button
data-testid="modal-overlay"
onClick={(e) => {
if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) {
setOpen(false);
}
}}>
<div data-testid="modal-content">{children}</div>
</button>
) : null; // NOSONAR // This is a mock
},
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({
children,
className,
hideCloseButton,
}: {
children: React.ReactNode;
className?: string;
hideCloseButton?: boolean;
}) => (
<div data-testid="dialog-content" className={className} data-hide-close-button={hideCloseButton}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the Input component
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ placeholder, onChange, autoFocus }: any) => (
<input data-testid="search-input" placeholder={placeholder} onChange={onChange} autoFocus={autoFocus} />
),
}));
// Mock the TabBar component
vi.mock("@/modules/ui/components/tab-bar", () => ({
TabBar: ({
tabs,
activeId,
setActiveId,
}: {
tabs: any[];
activeId: string;
setActiveId: (id: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button key={tab.id} data-testid={`tab-${tab.id}`} onClick={() => setActiveId(tab.id)}>
TabBar: ({ tabs, activeId, setActiveId }: any) => (
<div data-testid="tab-bar">
{tabs.map((tab: any) => (
<button
key={tab.id}
data-testid={`tab-${tab.id}`}
onClick={() => setActiveId(tab.id)}
className={activeId === tab.id ? "active" : ""}>
{tab.label} {activeId === tab.id ? "(Active)" : ""}
</button>
))}
@@ -54,11 +73,94 @@ vi.mock("@/modules/ui/components/tab-bar", () => ({
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.add_filter": "Add Filter",
"common.all": "All",
"environments.segments.person_and_attributes": "Person & Attributes",
"common.segments": "Segments",
"environments.segments.devices": "Devices",
"environments.segments.phone": "Phone",
"environments.segments.desktop": "Desktop",
"environments.segments.no_filters_yet": "No filters yet",
"environments.segments.no_segments_yet": "No segments yet",
"environments.segments.no_attributes_yet": "No attributes yet",
"common.user_id": "userId",
"common.person": "Person",
"common.attributes": "Attributes",
};
return translations[key] || key;
},
}),
}));
// Mock createId
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mockCuid"),
}));
// Mock the AttributeTabContent component
vi.mock("./attribute-tab-content", () => ({
default: ({ contactAttributeKeys, onAddFilter, setOpen, handleAddFilter }: any) => (
<div data-testid="attribute-tab-content">
<h2>Person</h2>
<button
data-testid="filter-btn-person-userId"
onClick={() => handleAddFilter({ type: "person", onAddFilter, setOpen })}
onKeyDown={(e: any) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAddFilter({ type: "person", onAddFilter, setOpen });
}
}}
tabIndex={0}>
userId
</button>
<hr />
<h2>Attributes</h2>
{contactAttributeKeys.length === 0 ? (
<p>No attributes yet</p>
) : (
contactAttributeKeys.map((attr: any) => (
<button
key={attr.id}
data-testid={`filter-btn-attribute-${attr.key}`}
onClick={() =>
handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key })
}
onKeyDown={(e: any) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key });
}
}}
tabIndex={0}>
{attr.name ?? attr.key}
</button>
))
)}
</div>
),
}));
// Mock the FilterButton component
vi.mock("./filter-button", () => ({
default: ({ icon, label, onClick, onKeyDown, tabIndex = 0, ...props }: any) => (
<button
className="flex w-full cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={onKeyDown}
{...props}>
{icon}
<span>{label}</span>
</button>
),
}));
const mockContactAttributeKeys: TContactAttributeKey[] = [
{
id: "attr1",
@@ -154,16 +256,20 @@ describe("AddFilterModal", () => {
/>
);
// ... assertions ...
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Add Filter");
expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Browse filters...")).toBeInTheDocument();
expect(screen.getByTestId("tab-all")).toHaveTextContent("common.all (Active)");
expect(screen.getByTestId("tab-all")).toHaveTextContent("All (Active)");
expect(screen.getByText("Email Address")).toBeInTheDocument();
expect(screen.getByText("Plan Type")).toBeInTheDocument();
expect(screen.getByText("userId")).toBeInTheDocument();
expect(screen.getByText("Active Users")).toBeInTheDocument();
expect(screen.getByText("Paying Customers")).toBeInTheDocument();
expect(screen.queryByText("Private Segment")).not.toBeInTheDocument();
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
expect(screen.getByText("environments.segments.desktop")).toBeInTheDocument();
expect(screen.getByText("Phone")).toBeInTheDocument();
expect(screen.getByText("Desktop")).toBeInTheDocument();
});
test("does not render when closed", () => {
@@ -176,6 +282,7 @@ describe("AddFilterModal", () => {
segments={mockSegments}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument();
});
@@ -210,22 +317,22 @@ describe("AddFilterModal", () => {
const attributesTabButton = screen.getByTestId("tab-attributes");
await user.click(attributesTabButton);
// ... assertions ...
expect(attributesTabButton).toHaveTextContent("environments.segments.person_and_attributes (Active)");
expect(screen.getByText("common.user_id")).toBeInTheDocument();
expect(attributesTabButton).toHaveTextContent("Person & Attributes (Active)");
expect(screen.getByText("userId")).toBeInTheDocument();
// Switch to Segments tab
const segmentsTabButton = screen.getByTestId("tab-segments");
await user.click(segmentsTabButton);
// ... assertions ...
expect(segmentsTabButton).toHaveTextContent("common.segments (Active)");
expect(segmentsTabButton).toHaveTextContent("Segments (Active)");
expect(screen.getByText("Active Users")).toBeInTheDocument();
// Switch to Devices tab
const devicesTabButton = screen.getByTestId("tab-devices");
await user.click(devicesTabButton);
// ... assertions ...
expect(devicesTabButton).toHaveTextContent("environments.segments.devices (Active)");
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
expect(devicesTabButton).toHaveTextContent("Devices (Active)");
expect(screen.getByText("Phone")).toBeInTheDocument();
});
// --- Click and Keydown Tests ---
@@ -499,7 +606,7 @@ describe("AddFilterModal", () => {
/>
);
await user.click(screen.getByTestId("tab-attributes"));
expect(await screen.findByText("environments.segments.no_attributes_yet")).toBeInTheDocument();
expect(await screen.findByText("No attributes yet")).toBeInTheDocument();
});
test("displays 'no segments yet' message", async () => {
@@ -513,7 +620,7 @@ describe("AddFilterModal", () => {
/>
);
await user.click(screen.getByTestId("tab-segments"));
expect(await screen.findByText("environments.segments.no_segments_yet")).toBeInTheDocument();
expect(await screen.findByText("No segments yet")).toBeInTheDocument();
});
test("displays 'no filters match' message when search yields no results", async () => {
@@ -528,7 +635,7 @@ describe("AddFilterModal", () => {
);
const searchInput = screen.getByPlaceholderText("Browse filters...");
await user.type(searchInput, "nonexistentfilter");
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
expect(await screen.findByText("No filters yet")).toBeInTheDocument();
});
test("verifies keyboard navigation through filter buttons", async () => {
@@ -548,19 +655,19 @@ describe("AddFilterModal", () => {
// Tab to the first tab button ("all")
await user.tab();
expect(document.activeElement).toHaveTextContent(/common\.all/);
expect(document.activeElement).toHaveTextContent(/All/);
// Tab to the second tab button ("attributes")
await user.tab();
expect(document.activeElement).toHaveTextContent(/person_and_attributes/);
expect(document.activeElement).toHaveTextContent(/Person & Attributes/);
// Tab to the third tab button ("segments")
await user.tab();
expect(document.activeElement).toHaveTextContent(/common\.segments/);
expect(document.activeElement).toHaveTextContent(/Segments/);
// Tab to the fourth tab button ("devices")
await user.tab();
expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/);
expect(document.activeElement).toHaveTextContent(/Devices/);
// Tab to the first filter button ("Email Address")
await user.tab();
@@ -595,21 +702,4 @@ describe("AddFilterModal", () => {
expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable
});
});
test("closes the modal when clicking outside the content area", async () => {
render(
<AddFilterModal
open={true}
setOpen={setOpen}
onAddFilter={onAddFilter}
contactAttributeKeys={mockContactAttributeKeys}
segments={mockSegments}
/>
);
const modalOverlay = screen.getByTestId("modal-overlay");
await user.click(modalOverlay);
expect(setOpen).toHaveBeenCalledWith(false);
});
});
@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/cn";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
@@ -457,26 +457,31 @@ export function AddFilterModal({
};
return (
<Modal
className="sm:w-[650px] sm:max-w-full"
closeOnOutsideClick
hideCloseButton
open={open}
setOpen={setOpen}>
<div className="flex w-auto flex-col">
<Input
autoFocus
onChange={(e) => {
setSearchValue(e.target.value);
}}
placeholder="Browse filters..."
/>
<TabBar activeId={activeTabId} className="bg-white" setActiveId={setActiveTabId} tabs={tabs} />
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent width="narrow" disableCloseOnOutsideClick>
<DialogHeader>
<DialogTitle>{t("common.add_filter")}</DialogTitle>
</DialogHeader>
<div className={cn("mt-2 flex max-h-80 flex-col gap-1 overflow-y-auto")}>
<TabContent />
</div>
</Modal>
<DialogBody>
<div className="flex flex-col">
<div className="flex w-auto flex-col">
<Input
autoFocus
onChange={(e) => {
setSearchValue(e.target.value);
}}
placeholder="Browse filters..."
/>
<TabBar activeId={activeTabId} className="bg-white" setActiveId={setActiveTabId} tabs={tabs} />
</div>
<div className={cn("mt-2 flex flex-col gap-1 overflow-y-auto")}>
<TabContent />
</div>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
@@ -27,16 +27,55 @@ vi.mock("@/modules/ee/contacts/segments/actions", () => ({
}));
// Mock child components that are complex or have their own tests
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, setOpen, children, noPadding, closeOnOutsideClick, size, className }) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="modal" className={className} data-size={size} data-nopadding={noPadding}>
<div data-testid="dialog">
{children}
<button data-testid="modal-close-outside" onClick={() => closeOnOutsideClick && setOpen(false)}>
Close Outside
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({
children,
className,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
className?: string;
disableCloseOnOutsideClick?: boolean;
}) => (
<div
data-testid="dialog-content"
className={className}
data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
vi.mock("./add-filter-modal", () => ({
@@ -84,12 +123,12 @@ describe("CreateSegmentModal", () => {
render(<CreateSegmentModal {...defaultProps} />);
const createButton = screen.getByText("common.create_segment");
expect(createButton).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
await userEvent.click(createButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("common.create_segment", { selector: "h3" })).toBeInTheDocument(); // Modal title
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("common.create_segment", { selector: "h2" })).toBeInTheDocument(); // Modal title
});
test("closes modal on cancel button click", async () => {
@@ -97,11 +136,11 @@ describe("CreateSegmentModal", () => {
const createButton = screen.getByText("common.create_segment");
await userEvent.click(createButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("updates title and description state on input change", async () => {
@@ -144,7 +183,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton);
// Get modal and scope queries
const modal = await screen.findByTestId("modal");
const modal = await screen.findByTestId("dialog");
// Find the save button using getByText with a specific selector within the modal
const saveButton = within(modal).getByText("common.create_segment", {
@@ -168,7 +207,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(createButton);
// Get modal and scope queries
const modal = await screen.findByTestId("modal");
const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
const descriptionInput = within(modal).getByPlaceholderText(
@@ -196,7 +235,7 @@ describe("CreateSegmentModal", () => {
});
});
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully");
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); // Modal should close on success
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); // Modal should close on success
});
test("shows error toast if createSegmentAction fails", async () => {
@@ -219,7 +258,7 @@ describe("CreateSegmentModal", () => {
});
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
expect(toast.error).toHaveBeenCalledWith("Formatted API Error");
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open on error
expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open on error
});
test("shows generic error toast if Zod parsing succeeds during save error handling", async () => {
@@ -230,7 +269,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton);
// Get the modal element
const modal = await screen.findByTestId("modal");
const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
await userEvent.type(titleInput, "Generic Error Segment");
@@ -253,7 +292,7 @@ describe("CreateSegmentModal", () => {
// Now that we know the catch block ran, verify the action was called
expect(createSegmentAction).toHaveBeenCalled();
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open
expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open
});
test("opens AddFilterModal when 'Add Filter' button is clicked", async () => {
@@ -4,8 +4,16 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -132,41 +140,30 @@ export function CreateSegmentModal({
<PlusIcon />
</Button>
<Modal
className="md:w-full"
closeOnOutsideClick={false}
noPadding
<Dialog
open={open}
setOpen={() => {
handleResetState();
}}
size="lg">
<div className="rounded-lg bg-slate-50">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<UsersIcon className="h-5 w-5" />
</div>
<div>
<h3 className="text-base font-medium">{t("common.create_segment")}</h3>
<p className="text-sm text-slate-600">
{t(
"environments.segments.segments_help_you_target_users_with_same_characteristics_easily"
)}
</p>
</div>
</div>
</div>
</div>
onOpenChange={(open) => {
if (!open) {
handleResetState();
}
}}>
<DialogContent className="sm:max-w-4xl" disableCloseOnOutsideClick>
<DialogHeader>
<UsersIcon />
<DialogTitle>{t("common.create_segment")}</DialogTitle>
<DialogDescription>
{t("environments.segments.segments_help_you_target_users_with_same_characteristics_easily")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
<DialogBody>
<div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">{t("common.title")}</label>
<div className="relative flex flex-col gap-1">
<Input
className="w-auto"
value={segment.title}
onChange={(e) => {
setSegment((prev) => ({
...prev,
@@ -181,6 +178,7 @@ export function CreateSegmentModal({
<div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">{t("common.description")}</label>
<Input
value={segment.description ?? ""}
onChange={(e) => {
setSegment((prev) => ({
...prev,
@@ -191,72 +189,71 @@ export function CreateSegmentModal({
/>
</div>
</div>
<div className="flex flex-col gap-y-2 pt-4">
<label className="text-sm font-medium text-slate-900">{t("common.targeting")}</label>
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment.filters.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">
{t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
</div>
)}
<label className="my-4 text-sm font-medium text-slate-900">{t("common.targeting")}</label>
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment.filters.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">
{t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
</div>
)}
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
/>
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
/>
<Button
className="w-fit"
onClick={() => {
setAddFilterModalOpen(true);
}}
size="sm"
variant="secondary">
{t("common.add_filter")}
</Button>
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div>
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
className="w-fit"
onClick={() => {
handleResetState();
setAddFilterModalOpen(true);
}}
type="button"
variant="ghost">
{t("common.cancel")}
size="sm"
variant="secondary">
{t("common.add_filter")}
</Button>
<Button
disabled={isSaveDisabled}
loading={isCreatingSegment}
onClick={() => {
handleCreateSegment();
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
type="submit">
{t("common.create_segment")}
</Button>
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div>
</div>
</div>
</div>
</Modal>
</DialogBody>
<DialogFooter>
<Button
onClick={() => {
handleResetState();
}}
type="button"
variant="secondary">
{t("common.cancel")}
</Button>
<Button
disabled={isSaveDisabled}
loading={isCreatingSegment}
onClick={() => {
handleCreateSegment();
}}
type="submit">
{t("common.create_segment")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,32 +1,80 @@
import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
// Mock child components
vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({
SegmentSettings: vi.fn(() => <div>SegmentSettingsMock</div>),
SegmentSettings: vi.fn(() => <div data-testid="segment-settings">SegmentSettingsMock</div>),
}));
vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({
SegmentActivityTab: vi.fn(() => <div>SegmentActivityTabMock</div>),
SegmentActivityTab: vi.fn(() => <div data-testid="segment-activity-tab">SegmentActivityTabMock</div>),
}));
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ open, label, description, tabs, icon }) =>
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div>
<h1>{label}</h1>
<p>{description}</p>
<div>{icon}</div>
<ul>
{tabs.map((tab) => (
<li key={tab.title}>
<h2>{tab.title}</h2>
<div>{tab.children}</div>
</li>
))}
</ul>
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null
) : null,
DialogContent: ({
children,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
disableCloseOnOutsideClick?: boolean;
}) => (
<div data-testid="dialog-content" data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
};
return translations[key] || key;
},
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
UsersIcon: ({ className }: { className?: string }) => (
<span data-testid="users-icon" className={className}>
👥
</span>
),
}));
@@ -62,77 +110,92 @@ describe("EditSegmentModal", () => {
vi.clearAllMocks();
});
test("renders correctly when open and contacts enabled", async () => {
test("renders correctly when open and contacts enabled", () => {
render(<EditSegmentModal {...defaultProps} />);
expect(screen.getByText("Test Segment")).toBeInTheDocument();
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
expect(screen.getByText("common.activity")).toBeInTheDocument();
expect(screen.getByText("common.settings")).toBeInTheDocument();
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
expect(screen.getByText("SegmentSettingsMock")).toBeInTheDocument();
const ModalWithTabsMock = vi.mocked(
await import("@/modules/ui/components/modal-with-tabs")
).ModalWithTabs;
// Check that the mock was called
expect(ModalWithTabsMock).toHaveBeenCalled();
// Get the arguments of the first call
const callArgs = ModalWithTabsMock.mock.calls[0];
expect(callArgs).toBeDefined(); // Ensure the mock was called
const propsPassed = callArgs[0]; // The first argument is the props object
// Assert individual properties
expect(propsPassed.open).toBe(true);
expect(propsPassed.setOpen).toBe(defaultProps.setOpen);
expect(propsPassed.label).toBe("Test Segment");
expect(propsPassed.description).toBe("This is a test segment");
expect(propsPassed.closeOnOutsideClick).toBe(false);
expect(propsPassed.icon).toBeDefined(); // Check if icon exists
expect(propsPassed.tabs).toHaveLength(2); // Check number of tabs
// Check properties of the first tab
expect(propsPassed.tabs[0].title).toBe("common.activity");
expect(propsPassed.tabs[0].children).toBeDefined();
// Check properties of the second tab
expect(propsPassed.tabs[1].title).toBe("common.settings");
expect(propsPassed.tabs[1].children).toBeDefined();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
expect(screen.getByTestId("users-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("renders correctly when open and contacts disabled", async () => {
test("renders correctly when open and contacts disabled", () => {
render(<EditSegmentModal {...defaultProps} isContactsEnabled={false} />);
expect(screen.getByText("Test Segment")).toBeInTheDocument();
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
expect(screen.getByText("common.activity")).toBeInTheDocument();
expect(screen.getByText("common.settings")).toBeInTheDocument(); // Tab title still exists
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
// Check that the settings content is not rendered, which is the key behavior
expect(screen.queryByText("SegmentSettingsMock")).not.toBeInTheDocument();
const ModalWithTabsMock = vi.mocked(
await import("@/modules/ui/components/modal-with-tabs")
).ModalWithTabs;
const calls = ModalWithTabsMock.mock.calls;
const lastCallArgs = calls[calls.length - 1][0]; // Get the props of the last call
// Check that the Settings tab was passed in props
const settingsTab = lastCallArgs.tabs.find((tab) => tab.title === "common.settings");
expect(settingsTab).toBeDefined();
// The children prop will be <SettingsTab />, but its rendered output is null/empty.
// The check above (queryByText("SegmentSettingsMock")) already confirms this.
// No need to check settingsTab.children === null here.
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
// Settings tab content should not render when contacts are disabled
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("does not render when open is false", () => {
render(<EditSegmentModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Test Segment")).not.toBeInTheDocument();
expect(screen.queryByText("common.activity")).not.toBeInTheDocument();
expect(screen.queryByText("common.settings")).not.toBeInTheDocument();
expect(screen.queryByText("Activity")).not.toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<EditSegmentModal {...defaultProps} />);
// Initially shows activity tab (first tab is active)
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now shows settings tab content
expect(screen.queryByTestId("segment-activity-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
// Click activity tab again
const activityTab = screen.getByText("Activity");
await user.click(activityTab);
// Back to activity tab content
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<EditSegmentModal {...defaultProps} />);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
// Close modal
rerender(<EditSegmentModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<EditSegmentModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("handles segment without description", () => {
const segmentWithoutDescription = { ...mockSegment, description: "" };
render(<EditSegmentModal {...defaultProps} currentSegment={segmentWithoutDescription} />);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("");
});
});
@@ -1,9 +1,17 @@
"use client";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { SegmentActivityTab } from "./segment-activity-tab";
@@ -30,6 +38,8 @@ export const EditSegmentModal = ({
isReadOnly,
}: EditSegmentModalProps) => {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => {
if (isContactsEnabled) {
return (
@@ -58,17 +68,42 @@ export const EditSegmentModal = ({
},
];
const handleTabClick = (index: number) => {
setActiveTab(index);
};
useEffect(() => {
if (!open) {
setActiveTab(0);
}
}, [open]);
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<UsersIcon className="h-5 w-5" />}
label={currentSegment.title}
description={currentSegment.description || ""}
closeOnOutsideClick={false}
/>
</>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<UsersIcon />
<DialogTitle>{currentSegment.title}</DialogTitle>
<DialogDescription>{currentSegment.description ?? ""}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex h-full w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
key={tab.title}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 pt-4">{tabs[activeTab].children}</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -138,7 +138,7 @@ export function SegmentSettings({
}, [segment]);
return (
<div className="mb-4">
<div>
<div className="rounded-lg bg-slate-50">
<div className="flex flex-col overflow-auto rounded-lg bg-white">
<div className="flex w-full items-center gap-4">
@@ -179,50 +179,51 @@ export function SegmentSettings({
</div>
</div>
<label className="my-4 text-sm font-medium text-slate-900">{t("common.targeting")}</label>
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment.filters.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">
{t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
<div className="flex flex-col gap-y-2 pt-4">
<label className="text-sm font-medium text-slate-900">{t("common.targeting")}</label>
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment.filters.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">
{t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
</div>
)}
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
viewOnly={isReadOnly}
/>
<div>
<Button
onClick={() => {
setAddFilterModalOpen(true);
}}
size="sm"
disabled={isReadOnly}
variant="secondary">
{t("common.add_filter")}
</Button>
</div>
)}
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
viewOnly={isReadOnly}
/>
<div>
<Button
onClick={() => {
setAddFilterModalOpen(true);
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
size="sm"
disabled={isReadOnly}
variant="secondary">
{t("common.add_filter")}
</Button>
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div>
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div>
<div className="flex w-full items-center justify-between pt-4">
{!isReadOnly && (
<>
@@ -6,8 +6,24 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamModal } from "./create-team-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children }: any) => <div data-testid="Modal">{children}</div>,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
@@ -24,9 +40,13 @@ describe("CreateTeamModal", () => {
const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => {
test("renders dialog, form, and tolgee strings", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
@@ -47,7 +67,7 @@ describe("CreateTeamModal", () => {
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
});
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => {
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes dialog on success", async () => {
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
const onCreate = vi.fn();
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />);
@@ -3,10 +3,16 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -48,45 +54,45 @@ export const CreateTeamModal = ({ open, setOpen, organizationId, onCreate }: Cre
};
return (
<Modal noPadding closeOnOutsideClick={true} size="md" open={open} setOpen={setOpen}>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UsersIcon className="h-5 w-5" />
<H4>{t("environments.settings.teams.create_new_team")}</H4>
</div>
</div>
</div>
<form onSubmit={handleTeamCreation}>
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
<Label htmlFor="team-name" className="mb-1 text-sm font-medium text-slate-900">
{t("environments.settings.teams.team_name")}
</Label>
<Input
id="team-name"
name="team-name"
value={teamName}
onChange={(e) => {
setTeamName(e.target.value);
}}
placeholder={t("environments.settings.teams.enter_team_name")}
/>
</div>
<div className="flex items-end justify-end gap-2 p-6 pt-0">
<Button
variant="secondary"
type="button"
onClick={() => {
setOpen(false);
setTeamName("");
}}>
{t("common.cancel")}
</Button>
<Button disabled={!teamName || isLoading} loading={isLoading} type="submit">
{t("environments.settings.teams.create")}
</Button>
</div>
</form>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<UsersIcon />
<DialogTitle>{t("environments.settings.teams.create_new_team")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleTeamCreation} className="gap-y-4 pt-4">
<DialogBody>
<div className="grid w-full gap-y-2 pb-4">
<Label htmlFor="team-name">{t("environments.settings.teams.team_name")}</Label>
<Input
id="team-name"
name="team-name"
value={teamName}
onChange={(e) => {
setTeamName(e.target.value);
}}
placeholder={t("environments.settings.teams.enter_team_name")}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
variant="secondary"
type="button"
onClick={() => {
setOpen(false);
setTeamName("");
}}>
{t("common.cancel")}
</Button>
<Button disabled={!teamName || isLoading} loading={isLoading} type="submit">
{t("environments.settings.teams.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -42,7 +42,7 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
return (
<>
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-baseline space-x-2">
<Label htmlFor="deleteTeamButton">{t("common.danger_zone")}</Label>
<TooltipRenderer
shouldRender={!isOwnerOrManager}
@@ -50,7 +50,6 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
className="w-auto">
<Button
variant="destructive"
size="sm"
type="button"
id="deleteTeamButton"
className="w-auto"
@@ -7,8 +7,49 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamSettingsModal } from "./team-settings-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, ...props }: any) => <div data-testid="Modal">{children}</div>,
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
@@ -60,15 +101,15 @@ describe("TeamSettingsModal", () => {
currentUserId="1"
/>
);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument();
expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Add project")).toBeInTheDocument();
expect(screen.getByText("common.projects")).toBeInTheDocument();
expect(screen.getByText("common.add_project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
@@ -1,6 +1,5 @@
"use client";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
@@ -17,9 +16,17 @@ import {
} from "@/modules/ee/teams/team-list/types/team";
import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams";
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 } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -28,10 +35,10 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { H4, Muted } from "@/modules/ui/components/typography";
import { Muted } from "@/modules/ui/components/typography";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -196,44 +203,19 @@ export const TeamSettingsModal = ({
const hasEmptyProject = watchProjects.some((p) => !p.projectId);
return (
<Modal
open={open}
setOpen={setOpen}
noPadding
className="flex max-h-[90dvh] flex-col overflow-visible"
size="md"
hideCloseButton
closeOnOutsideClick={true}>
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form
className="flex w-full flex-grow flex-col overflow-hidden"
onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex-grow space-y-6 overflow-y-auto p-6">
<div className="space-y-6">
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader className="pb-4">
<DialogTitle>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</DialogTitle>
<DialogDescription>{t("environments.settings.teams.team_settings_description")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form className="contents space-y-4" onSubmit={handleSubmit(handleUpdateTeam)}>
<DialogBody className="flex-grow space-y-6 overflow-y-auto">
<FormField
control={control}
name="name"
@@ -255,13 +237,18 @@ export const TeamSettingsModal = ({
{/* Members Section */}
<div className="space-y-2">
<FormLabel>{t("common.members")}</FormLabel>
<div className="flex flex-col space-y-1">
<FormLabel>{t("common.members")}</FormLabel>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_members_description")}
</Muted>
</div>
<FormField
control={control}
name={`members`}
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
<div className="space-y-2 overflow-y-auto">
{watchMembers.map((member, index) => {
const memberOpts = getMemberOptionsForIndex(index);
return (
@@ -382,20 +369,22 @@ export const TeamSettingsModal = ({
<span>Add member</span>
</Button>
</TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_members_description")}
</Muted>
</div>
{/* Projects Section */}
<div className="space-y-2">
<FormLabel>Projects</FormLabel>
<div className="flex flex-col space-y-1">
<FormLabel>{t("common.projects")}</FormLabel>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_projects_description")}
</Muted>
</div>
<FormField
control={control}
name={`projects`}
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
<div className="space-y-2">
{watchProjects.map((project, index) => {
const projectOpts = getProjectOptionsForIndex(index);
return (
@@ -495,26 +484,19 @@ export const TeamSettingsModal = ({
!isOwnerOrManager || selectedProjectIds.length === orgProjects.length || hasEmptyProject
}>
<PlusIcon className="h-4 w-4" />
<span>Add project</span>
{t("common.add_project")}
</Button>
</TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_projects_description")}
</Muted>
</div>
<div className="w-max">
</DialogBody>
<DialogFooter>
<div className="w-full">
<DeleteTeam
teamId={team.id}
onDelete={closeSettingsModal}
isOwnerOrManager={isOwnerOrManager}
/>
</div>
</div>
</div>
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
<div className="flex justify-between">
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
{t("common.cancel")}
</Button>
@@ -525,10 +507,10 @@ export const TeamSettingsModal = ({
disabled={!isOwnerOrManager && !isTeamAdminMember}>
{t("common.save")}
</Button>
</div>
</div>
</form>
</FormProvider>
</Modal>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
@@ -14,6 +14,23 @@ vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
setupTwoFactorAuthAction: vi.fn(),
}));
// Mock the translation function
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"environments.settings.profile.two_factor_authentication": "Two-Factor Authentication",
"environments.settings.profile.confirm_your_current_password_to_get_started":
"Confirm your current password to get started",
"common.password": "Password",
"common.confirm": "Confirm",
"common.cancel": "Cancel",
};
return translations[key] || key;
},
}),
}));
describe("ConfirmPasswordForm", () => {
afterEach(() => {
cleanup();
@@ -31,13 +48,9 @@ describe("ConfirmPasswordForm", () => {
test("renders the form with password input", () => {
render(<ConfirmPasswordForm {...mockProps} />);
expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.confirm_your_current_password_to_get_started")
).toBeInTheDocument();
expect(screen.getByLabelText("common.password")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.confirm" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.cancel" })).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
test("handles form submission successfully", async () => {
@@ -56,9 +69,9 @@ describe("ConfirmPasswordForm", () => {
render(<ConfirmPasswordForm {...mockProps} />);
const passwordInput = screen.getByLabelText("common.password");
const passwordInput = screen.getByLabelText("Password");
await user.type(passwordInput, "testPassword123!");
const submitButton = screen.getByRole("button", { name: "common.confirm" });
const submitButton = screen.getByRole("button", { name: "Confirm" });
await user.click(submitButton);
await waitFor(() => {
@@ -74,7 +87,7 @@ describe("ConfirmPasswordForm", () => {
const user = userEvent.setup();
render(<ConfirmPasswordForm {...mockProps} />);
await user.click(screen.getByRole("button", { name: "common.cancel" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
});
});
@@ -57,16 +57,8 @@ export const ConfirmPasswordForm = ({
return (
<FormProvider {...form}>
<div className="p-6">
<h1 className="text-lg font-semibold">
{t("environments.settings.profile.two_factor_authentication")}
</h1>
<h3 className="text-sm text-slate-700">
{t("environments.settings.profile.confirm_your_current_password_to_get_started")}
</h3>
</div>
<form className="flex flex-col space-y-10" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6">
<form className="flex flex-col space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="password" className="text-sm font-medium text-slate-700">
{t("common.password")}
</label>
@@ -95,7 +87,7 @@ export const ConfirmPasswordForm = ({
/>
</div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<div className="flex w-full items-center justify-end space-x-2">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
@@ -5,6 +5,49 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DisableTwoFactorModal } from "./disable-two-factor-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: () => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={onOpenChange}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
disableTwoFactorAuthAction: vi.fn(),
}));
@@ -15,15 +58,23 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("DisableTwoFactorModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal with correct title and description", () => {
test("renders dialog with correct title and description", () => {
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.disable_two_factor_authentication")
).toBeInTheDocument();
@@ -3,9 +3,17 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { disableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/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 } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { OTPInput } from "@/modules/ui/components/otp-input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -63,101 +71,45 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
};
return (
<Modal
<Dialog
open={open}
setOpen={() => {
form.reset();
setOpen(false);
}}
noPadding>
<FormProvider {...form}>
<div>
<div className="p-6">
<h1 className="text-lg font-semibold">
{t("environments.settings.profile.disable_two_factor_authentication")}
</h1>
<p className="text-sm text-slate-700">
{t("environments.settings.profile.disable_two_factor_authentication_description")}
</p>
</div>
onOpenChange={(open) => {
if (!open) {
form.reset();
}
setOpen(open);
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.profile.disable_two_factor_authentication")}</DialogTitle>
<DialogDescription>
{t("environments.settings.profile.disable_two_factor_authentication_description")}
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form className="flex flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6">
<label htmlFor="password" className="text-sm font-medium text-slate-700">
{t("common.password")}
</label>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="px-6">
<DialogBody className="space-y-6">
<div className="flex flex-col gap-2">
<label htmlFor="code" className="text-sm font-medium text-slate-700">
{backupCodeInputVisible
? t("environments.settings.profile.backup_code")
: t("environments.settings.profile.two_factor_code")}
<label htmlFor="password" className="text-sm font-medium text-slate-700">
{t("common.password")}
</label>
<p className="text-sm text-slate-700">
{backupCodeInputVisible
? t(
"environments.settings.profile.each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator"
)
: t(
"environments.settings.profile.two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app"
)}
</p>
</div>
{backupCodeInputVisible ? (
<FormField
control={form.control}
name="backupCode"
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<Input {...field} placeholder="XXXXX-XXXXX" className="mt-2" />
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="code"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<OTPInput
value={field.value || ""}
valueLength={6}
onChange={field.onChange}
containerClassName="justify-start mt-4"
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
@@ -165,14 +117,70 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
</FormItem>
)}
/>
)}
</div>
</div>
<div className="flex w-full items-center justify-between border-t border-slate-300 p-4">
<div>
<div className="flex flex-col gap-2">
<label htmlFor="code" className="text-sm font-medium text-slate-700">
{backupCodeInputVisible
? t("environments.settings.profile.backup_code")
: t("environments.settings.profile.two_factor_code")}
</label>
<p className="text-sm text-slate-700">
{backupCodeInputVisible
? t(
"environments.settings.profile.each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator"
)
: t(
"environments.settings.profile.two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app"
)}
</p>
</div>
{backupCodeInputVisible ? (
<FormField
control={form.control}
name="backupCode"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<Input {...field} placeholder="XXXXX-XXXXX" className="mt-2" />
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="code"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<OTPInput
value={field.value || ""}
valueLength={6}
onChange={field.onChange}
containerClassName="justify-start mt-4"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</DialogBody>
<DialogFooter className="flex w-full items-center justify-between">
<div>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setBackupCodeInputVisible((prev) => !prev)}>
{backupCodeInputVisible
@@ -183,7 +191,6 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
<div className="flex items-center space-x-4">
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => {
form.reset();
@@ -192,14 +199,12 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
{t("common.cancel")}
</Button>
<Button size="sm" loading={form.formState.isSubmitting}>
{t("common.disable")}
</Button>
<Button loading={form.formState.isSubmitting}>{t("common.disable")}</Button>
</div>
</div>
</DialogFooter>
</form>
</div>
</FormProvider>
</Modal>
</FormProvider>
</DialogContent>
</Dialog>
);
};
@@ -4,17 +4,40 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnableTwoFactorModal } from "./enable-two-factor-modal";
// Mock the Modal component to expose the close functionality
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }: { children: React.ReactNode; open: boolean; setOpen: () => void }) =>
// Mock the Dialog component to expose the close functionality
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: () => void;
}) =>
open ? (
<div data-testid="modal">
<div data-testid="dialog">
{children}
<button data-testid="modal-close" onClick={setOpen}>
<button data-testid="dialog-close" onClick={onOpenChange}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the child components
@@ -94,6 +117,8 @@ describe("EnableTwoFactorModal", () => {
const setOpen = vi.fn();
render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
});
@@ -101,6 +126,7 @@ describe("EnableTwoFactorModal", () => {
const setOpen = vi.fn();
render(<EnableTwoFactorModal open={false} setOpen={setOpen} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("confirm-password-form")).not.toBeInTheDocument();
});
@@ -127,7 +153,7 @@ describe("EnableTwoFactorModal", () => {
expect(screen.getByTestId("display-backup-codes")).toBeInTheDocument();
});
test("resets state when modal is closed", async () => {
test("resets state when dialog is closed", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
const { rerender } = render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
@@ -136,13 +162,13 @@ describe("EnableTwoFactorModal", () => {
await user.click(screen.getByText("Next"));
expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument();
// Close modal using the close button
await user.click(screen.getByTestId("modal-close"));
// Close dialog using the close button
await user.click(screen.getByTestId("dialog-close"));
// Verify setOpen was called with false
expect(setOpen).toHaveBeenCalledWith(false);
// Reopen modal
// Reopen dialog
rerender(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
// Should be back at the first step
@@ -4,7 +4,15 @@ import { ConfirmPasswordForm } from "@/modules/ee/two-factor-auth/components/con
import { DisplayBackupCodes } from "@/modules/ee/two-factor-auth/components/display-backup-codes";
import { EnterCode } from "@/modules/ee/two-factor-auth/components/enter-code";
import { ScanQRCode } from "@/modules/ee/two-factor-auth/components/scan-qr-code";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -26,6 +34,8 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
router.refresh();
};
const { t } = useTranslate();
const resetState = () => {
setCurrentStep("confirmPassword");
setBackupCodes([]);
@@ -35,26 +45,38 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
};
return (
<Modal open={open} setOpen={() => resetState()} noPadding>
{currentStep === "confirmPassword" && (
<ConfirmPasswordForm
setBackupCodes={setBackupCodes}
setCurrentStep={setCurrentStep}
setDataUri={setDataUri}
setSecret={setSecret}
setOpen={setOpen}
/>
)}
<Dialog open={open} onOpenChange={() => resetState()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.profile.two_factor_authentication")}</DialogTitle>
<DialogDescription>
{t("environments.settings.profile.confirm_your_current_password_to_get_started")}
</DialogDescription>
</DialogHeader>
<DialogBody>
{currentStep === "confirmPassword" && (
<ConfirmPasswordForm
setBackupCodes={setBackupCodes}
setCurrentStep={setCurrentStep}
setDataUri={setDataUri}
setSecret={setSecret}
setOpen={setOpen}
/>
)}
{currentStep === "scanQRCode" && (
<ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} />
)}
{currentStep === "scanQRCode" && (
<ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} />
)}
{currentStep === "enterCode" && (
<EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} />
)}
{currentStep === "enterCode" && (
<EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} />
)}
{currentStep === "backupCodes" && <DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />}
</Modal>
{currentStep === "backupCodes" && (
<DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -86,7 +86,7 @@ export const EnterCode = ({ setCurrentStep, setOpen, refreshData }: EnterCodePro
/>
</div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<div className="flex w-full items-center justify-end space-x-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
@@ -47,7 +47,7 @@ export const ScanQRCode = ({ dataUri, secret, setCurrentStep, setOpen }: ScanQRC
</div>
</div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<div className="flex w-full items-center justify-end space-x-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
@@ -0,0 +1,181 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AddWebhookModal } from "./add-webhook-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock the child components
vi.mock("./survey-checkbox-group", () => ({
SurveyCheckboxGroup: ({
surveys,
selectedSurveys,
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
allowChanges,
}: any) => (
<div data-testid="survey-checkbox-group">
<button onClick={onSelectAllSurveys}>Select All Surveys</button>
{surveys.map((survey: any) => (
<button key={survey.id} onClick={() => onSelectedSurveyChange(survey.id)}>
{survey.name}
</button>
))}
</div>
),
}));
vi.mock("./trigger-checkbox-group", () => ({
TriggerCheckboxGroup: ({ selectedTriggers, onCheckboxChange, allowChanges }: any) => (
<div data-testid="trigger-checkbox-group">
<button onClick={() => onCheckboxChange("responseCreated")}>Response Created</button>
<button onClick={() => onCheckboxChange("responseUpdated")}>Response Updated</button>
</div>
),
}));
// Mock actions
vi.mock("../actions", () => ({
createWebhookAction: vi.fn(),
testEndpointAction: vi.fn(),
}));
// Mock other dependencies
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("AddWebhookModal", () => {
const mockProps = {
environmentId: "env-123",
open: true,
surveys: [
{ id: "survey-1", name: "Test Survey 1" },
{ id: "survey-2", name: "Test Survey 2" },
],
setOpen: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders dialog with correct title and description", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent(
"environments.integrations.webhooks.add_webhook"
);
expect(
screen.getByText("environments.integrations.webhooks.add_webhook_description")
).toBeInTheDocument();
});
test("does not render when closed", () => {
render(<AddWebhookModal {...mockProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders form fields", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByLabelText("common.name")).toBeInTheDocument();
expect(screen.getByLabelText("common.url")).toBeInTheDocument();
expect(screen.getByTestId("trigger-checkbox-group")).toBeInTheDocument();
expect(screen.getByTestId("survey-checkbox-group")).toBeInTheDocument();
});
test("renders footer buttons", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.integrations.webhooks.add_webhook" })
).toBeInTheDocument();
});
test("calls setOpen when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<AddWebhookModal {...mockProps} />);
await user.click(screen.getByText("common.cancel"));
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
});
test("renders webhook icon in header", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
// The Webhook icon should be rendered within the header
const header = screen.getByTestId("dialog-header");
expect(header).toBeInTheDocument();
});
});
@@ -5,9 +5,17 @@ import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { PipelineTriggers } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
@@ -159,114 +167,102 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Webhook />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.webhooks.add_webhook")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.webhooks.add_webhook_description")}
</div>
</div>
</div>
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<Webhook />
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.webhooks.add_webhook_description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(submitWebhook)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="col-span-1">
<Label htmlFor="name">{t("common.name")}</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")}
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">{t("common.url")}</Label>
<div className="mt-1 flex">
<Input
type="url"
id="URL"
value={testEndpointInput}
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
className={clsx(
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder={t("environments.integrations.webhooks.webhook_url_placeholder")}
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
disabled={testEndpointInput.trim() === ""}
onClick={() => {
handleTestEndpoint(true);
}}>
{t("environments.integrations.webhooks.test_endpoint")}
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
<TriggerCheckboxGroup
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
<div>
<Label htmlFor="Surveys">{t("common.surveys")}</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
<DialogBody className="space-y-4 pb-4">
<div className="col-span-1">
<Label htmlFor="name">{t("common.name")}</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")}
/>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpenWithStates(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={creatingWebhook}>
{t("environments.integrations.webhooks.add_webhook")}
</Button>
<div className="col-span-1">
<Label htmlFor="URL">{t("common.url")}</Label>
<div className="mt-1 flex">
<Input
type="url"
id="URL"
value={testEndpointInput}
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
className={clsx(
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder={t("environments.integrations.webhooks.webhook_url_placeholder")}
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
disabled={testEndpointInput.trim() === ""}
onClick={() => {
handleTestEndpoint(true);
}}>
{t("environments.integrations.webhooks.test_endpoint")}
</Button>
</div>
</div>
</div>
<div>
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
<TriggerCheckboxGroup
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
<div>
<Label htmlFor="Surveys">{t("common.surveys")}</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpenWithStates(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={creatingWebhook}>
{t("environments.integrations.webhooks.add_webhook")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,239 @@
import { WebhookModal } from "@/modules/integrations/webhooks/components/webhook-detail-modal";
import { Webhook } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({
children,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
disableCloseOnOutsideClick?: boolean;
}) => (
<div data-testid="dialog-content" data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the tab components
vi.mock("@/modules/integrations/webhooks/components/webhook-overview-tab", () => ({
WebhookOverviewTab: ({ webhook }: { webhook: Webhook }) => (
<div data-testid="webhook-overview-tab">Overview for {webhook.name}</div>
),
}));
vi.mock("@/modules/integrations/webhooks/components/webhook-settings-tab", () => ({
WebhookSettingsTab: ({ webhook, setOpen }: { webhook: Webhook; setOpen: (v: boolean) => void }) => (
<div data-testid="webhook-settings-tab">
Settings for {webhook.name}
<button onClick={() => setOpen(false)}>Close from settings</button>
</div>
),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.overview": "Overview",
"common.settings": "Settings",
"common.webhook": "Webhook",
};
return translations[key] || key;
},
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
WebhookIcon: () => <span data-testid="webhook-icon">🪝</span>,
}));
const mockWebhook: Webhook = {
id: "webhook-1",
name: "Test Webhook",
url: "https://example.com/webhook",
source: "user",
triggers: [],
surveyIds: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-1",
};
const mockSurveys: TSurvey[] = [];
describe("WebhookModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly when open", () => {
const setOpen = vi.fn();
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Webhook");
expect(screen.getByTestId("webhook-icon")).toBeInTheDocument();
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
});
test("does not render when closed", () => {
const setOpen = vi.fn();
render(
<WebhookModal
open={false}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Initially shows overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now shows settings tab
expect(screen.queryByTestId("webhook-overview-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("webhook-settings-tab")).toBeInTheDocument();
// Click overview tab again
const overviewTab = screen.getByText("Overview");
await user.click(overviewTab);
// Back to overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
});
test("uses webhook as title when name is not provided", () => {
const setOpen = vi.fn();
const webhookWithoutName = { ...mockWebhook, name: "" };
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={webhookWithoutName}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Webhook");
});
test("resets to first tab when modal is reopened", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
const { rerender } = render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("webhook-settings-tab")).toBeInTheDocument();
// Close modal
rerender(
<WebhookModal
open={false}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Reopen modal
rerender(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Should be back to overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
});
});
@@ -2,10 +2,18 @@
import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab";
import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Webhook } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { WebhookIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface WebhookModalProps {
@@ -18,6 +26,8 @@ interface WebhookModalProps {
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{
title: t("common.overview"),
@@ -31,16 +41,45 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
},
];
const handleTabClick = (index: number) => {
setActiveTab(index);
};
useEffect(() => {
if (!open) {
setActiveTab(0);
}
}, [open]);
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<WebhookIcon />}
label={webhook.name ? webhook.name : webhook.url}
description={""}
/>
</>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<WebhookIcon />
<DialogTitle>{webhook.name || t("common.webhook")}</DialogTitle>{" "} {/* NOSONAR // We want to check for empty strings */}
<DialogDescription>{webhook.url}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex h-full w-full flex-col">
<div className="flex w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
key={tab.title}
type="button"
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto pt-4">{tabs[activeTab].children}</div>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -217,14 +217,10 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
/>
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<div className="flex justify-between space-x-2">
<div className="flex space-x-2">
{!isReadOnly && (
<Button
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3">
<Button type="button" variant="destructive" onClick={() => setOpenDeleteDialog(true)}>
<TrashIcon />
{t("common.delete")}
</Button>
@@ -6,8 +6,27 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateOrganizationModal } from "./index";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }) => (open ? <div data-testid="modal">{children}</div> : null),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
vi.mock("lucide-react", () => ({
@@ -34,9 +53,14 @@ describe("CreateOrganizationModal", () => {
cleanup();
});
test("renders modal and form fields", () => {
test("renders dialog and form fields", () => {
render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder")
).toBeInTheDocument();
@@ -61,7 +85,7 @@ describe("CreateOrganizationModal", () => {
expect(submitBtn).not.toBeDisabled();
});
test("calls createOrganizationAction and closes modal on success", async () => {
test("calls createOrganizationAction and closes dialog on success", async () => {
const setOpen = vi.fn();
vi.mocked(createOrganizationAction).mockResolvedValue({ data: { id: "org-1" } } as any);
render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
@@ -3,9 +3,17 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createOrganizationAction } from "@/modules/organization/actions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { PlusCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -50,57 +58,44 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-10 w-10 text-slate-500">
<PlusCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.settings.general.create_new_organization")}
</div>
<div className="text-sm text-slate-500">
{t("environments.settings.general.create_new_organization_description")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick={true}>
<DialogHeader>
<PlusCircleIcon />
<DialogTitle>{t("environments.settings.general.create_new_organization")}</DialogTitle>
<DialogDescription>
{t("environments.settings.general.create_new_organization_description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(submitOrganization)} className="space-y-4">
<DialogBody>
<div className="grid w-full space-y-2">
<Label>{t("environments.settings.general.organization_name")}</Label>
<Input
autoFocus
placeholder={t("environments.settings.general.organization_name_placeholder")}
{...register("name", { required: true })}
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitOrganization)}>
<div className="flex w-full justify-between space-y-4 rounded-lg p-6">
<div className="grid w-full gap-x-2">
<div>
<Label>{t("environments.settings.general.organization_name")}</Label>
<Input
autoFocus
placeholder={t("environments.settings.general.organization_name_placeholder")}
{...register("name", { required: true })}
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={loading} disabled={!isOrganizationNameValid}>
{t("environments.settings.general.create_new_organization")}
</Button>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={loading} disabled={!isOrganizationNameValid}>
{t("environments.settings.general.create_new_organization")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,44 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { AddApiKeyModal } from "./add-api-key-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock the translate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
@@ -102,11 +140,9 @@ describe("AddApiKeyModal", () => {
test("renders the modal with initial state", () => {
render(<AddApiKeyModal {...defaultProps} />);
const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", {
selector: "div.text-xl",
});
expect(modalTitle).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("environments.project.api_keys.add_api_key");
expect(screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack")).toBeInTheDocument();
expect(screen.getByText("environments.project.api_keys.project_access")).toBeInTheDocument();
});
@@ -4,6 +4,14 @@ import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/sett
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,7 +20,6 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
@@ -210,212 +217,199 @@ export const AddApiKeyModal = ({
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api_keys.add_api_key")}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
<DialogBody className="space-y-4 overflow-y-auto py-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitAPIKey)}>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
const permission = selectedPermissions[key];
return (
<div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">{permission.projectName}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem]">
{projectOptions.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.environmentType}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button
type="button"
className="p-2"
onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
const permission = selectedPermissions[key];
return (
<div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">{permission.projectName}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem]">
{projectOptions.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.environmentType}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
</div>
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div>
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div>
</div>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
</Alert>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
reset();
setSelectedPermissions({});
}}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitDisabled() || isCreatingAPIKey}
loading={isCreatingAPIKey}>
{t("environments.project.api_keys.add_api_key")}
</Button>
</div>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
</Alert>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
reset();
setSelectedPermissions({});
}}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitDisabled() || isCreatingAPIKey}
loading={isCreatingAPIKey}>
{t("environments.project.api_keys.add_api_key")}
</Button>
</DialogFooter>
</form>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};
@@ -31,6 +31,31 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{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>,
}));
// Base project setup
const baseProject = {};
@@ -156,11 +181,10 @@ describe("EditAPIKeys", () => {
const addButton = screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" });
await userEvent.click(addButton);
// Look for the modal title specifically
const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", {
selector: "div.text-xl",
});
// Look for the modal title using the correct test id
const modalTitle = screen.getByTestId("dialog-title");
expect(modalTitle).toBeInTheDocument();
expect(modalTitle).toHaveTextContent("environments.project.api_keys.add_api_key");
});
test("handles API key deletion", async () => {
@@ -244,6 +244,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
deleteWhat={t("environments.project.api_keys.api_key")}
onDelete={handleDeleteKey}
isDeleting={isLoading}
text={t("environments.project.api_keys.delete_api_key_confirmation")}
/>
</div>
);
@@ -8,10 +8,17 @@ import {
ZApiKeyUpdateInput,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownMenu, DropdownMenuTrigger } from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
@@ -78,147 +85,143 @@ export const ViewPermissionModal = ({
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">{apiKey.label}</div>
</div>
</div>
</div>
<div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{apiKey.label}</DialogTitle>
</DialogHeader>
<DialogBody>
<form onSubmit={handleSubmit(updateApiKey)}>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<div className="w-full space-y-6">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
data-testid="api-key-label"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.permissions")}</Label>
{apiKey.apiKeyEnvironments?.length === 0 && (
<div className="text-center text-sm">
{t("environments.project.api_keys.no_env_permissions_found")}
</div>
)}
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
data-testid="api-key-label"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
{/* Permission rows */}
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.permissions")}</Label>
{apiKey.apiKeyEnvironments?.length === 0 && (
<div className="text-center text-sm">
{t("environments.project.api_keys.no_env_permissions_found")}
</div>
)}
<div className="space-y-2">
{/* Permission rows */}
{apiKey.apiKeyEnvironments?.map((permission) => {
return (
<div key={permission.environmentId} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getProjectName(permission.environmentId)}
</span>
{apiKey.apiKeyEnvironments?.map((permission) => {
return (
<div key={permission.environmentId} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getProjectName(permission.environmentId)}
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{getEnvironmentName(permission.environmentId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
);
})}
</div>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{getEnvironmentName(permission.environmentId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
</div>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
reset();
}}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitDisabled() || isUpdating} loading={isUpdating}>
{t("common.update")}
</Button>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</Modal>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
reset();
}}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitDisabled() || isUpdating}
loading={isUpdating}
onClick={handleSubmit(updateApiKey)}>
{t("common.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -157,6 +157,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
deleteWhat={`${memberName} ${t("environments.settings.general.from_your_organization")}`}
onDelete={handleDeleteMember}
isDeleting={isDeleting}
text={t("environments.settings.general.delete_member_confirmation")}
/>
{showShareInviteModal && (
@@ -41,22 +41,21 @@ vi.mock("@/modules/organization/settings/teams/components/invite-member/invite-m
}),
}));
// Mock the CustomDialog
vi.mock("@/modules/ui/components/custom-dialog", () => ({
CustomDialog: vi.fn(({ children, open, setOpen, onOk }) => {
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) => {
if (!open) return null;
return (
<div data-testid="leave-org-modal">
<div data-testid="leave-org-modal" onClick={() => onOpenChange && onOpenChange(false)}>
{children}
<button data-testid="leave-org-confirm-btn" onClick={onOk}>
Confirm
</button>
<button data-testid="leave-org-cancel-btn" onClick={() => setOpen(false)}>
Cancel
</button>
</div>
);
}),
DialogContent: vi.fn(({ children }) => <div data-testid="dialog-content">{children}</div>),
DialogHeader: vi.fn(({ children }) => <div data-testid="dialog-header">{children}</div>),
DialogTitle: vi.fn(({ children }) => <div data-testid="dialog-title">{children}</div>),
DialogDescription: vi.fn(({ children }) => <div data-testid="dialog-description">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
}));
// Mock react-hot-toast
@@ -92,6 +91,25 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, loading, disabled, ...props }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-testid={
props.variant === "destructive"
? "leave-org-confirm-btn"
: props.variant === "secondary" && children === "common.cancel"
? "leave-org-cancel-btn"
: undefined
}
{...props}>
{children}
</button>
)),
}));
describe("OrganizationActions Component", () => {
const mockRouter = {
push: vi.fn(),
@@ -8,7 +8,14 @@ import { inviteUserAction, leaveOrganizationAction } from "@/modules/organizatio
import { InviteMemberModal } from "@/modules/organization/settings/teams/components/invite-member/invite-member-modal";
import { TInvitee } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
import { CustomDialog } from "@/modules/ui/components/custom-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -153,21 +160,33 @@ export const OrganizationActions = ({
teams={teams}
/>
<CustomDialog
open={isLeaveOrganizationModalOpen}
setOpen={setLeaveOrganizationModalOpen}
title={t("environments.settings.general.leave_organization_title")}
text={t("environments.settings.general.leave_organization_description")}
onOk={handleLeaveOrganization}
okBtnText={t("environments.settings.general.leave_organization_ok_btn_text")}
disabled={isLeaveOrganizationDisabled}
isLoading={loading}>
{isLeaveOrganizationDisabled && (
<p className="mt-2 text-sm text-red-700">
{t("environments.settings.general.cannot_leave_only_organization")}
</p>
)}
</CustomDialog>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.general.leave_organization_title")}</DialogTitle>
<DialogDescription>
{t("environments.settings.general.leave_organization_description")}
</DialogDescription>
</DialogHeader>
{isLeaveOrganizationDisabled && (
<p className="mt-2 text-sm text-red-700">
{t("environments.settings.general.cannot_leave_only_organization")}
</p>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleLeaveOrganization}
loading={loading}
disabled={isLeaveOrganizationDisabled}>
{t("environments.settings.general.leave_organization_ok_btn_text")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -132,25 +132,25 @@ export const BulkInviteTab = ({
</div>
<div className="flex justify-between">
<Button
size="default"
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Link
download
href="/sample-csv/formbricks-organization-members-template.csv"
target="_blank"
rel="noopener noreferrer">
<Button variant="secondary" size="default">
{t("common.download")} CSV template
</Button>
</Link>
<div className="flex space-x-2">
<Link
download
href="/sample-csv/formbricks-organization-members-template.csv"
target="_blank"
rel="noopener noreferrer">
<Button variant="secondary" size="default">
{t("common.download")} CSV template
</Button>
</Link>
<Button
size="default"
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button onClick={onImport} size="default" disabled={!csvFile}>
{t("common.import")}
</Button>
@@ -133,7 +133,7 @@ export const IndividualInviteTab = ({
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="italic">
<Small className="font-normal text-amber-600">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
@@ -161,11 +161,11 @@ export const IndividualInviteTab = ({
</Alert>
)}
<div className="flex justify-between">
<div className="flex items-end justify-end gap-x-2">
<Button
size="default"
type="button"
variant="outline"
variant="secondary"
onClick={() => {
setOpen(false);
}}>
@@ -13,9 +13,31 @@ vi.mock("./bulk-invite-tab", () => ({
vi.mock("./individual-invite-tab", () => ({
IndividualInviteTab: () => <div data-testid="individual-invite-tab">IndividualInviteTab</div>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: any) => (open ? <div data-testid="modal">{children}</div> : null),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/tab-toggle", () => ({
TabToggle: ({ options, onChange, defaultSelected }: any) => (
<select data-testid="tab-toggle" value={defaultSelected} onChange={(e) => onChange(e.target.value)}>
@@ -45,9 +67,14 @@ describe("InviteMemberModal", () => {
vi.clearAllMocks();
});
test("renders modal and individual tab by default", () => {
test("renders dialog and individual tab by default", () => {
render(<InviteMemberModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("individual-invite-tab")).toBeInTheDocument();
expect(screen.getByTestId("tab-toggle")).toBeInTheDocument();
});
@@ -1,12 +1,16 @@
"use client";
import { cn } from "@/lib/cn";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
import { H4, Muted } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { XIcon } from "lucide-react";
import { useState } from "react";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { BulkInviteTab } from "./bulk-invite-tab";
@@ -60,50 +64,26 @@ export const InviteMemberModal = ({
};
return (
<Modal
open={open}
setOpen={setOpen}
noPadding
closeOnOutsideClick={false}
className="overflow-visible"
size="md"
hideCloseButton>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
setOpen(false);
}}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>{t("environments.settings.teams.invite_member")}</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.invite_member_description")}
</Muted>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-6 p-6">
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
{tabs[type]}
</div>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick unconstrained>
<DialogHeader>
<DialogTitle>{t("environments.settings.teams.invite_member")}</DialogTitle>
<DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription>
</DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained>
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
{tabs[type]}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -8,8 +8,31 @@ import { ShareInviteModal } from "./share-invite-modal";
const t = (k: string) => k;
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t }) }));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: any) => (open ? <div data-testid="modal">{children}</div> : null),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-title" className={className}>
{children}
</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
const defaultProps = {
@@ -24,9 +47,15 @@ describe("ShareInviteModal", () => {
vi.clearAllMocks();
});
test("renders modal and invite link", () => {
test("renders dialog and invite link", () => {
render(<ShareInviteModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.general.organization_invite_link_ready")
).toBeInTheDocument();
@@ -38,8 +67,8 @@ describe("ShareInviteModal", () => {
expect(screen.getByText("common.copy_link")).toBeInTheDocument();
});
test("calls setOpen when modal is closed", () => {
test("calls setOpen when dialog is closed", () => {
render(<ShareInviteModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});
@@ -1,10 +1,17 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useRef } from "react";
import toast from "react-hot-toast";
interface ShareInviteModalProps {
@@ -14,46 +21,27 @@ interface ShareInviteModalProps {
}
export const ShareInviteModal = ({ inviteToken, open, setOpen }: ShareInviteModalProps) => {
const linkTextRef = useRef(null);
const { t } = useTranslate();
const handleTextSelection = () => {
if (linkTextRef.current) {
const range = document.createRange();
range.selectNodeContents(linkTextRef.current);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
return (
<Modal open={open} setOpen={setOpen} blur={false}>
<div>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-semibold leading-6 text-slate-900">
{t("environments.settings.general.organization_invite_link_ready")}
</h3>
<div className="mt-4">
<p className="text-sm text-slate-500">
{t(
"environments.settings.general.share_this_link_to_let_your_organization_member_join_your_organization"
)}
</p>
<p
ref={linkTextRef}
className="relative mt-3 w-full truncate rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
onClick={() => handleTextSelection()}
id="inviteLinkText">
{`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`}
</p>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<CheckIcon />
<DialogTitle>{t("environments.settings.general.organization_invite_link_ready")}</DialogTitle>
<DialogDescription>
{t(
"environments.settings.general.share_this_link_to_let_your_organization_member_join_your_organization"
)}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex items-center gap-2">
<Input
readOnly
id="inviteLinkText"
value={`${window.location.protocol}//${window.location.host}/invite?token=${inviteToken}`}></Input>
<Button
variant="secondary"
onClick={() => {
@@ -68,8 +56,8 @@ export const ShareInviteModal = ({ inviteToken, open, setOpen }: ShareInviteModa
<CopyIcon />
</Button>
</div>
</div>
</div>
</Modal>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -6,6 +6,30 @@ import { EditPublicSurveyAlertDialog } from "./index";
// Mock translation to return keys as text
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
// Mock Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
describe("EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
@@ -16,6 +40,14 @@ describe("EditPublicSurveyAlertDialog", () => {
const setOpen = vi.fn();
render(<EditPublicSurveyAlertDialog open={true} setOpen={setOpen} />);
// Check dialog structure
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
// Title
expect(screen.getByText("environments.surveys.edit.caution_edit_published_survey")).toBeInTheDocument();
@@ -110,7 +142,7 @@ describe("EditPublicSurveyAlertDialog", () => {
/>
);
// Modal has a close button by default plus our two action buttons
// Dialog has our two action buttons
const allButtons = screen.getAllByRole("button");
// Verify our two action buttons by their text content
const actionButtons = allButtons.filter(
@@ -1,5 +1,13 @@
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
interface EditPublicSurveyAlertDialogProps {
@@ -34,7 +42,7 @@ export const EditPublicSurveyAlertDialog = ({
label: secondaryButtonText,
onClick: secondaryButtonAction,
disabled: isLoading,
variant: "outline",
variant: "secondary",
});
}
if (primaryButtonAction) {
@@ -53,22 +61,31 @@ export const EditPublicSurveyAlertDialog = ({
});
}
return (
<Modal open={open} setOpen={setOpen} title={t("environments.surveys.edit.caution_edit_published_survey")}>
<p>{t("environments.surveys.edit.caution_recommendation")}</p>
<p className="mt-3">{t("environments.surveys.edit.caution_explanation_intro")}</p>
<ul className="mt-3 list-disc space-y-0.5 pl-5">
<li>{t("environments.surveys.edit.caution_explanation_responses_are_safe")}</li>
<li>{t("environments.surveys.edit.caution_explanation_new_responses_separated")}</li>
<li>{t("environments.surveys.edit.caution_explanation_only_new_responses_in_summary")}</li>
<li>{t("environments.surveys.edit.caution_explanation_all_data_as_download")}</li>
</ul>
<div className="my-4 space-x-2 text-right">
{actions.map(({ label, onClick, loading, variant, disabled }) => (
<Button key={label} variant={variant} onClick={onClick} loading={loading} disabled={disabled}>
{label}
</Button>
))}
</div>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[540px]">
<DialogHeader>
<DialogTitle>{t("environments.surveys.edit.caution_edit_published_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.edit.caution_recommendation")}</DialogDescription>
</DialogHeader>
<DialogBody>
<p>{t("environments.surveys.edit.caution_explanation_intro")}</p>
<ul className="mt-3 list-disc space-y-0.5 pl-5">
<li>{t("environments.surveys.edit.caution_explanation_responses_are_safe")}</li>
<li>{t("environments.surveys.edit.caution_explanation_new_responses_separated")}</li>
<li>{t("environments.surveys.edit.caution_explanation_only_new_responses_in_summary")}</li>
<li>{t("environments.surveys.edit.caution_explanation_all_data_as_download")}</li>
</ul>
</DialogBody>
<DialogFooter>
{actions.map(({ label, onClick, loading, variant, disabled }) => (
<Button key={label} variant={variant} onClick={onClick} loading={loading} disabled={disabled}>
{label}
</Button>
))}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,40 +1,62 @@
import { AddActionModal } from "@/modules/survey/editor/components/add-action-modal";
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { SavedActionsTab } from "@/modules/survey/editor/components/saved-actions-tab";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { ActionClass } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock child components
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
CreateNewActionTab: vi.fn(() => <div>CreateNewActionTab Mock</div>),
CreateNewActionTab: vi.fn(() => <div data-testid="create-new-action-tab">CreateNewActionTab Mock</div>),
}));
vi.mock("@/modules/survey/editor/components/saved-actions-tab", () => ({
SavedActionsTab: vi.fn(() => <div>SavedActionsTab Mock</div>),
SavedActionsTab: vi.fn(() => <div data-testid="saved-actions-tab">SavedActionsTab Mock</div>),
}));
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(
({ label, description, open, setOpen, tabs, size, closeOnOutsideClick, restrictOverflow }) => (
<div data-testid="modal-with-tabs">
<h1>{label}</h1>
<p>{description}</p>
<div>Open: {open.toString()}</div>
<button onClick={() => setOpen(false)}>Close</button>
<div>Size: {size}</div>
<div>Close on outside click: {closeOnOutsideClick.toString()}</div>
<div>Restrict overflow: {restrictOverflow.toString()}</div>
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
<div>{tab.children}</div>
</div>
))}
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
)
) : null,
DialogContent: ({
children,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
disableCloseOnOutsideClick?: boolean;
}) => (
<div data-testid="dialog-content" data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
@@ -103,7 +125,6 @@ const defaultProps = {
setLocalSurvey: mockSetLocalSurvey,
};
const ModalWithTabsMock = vi.mocked(ModalWithTabs);
const SavedActionsTabMock = vi.mocked(SavedActionsTab);
const CreateNewActionTabMock = vi.mocked(CreateNewActionTab);
@@ -115,36 +136,64 @@ describe("AddActionModal", () => {
test("renders correctly when open", () => {
render(<AddActionModal {...defaultProps} />);
expect(screen.getByTestId("modal-with-tabs")).toBeInTheDocument();
// Check for translated text
expect(screen.getByText("Add Action")).toBeInTheDocument();
expect(screen.getByText("Capture a new action...")).toBeInTheDocument();
expect(screen.getByText("Select Saved Action")).toBeInTheDocument(); // Check translated tab title
expect(screen.getByText("Capture New Action")).toBeInTheDocument(); // Check translated tab title
expect(screen.getByText("SavedActionsTab Mock")).toBeInTheDocument();
expect(screen.getByText("CreateNewActionTab Mock")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Add Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Capture a new action...");
expect(screen.getByText("Select Saved Action")).toBeInTheDocument();
expect(screen.getByText("Capture New Action")).toBeInTheDocument();
// Only the first tab (SavedActionsTab) should be active initially
expect(screen.getByTestId("saved-actions-tab")).toBeInTheDocument();
expect(screen.queryByTestId("create-new-action-tab")).not.toBeInTheDocument();
});
test("passes correct props to ModalWithTabs", () => {
test("does not render when open is false", () => {
render(<AddActionModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<AddActionModal {...defaultProps} />);
expect(ModalWithTabsMock).toHaveBeenCalledWith(
expect.objectContaining({
// Check for translated props
label: "Add Action",
description: "Capture a new action...",
open: true,
setOpen: mockSetOpen,
tabs: expect.any(Array),
size: "md",
closeOnOutsideClick: false,
restrictOverflow: true,
}),
undefined
);
expect(ModalWithTabsMock.mock.calls[0][0].tabs).toHaveLength(2);
// Check for translated tab titles in the tabs array
expect(ModalWithTabsMock.mock.calls[0][0].tabs[0].title).toBe("Select Saved Action");
expect(ModalWithTabsMock.mock.calls[0][0].tabs[1].title).toBe("Capture New Action");
// Initially shows saved actions tab (first tab is active)
expect(screen.getByTestId("saved-actions-tab")).toBeInTheDocument();
expect(screen.queryByTestId("create-new-action-tab")).not.toBeInTheDocument();
// Click capture new action tab
const captureNewActionTab = screen.getByText("Capture New Action");
await user.click(captureNewActionTab);
// Now shows create new action tab content
expect(screen.queryByTestId("saved-actions-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
// Click select saved action tab again
const selectSavedActionTab = screen.getByText("Select Saved Action");
await user.click(selectSavedActionTab);
// Back to saved actions tab content
expect(screen.getByTestId("saved-actions-tab")).toBeInTheDocument();
expect(screen.queryByTestId("create-new-action-tab")).not.toBeInTheDocument();
});
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<AddActionModal {...defaultProps} />);
// Switch to create new action tab
const captureNewActionTab = screen.getByText("Capture New Action");
await user.click(captureNewActionTab);
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
// Close modal
rerender(<AddActionModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<AddActionModal {...defaultProps} open={true} />);
// Should be back to saved actions tab (first tab)
expect(screen.getByTestId("saved-actions-tab")).toBeInTheDocument();
expect(screen.queryByTestId("create-new-action-tab")).not.toBeInTheDocument();
});
test("passes correct props to SavedActionsTab", () => {
@@ -160,8 +209,18 @@ describe("AddActionModal", () => {
);
});
test("passes correct props to CreateNewActionTab", () => {
test("passes correct props to CreateNewActionTab", async () => {
const user = userEvent.setup();
render(<AddActionModal {...defaultProps} />);
// CreateNewActionTab should not be called initially since first tab is active
expect(CreateNewActionTabMock).not.toHaveBeenCalled();
// Click the second tab to activate CreateNewActionTab
const captureNewActionTab = screen.getByText("Capture New Action");
await user.click(captureNewActionTab);
// Now CreateNewActionTab should be called with correct props
expect(CreateNewActionTabMock).toHaveBeenCalledWith(
{
actionClasses: mockActionClasses,
@@ -174,22 +233,4 @@ describe("AddActionModal", () => {
undefined
);
});
test("does not render when open is false", () => {
render(<AddActionModal {...defaultProps} open={false} />);
// Check the full props object passed to the mock, ensuring 'open' is false
expect(ModalWithTabsMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Add Action", // Expect translated label even when closed
description: "Capture a new action...", // Expect translated description
open: false, // Check that open is false
setOpen: mockSetOpen,
tabs: expect.any(Array),
size: "md",
closeOnOutsideClick: false,
restrictOverflow: true,
}),
undefined
);
});
});
@@ -2,9 +2,17 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { SavedActionsTab } from "@/modules/survey/editor/components/saved-actions-tab";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { ActionClass } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddActionModalProps {
@@ -29,6 +37,8 @@ export const AddActionModal = ({
environmentId,
}: AddActionModalProps) => {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{
title: t("environments.surveys.edit.select_saved_action"),
@@ -55,16 +65,45 @@ export const AddActionModal = ({
),
},
];
const handleTabClick = (index: number) => {
setActiveTab(index);
};
useEffect(() => {
if (!open) {
setActiveTab(0);
}
}, [open]);
return (
<ModalWithTabs
label={t("common.add_action")}
description={t("environments.surveys.edit.capture_a_new_action_to_trigger_a_survey_on")}
open={open}
setOpen={setOpen}
tabs={tabs}
size="md"
closeOnOutsideClick={false}
restrictOverflow
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<DialogTitle>{t("common.add_action")}</DialogTitle>
<DialogDescription>
{t("environments.surveys.edit.capture_a_new_action_to_trigger_a_survey_on")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex h-full w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
type="button"
key={tab.title}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 pt-4">{tabs[activeTab].children}</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -178,7 +178,7 @@ export const CreateNewActionTab = ({
<div>
<FormProvider {...form}>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto pr-4">
<div className="w-full space-y-4">
<div className="w-3/5">
<FormField
name={`type`}
@@ -257,15 +257,13 @@ export const CreateNewActionTab = ({
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
)}
</div>
<div className="flex justify-end pt-6">
<div className="flex space-x-2">
<Button type="button" variant="ghost" onClick={resetAllStates}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("environments.actions.create_action")}
</Button>
</div>
<div className="sticky bottom-0 flex justify-end space-x-2 bg-white pt-4">
<Button type="button" variant="secondary" onClick={resetAllStates}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("environments.actions.create_action")}
</Button>
</div>
</form>
</FormProvider>
@@ -20,6 +20,7 @@ vi.mock("react-hook-form", () => ({
getValues: vi.fn(),
setValue: vi.fn(),
register: vi.fn(),
clearErrors: vi.fn(),
}),
}));
@@ -27,6 +28,7 @@ vi.mock("react-hook-form", () => ({
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
@@ -93,9 +95,35 @@ vi.mock("./follow-up-action-multi-email-input", () => ({
),
}));
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open }: any) => (open ? <div data-testid="modal">{children}</div> : null),
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
// Mock the Form components
@@ -165,6 +193,7 @@ describe("FollowUpModal", () => {
test("renders modal with create heading when mode is create", () => {
render(<FollowUpModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_create_heading")).toBeInTheDocument();
});
@@ -186,12 +215,14 @@ describe("FollowUpModal", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_edit_heading")).toBeInTheDocument();
});
test("renders form fields in create mode", () => {
render(<FollowUpModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_trigger_label")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_action_label")).toBeInTheDocument();
expect(
@@ -13,6 +13,15 @@ import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Editor } from "@/modules/ui/components/editor";
import {
FormControl,
@@ -24,7 +33,6 @@ import {
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -358,9 +366,11 @@ export const FollowUpModal = ({
}
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale, t]);
const handleModalClose = () => {
form.reset();
setOpen(false);
const handleModalClose = (open: boolean) => {
if (!open) {
form.reset();
}
setOpen(open);
};
const emailSendToQuestionOptions = emailSendToOptions.filter(
@@ -395,473 +405,456 @@ export const FollowUpModal = ({
};
return (
<Modal open={open} setOpen={handleModalClose} noPadding size="md">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MailIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{mode === "edit"
? t("environments.surveys.edit.follow_ups_modal_edit_heading")
: t("environments.surveys.edit.follow_ups_modal_create_heading")}
</div>
<div className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_subheading")}
</div>
</div>
</div>
</div>
</div>
</div>
<Dialog open={open} onOpenChange={handleModalClose}>
<DialogContent>
<DialogHeader>
<MailIcon />
<DialogTitle>
{mode === "edit"
? t("environments.surveys.edit.follow_ups_modal_edit_heading")
: t("environments.surveys.edit.follow_ups_modal_create_heading")}
</DialogTitle>
<DialogDescription>{t("environments.surveys.edit.follow_ups_modal_subheading")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="mb-12 h-full max-h-[600px] overflow-auto px-6 py-4" ref={containerRef}>
<div className="flex flex-col space-y-4">
{/* Follow up name */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="followUpName"
render={({ field }) => {
return (
<FormItem>
<FormLabel htmlFor="follow-up-name">
{t("environments.surveys.edit.follow_ups_modal_name_label")}:
</FormLabel>
<FormControl>
<Input
{...field}
type="text"
className="max-w-80"
isInvalid={!!formErrors.followUpName}
placeholder={t("environments.surveys.edit.follow_ups_modal_name_placeholder")}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
{/* Trigger */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<ZapIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_trigger_label")}
</h2>
</div>
<div className="flex flex-col gap-4 p-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="contents">
<DialogBody className="my-4">
<div ref={containerRef} className="flex flex-col space-y-4">
{/* Follow up name */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="triggerType"
name="followUpName"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="triggerType">
{t("environments.surveys.edit.follow_ups_modal_trigger_description")}
</FormLabel>
<div className="max-w-80">
<Select
defaultValue={field.value}
onValueChange={(value) => field.onChange(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="response">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_response")}
</SelectItem>
{localSurvey.endings.length > 0 ? (
<SelectItem value="endings">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{triggerType === "endings" && !localSurvey.endings.length ? (
<Alert variant="warning" size="small">
<AlertTitle>
{t(
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
)}
</AlertTitle>
</Alert>
) : null}
</div>
</div>
<FormLabel htmlFor="follow-up-name">
{t("environments.surveys.edit.follow_ups_modal_name_label")}:
</FormLabel>
<FormControl>
<Input
{...field}
type="text"
className="max-w-80"
isInvalid={!!formErrors.followUpName}
placeholder={t("environments.surveys.edit.follow_ups_modal_name_placeholder")}
/>
</FormControl>
</FormItem>
);
}}
/>
{localSurvey.endings.length > 0 && triggerType === "endings" ? (
<FormField
control={form.control}
name="endingIds"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-700">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending_select")}
</h3>
<div className="flex flex-col space-y-2">
{localSurvey.endings.map((ending) => {
const getEndingLabel = (): string => {
if (ending.type === "endScreen") {
return (
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
);
}
return ending.label || ending.url || "Ending";
};
return (
<Label
key={ending.id}
className="w-80 cursor-pointer rounded-md border border-slate-300 bg-slate-50 px-3 py-2 hover:bg-slate-100"
htmlFor={`ending-${ending.id}`}>
<div className="flex items-center space-x-2">
<Checkbox
className="inline"
checked={field.value?.includes(ending.id)}
id={`ending-${ending.id}`}
onCheckedChange={(checked) => {
if (checked) {
form.setValue("endingIds", [...(field.value ?? []), ending.id]);
} else {
form.setValue(
"endingIds",
(field.value ?? []).filter((id) => id !== ending.id)
);
}
}}
/>
<HandshakeIcon className="h-4 min-h-4 w-4 min-w-4" />
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-slate-900">
{getEndingLabel()}
</span>
</div>
</Label>
);
})}
{formErrors.endingIds ? (
<div className="mt-2">
<span className="text-red-500">{formErrors.endingIds.message}</span>
</div>
) : null}
</div>
</div>
);
}}
/>
) : null}
</div>
</div>
{/* Arrow */}
<div className="flex items-center justify-center">
<ArrowDownIcon className="h-4 w-4 text-slate-500" />
</div>
{/* Action */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<MailIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_label")}
</h2>
</div>
{/* email setup */}
<div className="flex flex-col gap-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_settings")}
</h2>
{/* To */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="emailTo"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="emailTo" className="font-medium">
{t("environments.surveys.edit.follow_ups_modal_action_to_label")}
</FormLabel>
<FormDescription
className={cn(
"text-sm",
formErrors.emailTo ? "text-red-500" : "text-slate-500"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_to_description")}
</FormDescription>
{emailSendToOptions.length === 0 && (
<div className="mt-4 flex items-start text-yellow-600">
<TriangleAlertIcon
className="mr-2 h-5 min-h-5 w-5 min-w-5"
aria-hidden="true"
/>
<p className="text-sm">
{t("environments.surveys.edit.follow_ups_modal_action_to_warning")}
</p>
</div>
)}
{emailSendToOptions.length > 0 ? (
<div className="max-w-80">
<FormControl>
<Select
defaultValue={field.value}
onValueChange={(value) => {
const selectedOption = emailSendToOptions.find(
(option) => option.id === value
);
if (!selectedOption) return;
field.onChange(selectedOption.id);
}}>
<SelectTrigger className="overflow-hidden text-ellipsis whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailSendToQuestionOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex items-center space-x-2 p-2">
<p className="text-sm text-slate-500">Questions</p>
</div>
{emailSendToQuestionOptions.map((option) =>
renderSelectItem(option)
)}
</div>
) : null}
{emailSendToHiddenFieldOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex space-x-2 p-2">
<p className="text-sm text-slate-500">Hidden Fields</p>
</div>
{emailSendToHiddenFieldOptions.map((option) =>
renderSelectItem(option)
)}
</div>
) : null}
{userSendToEmailOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex space-x-2 p-2">
<p className="text-sm text-slate-500">Users</p>
</div>
{userSendToEmailOptions.map((option) => renderSelectItem(option))}
</div>
) : null}
</SelectContent>
</Select>
</FormControl>
</div>
) : null}
</div>
);
}}
/>
</div>
{/* From */}
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_from_label")}
</h3>
<p className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_from_description")}
</p>
<div className="w-fit rounded-md border border-slate-200 bg-slate-100 px-2 py-1">
<span className="text-sm text-slate-900">{mailFrom}</span>
{/* Trigger */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<ZapIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_trigger_label")}
</h2>
</div>
{/* Reply To */}
<div className="flex flex-col space-y-2">
<div className="flex flex-col gap-4 p-4">
<FormField
control={form.control}
name="replyTo"
name="triggerType"
render={({ field }) => {
return (
<FormItem>
<FormLabel htmlFor="replyTo">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_label")}
</FormLabel>
<FormDescription className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_description")}
</FormDescription>
<FormControl>
<FollowUpActionMultiEmailInput
emails={field.value}
setEmails={field.onChange}
isInvalid={!!formErrors.replyTo}
/>
</FormControl>
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="triggerType">
{t("environments.surveys.edit.follow_ups_modal_trigger_description")}
</FormLabel>
<div className="max-w-80">
<Select
defaultValue={field.value}
onValueChange={(value) => field.onChange(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="response">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_response")}
</SelectItem>
{localSurvey.endings.length > 0 ? (
<SelectItem value="endings">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{triggerType === "endings" && !localSurvey.endings.length ? (
<Alert variant="warning" size="small">
<AlertTitle>
{t(
"environments.surveys.edit.follow_ups_modal_trigger_type_ending_warning"
)}
</AlertTitle>
</Alert>
) : null}
</div>
</div>
</FormItem>
);
}}
/>
{localSurvey.endings.length > 0 && triggerType === "endings" ? (
<FormField
control={form.control}
name="endingIds"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-700">
{t("environments.surveys.edit.follow_ups_modal_trigger_type_ending_select")}
</h3>
<div className="flex flex-col space-y-2">
{localSurvey.endings.map((ending) => {
const getEndingLabel = (): string => {
if (ending.type === "endScreen") {
return (
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
);
}
return ending.label || ending.url || "Ending";
};
return (
<Label
key={ending.id}
className="w-80 cursor-pointer rounded-md border border-slate-300 bg-slate-50 px-3 py-2 hover:bg-slate-100"
htmlFor={`ending-${ending.id}`}>
<div className="flex items-center space-x-2">
<Checkbox
className="inline"
checked={field.value?.includes(ending.id)}
id={`ending-${ending.id}`}
onCheckedChange={(checked) => {
if (checked) {
form.setValue("endingIds", [...(field.value ?? []), ending.id]);
} else {
form.setValue(
"endingIds",
(field.value ?? []).filter((id) => id !== ending.id)
);
}
}}
/>
<HandshakeIcon className="h-4 min-h-4 w-4 min-w-4" />
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-slate-900">
{getEndingLabel()}
</span>
</div>
</Label>
);
})}
{formErrors.endingIds ? (
<div className="mt-2">
<span className="text-red-500">{formErrors.endingIds.message}</span>
</div>
) : null}
</div>
</div>
);
}}
/>
) : null}
</div>
</div>
{/* Arrow */}
<div className="flex items-center justify-center">
<ArrowDownIcon className="h-4 w-4 text-slate-500" />
</div>
{/* Action */}
<div className="flex flex-col rounded-lg border border-slate-300">
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
<div className="rounded-full border border-slate-300 bg-white p-1">
<MailIcon className="h-3 w-3 text-slate-500" />
</div>
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_label")}
</h2>
</div>
{/* email setup */}
<div className="flex flex-col gap-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_settings")}
</h2>
{/* To */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="emailTo"
render={({ field }) => {
return (
<div className="flex flex-col space-y-2">
<FormLabel htmlFor="emailTo" className="font-medium">
{t("environments.surveys.edit.follow_ups_modal_action_to_label")}
</FormLabel>
<FormDescription
className={cn(
"text-sm",
formErrors.emailTo ? "text-red-500" : "text-slate-500"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_to_description")}
</FormDescription>
{emailSendToOptions.length === 0 && (
<div className="mt-4 flex items-start text-yellow-600">
<TriangleAlertIcon
className="mr-2 h-5 min-h-5 w-5 min-w-5"
aria-hidden="true"
/>
<p className="text-sm">
{t("environments.surveys.edit.follow_ups_modal_action_to_warning")}
</p>
</div>
)}
{emailSendToOptions.length > 0 ? (
<div className="max-w-80">
<FormControl>
<Select
defaultValue={field.value}
onValueChange={(value) => {
const selectedOption = emailSendToOptions.find(
(option) => option.id === value
);
if (!selectedOption) return;
field.onChange(selectedOption.id);
}}>
<SelectTrigger className="overflow-hidden text-ellipsis whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailSendToQuestionOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex items-center space-x-2 p-2">
<p className="text-sm text-slate-500">Questions</p>
</div>
{emailSendToQuestionOptions.map((option) =>
renderSelectItem(option)
)}
</div>
) : null}
{emailSendToHiddenFieldOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex space-x-2 p-2">
<p className="text-sm text-slate-500">Hidden Fields</p>
</div>
{emailSendToHiddenFieldOptions.map((option) =>
renderSelectItem(option)
)}
</div>
) : null}
{userSendToEmailOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex space-x-2 p-2">
<p className="text-sm text-slate-500">Users</p>
</div>
{userSendToEmailOptions.map((option) => renderSelectItem(option))}
</div>
) : null}
</SelectContent>
</Select>
</FormControl>
</div>
) : null}
</div>
);
}}
/>
</div>
{/* From */}
<div className="flex flex-col space-y-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_from_label")}
</h3>
<p className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_from_description")}
</p>
<div className="w-fit rounded-md border border-slate-200 bg-slate-100 px-2 py-1">
<span className="text-sm text-slate-900">{mailFrom}</span>
</div>
</div>
{/* Reply To */}
<div className="flex flex-col space-y-2">
<FormField
control={form.control}
name="replyTo"
render={({ field }) => {
return (
<FormItem>
<FormLabel htmlFor="replyTo">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_label")}
</FormLabel>
<FormDescription className="text-sm text-slate-500">
{t("environments.surveys.edit.follow_ups_modal_action_replyTo_description")}
</FormDescription>
<FormControl>
<FollowUpActionMultiEmailInput
emails={field.value}
setEmails={field.onChange}
isInvalid={!!formErrors.replyTo}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</div>
{/* email content */}
<div className="flex flex-col space-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_content")}
</h2>
<FormField
control={form.control}
name="subject"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel>
{t("environments.surveys.edit.follow_ups_modal_action_subject_label")}
</FormLabel>
<FormControl>
<Input
{...field}
className="max-w-80"
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_subject_placeholder"
)}
isInvalid={!!formErrors.subject}
/>
</FormControl>
</div>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel
className={cn(
"font-medium",
formErrors.body ? "text-red-500" : "text-slate-700"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_body_label")}
</FormLabel>
<FormControl>
<Editor
disableLists
excludedToolbarItems={["blockType"]}
getText={() => field.value}
setText={(v: string) => {
field.onChange(v);
}}
firstRender={firstRender}
setFirstRender={setFirstRender}
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_body_placeholder"
)}
onEmptyChange={(isEmpty) => {
if (isEmpty) {
if (!formErrors.body) {
form.setError("body", {
type: "manual",
message: "Body is required",
});
}
} else {
if (formErrors.body) {
form.clearErrors("body");
}
}
}}
isInvalid={!!formErrors.body}
/>
</FormControl>
{formErrors.body ? (
<div>
<span className="text-sm text-red-500">{formErrors.body.message}</span>
</div>
) : null}
</div>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="attachResponseData"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="attachResponseData"
checked={field.value}
defaultChecked={defaultValues?.attachResponseData ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<FormLabel htmlFor="attachResponseData" className="font-medium">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
)}
</FormLabel>
</div>
<FormDescription className="text-sm text-slate-500">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
)}
</FormDescription>
</div>
</FormItem>
);
}}
/>
</div>
</div>
{/* email content */}
<div className="flex flex-col space-y-4 p-4">
<h2 className="text-md font-semibold text-slate-900">
{t("environments.surveys.edit.follow_ups_modal_action_email_content")}
</h2>
<FormField
control={form.control}
name="subject"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel>
{t("environments.surveys.edit.follow_ups_modal_action_subject_label")}
</FormLabel>
<FormControl>
<Input
{...field}
className="max-w-80"
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_subject_placeholder"
)}
isInvalid={!!formErrors.subject}
/>
</FormControl>
</div>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col space-y-2">
<FormLabel
className={cn(
"font-medium",
formErrors.body ? "text-red-500" : "text-slate-700"
)}>
{t("environments.surveys.edit.follow_ups_modal_action_body_label")}
</FormLabel>
<FormControl>
<Editor
disableLists
excludedToolbarItems={["blockType"]}
getText={() => field.value}
setText={(v: string) => {
field.onChange(v);
}}
firstRender={firstRender}
setFirstRender={setFirstRender}
placeholder={t(
"environments.surveys.edit.follow_ups_modal_action_body_placeholder"
)}
onEmptyChange={(isEmpty) => {
if (isEmpty) {
if (!formErrors.body) {
form.setError("body", {
type: "manual",
message: "Body is required",
});
}
} else {
if (formErrors.body) {
form.clearErrors("body");
}
}
}}
isInvalid={!!formErrors.body}
/>
</FormControl>
{formErrors.body ? (
<div>
<span className="text-sm text-red-500">{formErrors.body.message}</span>
</div>
) : null}
</div>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="attachResponseData"
render={({ field }) => {
return (
<FormItem>
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="attachResponseData"
checked={field.value}
defaultChecked={defaultValues?.attachResponseData ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<FormLabel htmlFor="attachResponseData" className="font-medium">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
)}
</FormLabel>
</div>
<FormDescription className="text-sm text-slate-500">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
)}
</FormDescription>
</div>
</FormItem>
);
}}
/>
</div>
</div>
</div>
</div>
</DialogBody>
<div className="absolute bottom-0 right-0 z-20 h-12 w-full bg-white p-2">
<div className="flex justify-end space-x-2">
<DialogFooter>
<Button
type="button"
variant="ghost"
size="sm"
variant="secondary"
onClick={() => {
setOpen(false);
form.reset();
@@ -869,13 +862,11 @@ export const FollowUpModal = ({
{t("common.cancel")}
</Button>
<Button loading={formSubmitting} size="sm">
{t("common.save")}
</Button>
</div>
</div>
</form>
</FormProvider>
</Modal>
<Button loading={formSubmitting}>{t("common.save")}</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
@@ -191,10 +191,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="relative flex h-full w-full flex-col gap-8 overflow-y-auto bg-white p-4">
<div className="space-y-8 pb-12">
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full w-full flex-col bg-white">
<div className="flex-1 space-y-8 overflow-y-auto">
{formFields.fields.map((field, projectIndex) => {
const project = filteredProjects.find((project) => project.id === field.project);
if (!project) return null;
@@ -211,13 +209,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: C
);
})}
</div>
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
</div>
<div className="sticky bottom-0 flex justify-end space-x-2 bg-white pt-4">
<Button type="button" onClick={onCancel} variant="secondary">
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
</div>
</form>
</FormProvider>
@@ -9,16 +9,26 @@ vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, noPadding, restrictOverflow }) =>
open ? (
<div data-testid="mock-modal" data-no-padding={noPadding} data-restrict-overflow={restrictOverflow}>
<button data-testid="modal-close-button" onClick={() => setOpen(false)}>
Close Modal
</button>
{children}
</div>
) : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock SurveyCopyOptions component
@@ -53,24 +63,26 @@ describe("CopySurveyModal", () => {
vi.clearAllMocks();
});
test("renders modal when open is true", () => {
test("renders dialog when open is true", () => {
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
// Check if the modal is rendered with correct props
const modal = screen.getByTestId("mock-modal");
expect(modal).toBeInTheDocument();
expect(modal).toHaveAttribute("data-no-padding", "true");
expect(modal).toHaveAttribute("data-restrict-overflow", "true");
// Check if the dialog is rendered with correct structure
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
// Check if the header content is rendered
expect(screen.getByText("environments.surveys.copy_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.copy_survey_description")).toBeInTheDocument();
});
test("doesn't render modal when open is false", () => {
test("doesn't render dialog when open is false", () => {
render(<CopySurveyModal open={false} setOpen={mockSetOpen} survey={mockSurvey} />);
expect(screen.queryByTestId("mock-modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.copy_survey")).not.toBeInTheDocument();
});
@@ -96,7 +108,7 @@ describe("CopySurveyModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("passes onCancel function that closes the modal", async () => {
test("passes onCancel function that closes the dialog", async () => {
const user = userEvent.setup();
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
@@ -1,7 +1,14 @@
"use client";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon } from "lucide-react";
import SurveyCopyOptions from "./survey-copy-options";
@@ -15,33 +22,23 @@ interface CopySurveyModalProps {
export const CopySurveyModal = ({ open, setOpen, survey }: CopySurveyModalProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} noPadding restrictOverflow>
<div className="flex h-full flex-col rounded-lg">
<div className="fixed left-0 right-0 z-10 h-24 rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<MousePointerClickIcon className="h-6 w-6 text-slate-500" />
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.surveys.copy_survey")}
</div>
<div className="text-sm text-slate-500">
{t("environments.surveys.copy_survey_description")}
</div>
</div>
</div>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[600px]">
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.surveys.copy_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.copy_survey_description")}</DialogDescription>
</DialogHeader>
<div className="h-full max-h-[500px] overflow-auto pl-4 pt-24">
<DialogBody>
<SurveyCopyOptions
survey={survey}
environmentId={survey.environmentId}
onCancel={() => setOpen(false)}
setOpen={setOpen}
/>
</div>
</div>
</Modal>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -1,27 +1,77 @@
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Modal } from "@/modules/ui/components/modal";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open, title }) =>
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-content">{children}</div>
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null
) : null,
DialogContent: ({
children,
className,
hideCloseButton,
}: {
children: React.ReactNode;
className?: string;
hideCloseButton?: boolean;
}) => (
<div data-testid="dialog-content" className={className} data-hide-close-button={hideCloseButton}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, variant, onClick }) => (
<button data-testid={`button-${variant || "primary"}`} onClick={onClick}>
{children}
</button>
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) =>
key === "common.are_you_sure_this_action_cannot_be_undone"
? "Are you sure? This action cannot be undone."
: key,
t: (key: string) => {
const translations = {
"common.are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.",
};
return translations[key] || key;
},
}),
}));
@@ -50,25 +100,23 @@ describe("AlertDialog Component", () => {
/>
);
// Verify Modal is rendered
const modalMock = vi.mocked(Modal);
expect(modalMock).toHaveBeenCalled();
// Check the props passed to Modal
const modalProps = modalMock.mock.calls[0][0];
expect(modalProps.open).toBe(true);
expect(modalProps.title).toBe("Test Header");
expect(modalProps.setOpen).toBe(setOpenMock);
// Verify Dialog is rendered
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Header");
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
// Verify main text is displayed
expect(screen.getByText("Test Main Text")).toBeInTheDocument();
// Verify buttons are displayed
const confirmButton = screen.getByText("Confirm");
expect(confirmButton).toBeInTheDocument();
const confirmButton = screen.getByTestId("button-primary");
expect(confirmButton).toHaveTextContent("Confirm");
const declineButton = screen.getByText("Decline");
expect(declineButton).toBeInTheDocument();
const declineButton = screen.getByTestId("button-destructive");
expect(declineButton).toHaveTextContent("Decline");
// Test button clicks
const user = userEvent.setup();
@@ -79,6 +127,20 @@ describe("AlertDialog Component", () => {
expect(onDeclineMock).toHaveBeenCalledTimes(1);
});
test("does not render when closed", () => {
render(
<AlertDialog
open={false}
setOpen={vi.fn()}
headerText="Test Header"
mainText="Test Main Text"
confirmBtnLabel="Confirm"
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("does not render the decline button when declineBtnLabel or onDecline is not provided", () => {
render(
<AlertDialog
@@ -90,10 +152,12 @@ describe("AlertDialog Component", () => {
/>
);
expect(screen.queryByText("Decline")).not.toBeInTheDocument();
expect(screen.queryByTestId("button-destructive")).not.toBeInTheDocument();
expect(screen.queryByTestId("button-ghost")).not.toBeInTheDocument();
expect(screen.getByTestId("button-primary")).toBeInTheDocument();
});
test("closes the modal when onConfirm is not provided and confirm button is clicked", async () => {
test("closes the dialog when onConfirm is not provided and confirm button is clicked", async () => {
const setOpenMock = vi.fn();
render(
@@ -107,9 +171,9 @@ describe("AlertDialog Component", () => {
);
const user = userEvent.setup();
await user.click(screen.getByText("Confirm"));
await user.click(screen.getByTestId("button-primary"));
// Should close the modal by setting open to false
// Should close the dialog by setting open to false
expect(setOpenMock).toHaveBeenCalledWith(false);
});
@@ -128,6 +192,6 @@ describe("AlertDialog Component", () => {
/>
);
expect(screen.getByText("Decline")).toBeInTheDocument();
expect(screen.getByTestId("button-ghost")).toBeInTheDocument();
});
});
@@ -1,8 +1,14 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface AlertDialogProps {
open: boolean;
@@ -27,29 +33,31 @@ export const AlertDialog = ({
declineBtnVariant = "ghost",
onConfirm,
}: AlertDialogProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} title={headerText}>
<p className="mb-6 text-slate-900">
{mainText ?? t("common.are_you_sure_this_action_cannot_be_undone")}
</p>
<div className="space-x-2 text-right">
{declineBtnLabel && onDecline && (
<Button variant={declineBtnVariant} onClick={onDecline}>
{declineBtnLabel}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg" hideCloseButton={true}>
<DialogHeader>
<DialogTitle>{headerText}</DialogTitle>
</DialogHeader>
<DialogBody>{mainText}</DialogBody>
<DialogFooter>
{declineBtnLabel && onDecline && (
<Button variant={declineBtnVariant} onClick={onDecline}>
{declineBtnLabel}
</Button>
)}
<Button
onClick={() => {
if (onConfirm) {
onConfirm();
} else {
setOpen(false);
}
}}>
{confirmBtnLabel}
</Button>
)}
<Button
onClick={() => {
if (onConfirm) {
onConfirm();
} else {
setOpen(false);
}
}}>
{confirmBtnLabel}
</Button>
</div>
</Modal>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -4,12 +4,82 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { ConfirmDeleteSegmentModal } from "./index";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, variant, onClick, disabled }) => (
<button data-testid={`button-${variant || "primary"}`} data-disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"environments.segments.delete_segment": "Delete Segment",
"environments.segments.cannot_delete_segment_used_in_surveys":
"Cannot delete segment used in surveys",
"environments.segments.please_remove_the_segment_from_these_surveys_in_order_to_delete_it":
"Please remove the segment from these surveys in order to delete it",
"environments.project.general.this_action_cannot_be_undone":
"Are you sure? This action cannot be undone.",
"common.cancel": "Cancel",
"common.delete": "Delete",
};
return translations[key] || key;
},
}),
}));
describe("ConfirmDeleteSegmentModal", () => {
afterEach(() => {
cleanup();
});
test("renders modal with segment that has no surveys", async () => {
test("renders dialog with segment that has no surveys", async () => {
const mockSegment: TSegmentWithSurveyNames = {
id: "seg-123",
title: "Test Segment",
@@ -36,16 +106,49 @@ describe("ConfirmDeleteSegmentModal", () => {
/>
);
expect(screen.getByText("common.are_you_sure_this_action_cannot_be_undone")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByText("Delete Segment")).toBeInTheDocument();
expect(screen.getByText("Are you sure? This action cannot be undone.")).toBeInTheDocument();
const deleteButton = screen.getByText("common.delete");
expect(deleteButton).not.toBeDisabled();
const deleteButton = screen.getByTestId("button-destructive");
expect(deleteButton).not.toHaveAttribute("data-disabled", "true");
await userEvent.click(deleteButton);
expect(mockOnDelete).toHaveBeenCalledTimes(1);
});
test("renders modal with segment that has surveys and disables delete button", async () => {
test("does not render when closed", () => {
const mockSegment: TSegmentWithSurveyNames = {
id: "seg-123",
title: "Test Segment",
description: "",
isPrivate: false,
filters: [],
surveys: [],
environmentId: "env-123",
createdAt: new Date(),
updatedAt: new Date(),
activeSurveys: [],
inactiveSurveys: [],
};
const mockOnDelete = vi.fn().mockResolvedValue(undefined);
const mockSetOpen = vi.fn();
render(
<ConfirmDeleteSegmentModal
open={false}
setOpen={mockSetOpen}
segment={mockSegment}
onDelete={mockOnDelete}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders dialog with segment that has surveys and disables delete button", async () => {
const mockSegment: TSegmentWithSurveyNames = {
id: "seg-456",
title: "Test Segment With Surveys",
@@ -72,23 +175,20 @@ describe("ConfirmDeleteSegmentModal", () => {
/>
);
expect(
screen.getByText("environments.segments.cannot_delete_segment_used_in_surveys")
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("Cannot delete segment used in surveys")).toBeInTheDocument();
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
expect(
screen.getByText(
"environments.segments.please_remove_the_segment_from_these_surveys_in_order_to_delete_it"
)
screen.getByText("Please remove the segment from these surveys in order to delete it")
).toBeInTheDocument();
const deleteButton = screen.getByText("common.delete");
expect(deleteButton).toBeDisabled();
const deleteButton = screen.getByTestId("button-destructive");
expect(deleteButton).toHaveAttribute("data-disabled", "true");
});
test("closes the modal when cancel button is clicked", async () => {
test("closes the dialog when cancel button is clicked", async () => {
const mockSegment: TSegmentWithSurveyNames = {
id: "seg-789",
title: "Test Segment",
@@ -115,7 +215,7 @@ describe("ConfirmDeleteSegmentModal", () => {
/>
);
const cancelButton = screen.getByText("common.cancel");
const cancelButton = screen.getByTestId("button-secondary");
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
@@ -1,7 +1,15 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import React, { useMemo } from "react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
@@ -29,36 +37,43 @@ export const ConfirmDeleteSegmentModal = ({
}, [segment.activeSurveys.length, segment.inactiveSurveys.length]);
return (
<Modal open={open} setOpen={setOpen} title={t("environments.segments.delete_segment")}>
<div className="text-slate-900">
{segmentHasSurveys && (
<div className="space-y-2">
<p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p>
<ol className="my-2 ml-4 list-decimal">
{segment.activeSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
{segment.inactiveSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
</ol>
</div>
)}
<p className="mt-2">
{segmentHasSurveys
? t("environments.segments.please_remove_the_segment_from_these_surveys_in_order_to_delete_it")
: t("common.are_you_sure_this_action_cannot_be_undone")}
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("environments.segments.delete_segment")}</DialogTitle>
<DialogDescription>
{t("environments.project.general.this_action_cannot_be_undone")}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-x-2 text-right">
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={segmentHasSurveys}>
{t("common.delete")}
</Button>
</div>
</Modal>
{segmentHasSurveys && (
<DialogBody>
<div className="space-y-2">
<p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p>
<ol className="my-2 ml-4 list-decimal">
{segment.activeSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
{segment.inactiveSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
</ol>
</div>
<p className="mt-2">
{t("environments.segments.please_remove_the_segment_from_these_surveys_in_order_to_delete_it")}
</p>
</DialogBody>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={segmentHasSurveys}>
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -9,21 +9,30 @@ vi.mock("@tolgee/react", () => ({
}),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children, title, hideCloseButton, closeOnOutsideClick, setOpen }: any) => (
<div
data-testid="mock-modal"
data-open={open}
data-title={title}
data-hide-close-button={hideCloseButton}
data-close-on-outside-click={closeOnOutsideClick}>
<button data-testid="modal-close-button" onClick={() => setOpen(false)}>
Close
</button>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-content">{children}</div>
</div>
// Mock Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog-component" data-open={open ? "true" : "false"}>
{children}
<button data-testid="close-dialog" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, disableCloseOnOutsideClick }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton ? "true" : "false"}
data-disable-close-outside={disableCloseOnOutsideClick ? "true" : "false"}>
{children}
</div>
)),
DialogHeader: vi.fn(({ children }) => <div data-testid="dialog-header">{children}</div>),
DialogTitle: vi.fn(({ children }) => <h2 data-testid="dialog-title">{children}</h2>),
DialogDescription: vi.fn(({ children }) => <div data-testid="dialog-description">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
}));
vi.mock("@/modules/ui/components/button", () => ({
@@ -59,10 +68,10 @@ describe("ConfirmationModal", () => {
/>
);
expect(screen.getByTestId("mock-modal")).toBeInTheDocument();
expect(screen.getByTestId("mock-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("mock-modal")).toHaveAttribute("data-title", "Test Title");
expect(screen.getByTestId("modal-content")).toContainHTML("Test confirmation text");
expect(screen.getByTestId("dialog-component")).toBeInTheDocument();
expect(screen.getByTestId("dialog-component")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Title");
expect(screen.getByTestId("dialog-description")).toContainHTML("Test confirmation text");
// Check that buttons exist
const buttons = screen.getAllByTestId("mock-button");
@@ -99,7 +108,7 @@ describe("ConfirmationModal", () => {
expect(mockOnConfirm).not.toHaveBeenCalled();
});
test("handles close modal button click correctly", async () => {
test("handles close dialog button click correctly", async () => {
const user = userEvent.setup();
const mockSetOpen = vi.fn();
const mockOnConfirm = vi.fn();
@@ -115,7 +124,7 @@ describe("ConfirmationModal", () => {
/>
);
await user.click(screen.getByTestId("modal-close-button"));
await user.click(screen.getByTestId("close-dialog"));
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect(mockOnConfirm).not.toHaveBeenCalled();
@@ -207,7 +216,7 @@ describe("ConfirmationModal", () => {
expect(buttons[1]).toHaveAttribute("data-loading", "true");
});
test("passes correct modal props", () => {
test("passes correct dialog props", () => {
const mockSetOpen = vi.fn();
const mockOnConfirm = vi.fn();
@@ -224,8 +233,8 @@ describe("ConfirmationModal", () => {
/>
);
expect(screen.getByTestId("mock-modal")).toHaveAttribute("data-hide-close-button", "true");
expect(screen.getByTestId("mock-modal")).toHaveAttribute("data-close-on-outside-click", "false");
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-hide-close-button", "true");
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-disable-close-outside", "true");
});
test("renders with default button variant", () => {
@@ -247,4 +256,22 @@ describe("ConfirmationModal", () => {
const buttons = screen.getAllByTestId("mock-button");
expect(buttons[1]).toHaveAttribute("data-variant", "default");
});
test("does not render when open is false", () => {
const mockSetOpen = vi.fn();
const mockOnConfirm = vi.fn();
render(
<ConfirmationModal
title="Test Title"
open={false}
setOpen={mockSetOpen}
onConfirm={mockOnConfirm}
text="Test confirmation text"
buttonText="Confirm Action"
/>
);
expect(screen.queryByTestId("dialog-component")).not.toBeInTheDocument();
});
});
@@ -1,7 +1,14 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import React from "react";
@@ -39,28 +46,31 @@ export const ConfirmationModal = ({
};
return (
<Modal
open={open}
setOpen={setOpen}
title={title}
hideCloseButton={hideCloseButton}
closeOnOutsideClick={closeOnOutsideClick}>
<div className="text-slate-900">
<p className="mt-2 whitespace-pre-wrap">{text}</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
hideCloseButton={hideCloseButton}
disableCloseOnOutsideClick={!closeOnOutsideClick}
className="max-w-[540px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="text-slate-900">
<span className="mt-2 whitespace-pre-wrap">{text}</span>
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-x-2 text-right">
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
loading={buttonLoading}
disabled={isButtonDisabled}
variant={buttonVariant}
onClick={handleButtonAction}>
{buttonText}
</Button>
</div>
</Modal>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
loading={buttonLoading}
disabled={isButtonDisabled}
variant={buttonVariant}
onClick={handleButtonAction}>
{buttonText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,133 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CustomDialog } from "./index";
// Mock dependencies
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, title }) =>
open ? (
<div data-testid="mock-modal" data-title={title}>
{children}
</div>
) : null,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, loading, disabled }) => (
<button
data-testid={`mock-button-${variant}`}
onClick={onClick}
disabled={loading || disabled}
data-loading={loading ? "true" : "false"}>
{children}
</button>
),
}));
describe("CustomDialog", () => {
afterEach(() => {
cleanup();
});
test("renders when open is true", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} title="Test Dialog" />);
expect(screen.getByTestId("mock-modal")).toBeInTheDocument();
});
test("does not render when open is false", () => {
render(<CustomDialog open={false} setOpen={() => {}} onOk={() => {}} />);
expect(screen.queryByTestId("mock-modal")).not.toBeInTheDocument();
});
test("renders with title", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} title="Test Dialog Title" />);
expect(screen.getByTestId("mock-modal")).toHaveAttribute("data-title", "Test Dialog Title");
});
test("renders text content", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} text="Dialog description text" />);
expect(screen.getByText("Dialog description text")).toBeInTheDocument();
});
test("renders children content", () => {
render(
<CustomDialog open={true} setOpen={() => {}} onOk={() => {}}>
<div data-testid="custom-content">Custom content</div>
</CustomDialog>
);
expect(screen.getByTestId("custom-content")).toBeInTheDocument();
});
test("calls onOk when ok button is clicked", async () => {
const user = userEvent.setup();
const handleOk = vi.fn();
render(<CustomDialog open={true} setOpen={() => {}} onOk={handleOk} />);
await user.click(screen.getByTestId("mock-button-destructive"));
expect(handleOk).toHaveBeenCalledTimes(1);
});
test("calls setOpen and onCancel when cancel button is clicked", async () => {
const user = userEvent.setup();
const handleCancel = vi.fn();
const setOpen = vi.fn();
render(<CustomDialog open={true} setOpen={setOpen} onOk={() => {}} onCancel={handleCancel} />);
await user.click(screen.getByTestId("mock-button-secondary"));
expect(handleCancel).toHaveBeenCalledTimes(1);
expect(setOpen).toHaveBeenCalledWith(false);
});
test("calls only setOpen when cancel button is clicked if onCancel is not provided", async () => {
const user = userEvent.setup();
const setOpen = vi.fn();
render(<CustomDialog open={true} setOpen={setOpen} onOk={() => {}} />);
await user.click(screen.getByTestId("mock-button-secondary"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("renders with custom button texts", () => {
render(
<CustomDialog
open={true}
setOpen={() => {}}
onOk={() => {}}
okBtnText="Custom OK"
cancelBtnText="Custom Cancel"
/>
);
expect(screen.getByText("Custom OK")).toBeInTheDocument();
expect(screen.getByText("Custom Cancel")).toBeInTheDocument();
});
test("renders with default button texts when not provided", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} />);
// Since tolgee is mocked, the translation key itself is returned
expect(screen.getByText("common.yes")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
});
test("renders loading state on ok button", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} isLoading={true} />);
expect(screen.getByTestId("mock-button-destructive")).toHaveAttribute("data-loading", "true");
});
test("renders disabled ok button", () => {
render(<CustomDialog open={true} setOpen={() => {}} onOk={() => {}} disabled={true} />);
expect(screen.getByTestId("mock-button-destructive")).toBeDisabled();
});
});
@@ -1,56 +0,0 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
interface CustomDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
title?: string;
text?: string;
isLoading?: boolean;
children?: React.ReactNode;
onOk: () => void;
okBtnText?: string;
onCancel?: () => void;
cancelBtnText?: string;
disabled?: boolean;
}
export const CustomDialog = ({
open,
setOpen,
title,
text,
isLoading,
children,
onOk,
okBtnText,
onCancel,
cancelBtnText,
disabled,
}: CustomDialogProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} title={title}>
<p>{text}</p>
<div>{children}</div>
<div className="my-4 space-x-2 text-right">
<Button
variant="secondary"
onClick={() => {
if (onCancel) {
onCancel();
}
setOpen(false);
}}>
{cancelBtnText || t("common.cancel")}
</Button>
<Button variant="destructive" onClick={onOk} loading={isLoading} disabled={disabled}>
{okBtnText || t("common.yes")}
</Button>
</div>
</Modal>
);
};
@@ -3,6 +3,62 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DataTableSettingsModal } from "./data-table-settings-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
SettingsIcon: () => <div data-testid="settings-icon" />,
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.table_settings": "Table Settings",
"common.reorder_and_hide_columns": "Reorder and hide columns",
};
return translations[key] || key;
},
}),
}));
// Mock the dnd-kit hooks and components
vi.mock("@dnd-kit/core", async () => {
const actual = await vi.importActual("@dnd-kit/core");
@@ -37,7 +93,7 @@ describe("DataTableSettingsModal", () => {
cleanup();
});
test("renders modal with correct title and subtitle", () => {
test("renders dialog with correct title and subtitle", () => {
const mockTable = {
getAllColumns: vi.fn().mockReturnValue([
{ id: "firstName", columnDef: {} },
@@ -55,8 +111,32 @@ describe("DataTableSettingsModal", () => {
/>
);
expect(screen.getByText("common.table_settings")).toBeInTheDocument();
expect(screen.getByText("common.reorder_and_hide_columns")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
expect(screen.getByText("Table Settings")).toBeInTheDocument();
expect(screen.getByText("Reorder and hide columns")).toBeInTheDocument();
});
test("does not render when closed", () => {
const mockTable = {
getAllColumns: vi.fn().mockReturnValue([
{ id: "firstName", columnDef: {} },
{ id: "lastName", columnDef: {} },
]),
};
render(
<DataTableSettingsModal
open={false}
setOpen={vi.fn()}
table={mockTable as any}
columnOrder={["firstName", "lastName"]}
handleDragEnd={vi.fn()}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("doesn't render columns with id 'select' or 'createdAt'", () => {
@@ -1,6 +1,13 @@
"use client";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
DndContext,
DragEndEvent,
@@ -46,22 +53,15 @@ export const DataTableSettingsModal = <T,>({
const tableColumns = useMemo(() => table.getAllColumns(), [table]);
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 flex h-10 w-10 items-center justify-center text-slate-500">
<SettingsIcon className="h-6 w-6" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">{t("common.table_settings")}</div>
<div className="text-sm text-slate-500">{t("common.reorder_and_hide_columns")}</div>
</div>
</div>
</div>
</div>
<div className="max-h-[75vh] space-y-2 overflow-auto p-8">
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<SettingsIcon className="h-6 w-6" />
<DialogTitle>{t("common.table_settings")}</DialogTitle>
<DialogDescription>{t("common.reorder_and_hide_columns")}</DialogDescription>
</DialogHeader>
<DialogBody className="max-h-[75vh] space-y-2 overflow-auto">
<DndContext
id="table-settings"
sensors={sensors}
@@ -76,8 +76,8 @@ export const DataTableSettingsModal = <T,>({
})}
</SortableContext>
</DndContext>
</div>
</div>
</Modal>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -82,6 +82,11 @@ export const SelectedRowSettings = <T,>({
// Helper component for the separator
const Separator = () => <div>|</div>;
const deleteDialogText =
type === "response"
? t("environments.surveys.responses.delete_response_confirmation")
: t("environments.contacts.delete_contact_confirmation");
return (
<>
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
@@ -144,6 +149,7 @@ export const SelectedRowSettings = <T,>({
deleteWhat={t(`common.${type}`)}
onDelete={handleDelete}
isDeleting={isDeleting}
text={deleteDialogText}
/>
</>
);
@@ -3,19 +3,42 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DeleteDialog } from "./index";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, title, open, setOpen }) => {
if (!open) return null;
return (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<div>{children}</div>
<button data-testid="close-button" onClick={() => setOpen(false)}>
Close
</button>
</div>
);
},
// Mock the translation function
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"common.delete": "Delete",
"common.cancel": "Cancel",
"common.save": "Save",
"environments.project.general.this_action_cannot_be_undone": "This action cannot be undone.",
};
return translations[key] || key;
},
}),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
@@ -41,11 +64,14 @@ describe("DeleteDialog", () => {
render(<DeleteDialog open={true} setOpen={setOpen} deleteWhat="Item" onDelete={onDelete} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toHaveTextContent("common.delete Item");
expect(screen.getByText("common.are_you_sure_this_action_cannot_be_undone")).toBeInTheDocument();
expect(screen.getByTestId("button-secondary")).toHaveTextContent("common.cancel");
expect(screen.getByTestId("button-destructive")).toHaveTextContent("common.delete");
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Delete Item");
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
expect(screen.getByTestId("button-secondary")).toHaveTextContent("Cancel");
expect(screen.getByTestId("button-destructive")).toHaveTextContent("Delete");
});
test("doesn't render when closed", () => {
@@ -54,7 +80,7 @@ describe("DeleteDialog", () => {
render(<DeleteDialog open={false} setOpen={setOpen} deleteWhat="Item" onDelete={onDelete} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("calls onDelete when delete button is clicked", async () => {
@@ -131,7 +157,7 @@ describe("DeleteDialog", () => {
/>
);
expect(screen.getByTestId("button-secondary")).toHaveTextContent("common.save");
expect(screen.getByTestId("button-secondary")).toHaveTextContent("Save");
});
test("calls onSave when save button is clicked with useSaveInsteadOfCancel", async () => {
@@ -1,8 +1,17 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
interface DeleteDialogProps {
open: boolean;
@@ -33,25 +42,38 @@ export const DeleteDialog = ({
}: DeleteDialogProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} title={`${t("common.delete")} ${deleteWhat}`}>
<p>{text || t("common.are_you_sure_this_action_cannot_be_undone")}</p>
<div>{children}</div>
<div className="mt-4 space-x-2 text-right">
<Button
loading={isSaving}
variant="secondary"
onClick={() => {
if (useSaveInsteadOfCancel && onSave) {
onSave();
}
setOpen(false);
}}>
{useSaveInsteadOfCancel ? t("common.save") : t("common.cancel")}
</Button>
<Button variant="destructive" onClick={onDelete} loading={isDeleting} disabled={disabled}>
{t("common.delete")}
</Button>
</div>
</Modal>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
<DialogHeader>
<DialogTitle>{`${t("common.delete")} ${deleteWhat}`}</DialogTitle>
<DialogDescription>
{t("environments.project.general.this_action_cannot_be_undone")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<p>{text}</p>
{children && <div>{children}</div>}
</DialogBody>
<DialogFooter>
<Button
loading={isSaving}
variant="secondary"
onClick={() => {
if (useSaveInsteadOfCancel && onSave) {
onSave();
}
setOpen(false);
}}>
{useSaveInsteadOfCancel ? t("common.save") : t("common.cancel")}
</Button>
<Button variant="destructive" onClick={onDelete} loading={isDeleting} disabled={disabled}>
<TrashIcon />
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+59 -30
View File
@@ -34,39 +34,64 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps {
hideCloseButton?: boolean;
disableCloseOnOutsideClick?: boolean;
width?: "default" | "wide";
width?: "default" | "wide" | "narrow";
unconstrained?: boolean;
}
const getDialogWidthClass = (width: "default" | "wide" | "narrow"): string => {
switch (width) {
case "wide":
return "md:w-[720px] lg:w-[960px]";
case "narrow":
return "md:w-[512px]";
default:
return "md:w-[720px]";
}
};
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
>(
(
{ className, children, hideCloseButton, disableCloseOnOutsideClick, width = "default", ...props },
{
className,
children,
hideCloseButton,
disableCloseOnOutsideClick,
width = "default",
unconstrained = false,
...props
},
ref
) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg md:overflow-hidden md:rounded-lg",
width === "default" ? "md:w-[720px]" : "md:w-[720px] lg:w-[960px]",
className
)}
onPointerDownOutside={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
{...props}>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<X className="size-4 text-slate-500" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
) => {
const widthClass = getDialogWidthClass(width);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg md:rounded-lg",
!unconstrained && "md:overflow-hidden",
widthClass,
className
)}
onPointerDownOutside={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
{...props}>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<X className="size-4 text-slate-500" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
@@ -80,7 +105,7 @@ const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div
className={cn(
"sticky top-[-32px] z-10 flex flex-shrink-0 flex-col gap-y-1 bg-white text-left",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:items-center [&>svg~*]:pl-6 md:[&>svg~*]:flex",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:min-h-4 [&>svg~*]:items-center [&>svg~*]:pl-6 md:[&>svg~*]:flex",
className
)}
{...props}
@@ -97,7 +122,7 @@ type DialogFooterProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn(
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse bg-white md:sticky md:flex-row md:justify-end",
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse gap-2 bg-white md:sticky md:flex-row md:justify-end",
className
)}
{...props}
@@ -106,9 +131,13 @@ const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
DialogFooter.displayName = "DialogFooter";
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
const DialogBody = ({
className,
unconstrained = false,
...props
}: React.HTMLAttributes<HTMLElement> & { unconstrained?: boolean }) => (
<section
className={cn("flex-1 overflow-y-auto text-sm", className)}
className={cn("flex-1 text-sm", !unconstrained && "overflow-y-auto", className)}
aria-label="Dialog content"
{...props}
/>
@@ -121,7 +150,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-primary min-h-4 text-sm font-medium leading-none tracking-tight", className)}
className={cn("text-primary text-sm font-medium leading-none", className)}
{...props}
/>
));
@@ -6,6 +6,41 @@ import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LoadSegmentModal } from ".";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: () => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={onOpenChange}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, size }: { children: React.ReactNode; size?: string }) => (
<div data-testid="dialog-content" data-size={size}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the nested SegmentDetail component
vi.mock("lucide-react", async () => {
const actual = await vi.importActual<typeof import("lucide-react")>("lucide-react");
@@ -20,10 +55,36 @@ vi.mock("lucide-react", async () => {
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"environments.surveys.edit.load_segment": "Load Segment",
"environments.surveys.edit.you_have_not_created_a_segment_yet": "You have not created a segment yet",
"common.segment": "Segment",
"common.updated_at": "Updated At",
"common.created_at": "Created At",
};
return translations[key] || key;
},
}),
}));
// Mock time utilities
vi.mock("@/lib/time", () => ({
timeSinceDate: vi.fn(() => "2 days ago"),
formatDate: vi.fn(() => "Jan 1, 2023"),
}));
// Mock cn utility
vi.mock("@/lib/cn", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
describe("LoadSegmentModal", () => {
const mockDate = new Date("2023-01-01T12:00:00Z");
@@ -103,19 +164,29 @@ describe("LoadSegmentModal", () => {
test("renders empty state when no segments are available", () => {
render(<LoadSegmentModal {...defaultProps} segments={[]} />);
expect(
screen.getByText("environments.surveys.edit.you_have_not_created_a_segment_yet")
).toBeInTheDocument();
expect(screen.queryByText("common.segment")).not.toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Load Segment");
expect(screen.getByText("You have not created a segment yet")).toBeInTheDocument();
expect(screen.queryByText("Segment")).not.toBeInTheDocument();
});
test("does not render when closed", () => {
render(<LoadSegmentModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders segments list correctly when segments are available", () => {
render(<LoadSegmentModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Load Segment");
// Headers
expect(screen.getByText("common.segment")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
expect(screen.getByText("common.created_at")).toBeInTheDocument();
expect(screen.getByText("Segment")).toBeInTheDocument();
expect(screen.getByText("Updated At")).toBeInTheDocument();
expect(screen.getByText("Created At")).toBeInTheDocument();
// Only non-private segments should be visible (2 out of 3)
expect(screen.getByText("Segment 1")).toBeInTheDocument();
@@ -2,7 +2,7 @@
import { cn } from "@/lib/cn";
import { formatDate, timeSinceDate } from "@/lib/time";
import { Modal } from "@/modules/ui/components/modal";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { Loader2, UsersIcon } from "lucide-react";
import { useState } from "react";
@@ -144,45 +144,51 @@ export const LoadSegmentModal = ({
const segmentsArray = segments?.filter((segment) => !segment.isPrivate);
return (
<Modal
<Dialog
open={open}
setOpen={() => {
handleResetState();
}}
title={t("environments.surveys.edit.load_segment")}
size="lg">
<>
{!segmentsArray?.length ? (
<div className="group">
<div className="flex h-16 w-full flex-col items-center justify-center rounded-lg text-slate-700">
{t("environments.surveys.edit.you_have_not_created_a_segment_yet")}
</div>
</div>
) : (
<div className="flex flex-col">
<div>
<div className="grid h-12 grid-cols-5 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.segment")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
</div>
onOpenChange={(open) => {
if (!open) {
handleResetState();
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.surveys.edit.load_segment")}</DialogTitle>
</DialogHeader>
{segmentsArray.map((segment) => (
<SegmentDetail
key={segment.id}
segment={segment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
setOpen={setOpen}
setSegment={setSegment}
onSegmentLoad={onSegmentLoad}
surveyId={surveyId}
currentSegment={currentSegment}
/>
))}
<DialogBody>
{!segmentsArray?.length ? (
<div className="group">
<div className="flex h-16 w-full flex-col items-center justify-center rounded-md text-slate-700">
{t("environments.surveys.edit.you_have_not_created_a_segment_yet")}
</div>
</div>
</div>
)}
</>
</Modal>
) : (
<div className="flex flex-col">
<div>
<div className="grid h-12 grid-cols-5 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.segment")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
</div>
{segmentsArray.map((segment) => (
<SegmentDetail
key={segment.id}
segment={segment}
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
setOpen={setOpen}
setSegment={setSegment}
onSegmentLoad={onSegmentLoad}
surveyId={surveyId}
currentSegment={currentSegment}
/>
))}
</div>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -2,26 +2,31 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Modal } from "../modal";
import { ModalWithTabs } from "./index";
// Mock Modal component
vi.mock("../modal", () => ({
Modal: vi.fn(({ children, open, setOpen, closeOnOutsideClick, size, restrictOverflow, noPadding }) =>
// Mock Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div
data-testid="modal-component"
data-no-padding={noPadding ? "true" : "false"}
data-size={size}
data-restrict-overflow={restrictOverflow ? "true" : "false"}
data-close-outside={closeOnOutsideClick ? "true" : "false"}>
<div data-testid="dialog-component" data-open={open ? "true" : "false"}>
{children}
<button data-testid="close-modal" onClick={() => setOpen(false)}>
<button data-testid="close-dialog" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, disableCloseOnOutsideClick }) => (
<div
data-testid="dialog-content"
data-disable-close-outside={disableCloseOnOutsideClick ? "true" : "false"}>
{children}
</div>
)),
DialogHeader: vi.fn(({ children }) => <div data-testid="dialog-header">{children}</div>),
DialogTitle: vi.fn(({ children }) => <h2 data-testid="dialog-title">{children}</h2>),
DialogDescription: vi.fn(({ children }) => <p data-testid="dialog-description">{children}</p>),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
}));
describe("ModalWithTabs", () => {
@@ -52,10 +57,14 @@ describe("ModalWithTabs", () => {
description: "Test Description",
};
test("renders modal with tabs when open", () => {
test("renders dialog with tabs when open", () => {
render(<ModalWithTabs {...defaultProps} />);
expect(screen.getByTestId("modal-component")).toBeInTheDocument();
expect(screen.getByTestId("dialog-component")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByText("Test Label")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
@@ -73,7 +82,7 @@ describe("ModalWithTabs", () => {
test("doesn't render when not open", () => {
render(<ModalWithTabs {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal-component")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog-component")).not.toBeInTheDocument();
});
test("switches tabs when clicking on tab buttons", async () => {
@@ -110,11 +119,11 @@ describe("ModalWithTabs", () => {
await user.click(screen.getByText("Tab 2"));
expect(screen.getByTestId("tab-2-content")).toBeInTheDocument();
// Close the modal
await user.click(screen.getByTestId("close-modal"));
// Close the dialog
await user.click(screen.getByTestId("close-dialog"));
expect(setOpen).toHaveBeenCalledWith(false);
// Reopen the modal
// Reopen the dialog
rerender(<ModalWithTabs {...defaultProps} setOpen={setOpen} open={false} />);
rerender(<ModalWithTabs {...defaultProps} setOpen={setOpen} open={true} />);
@@ -130,30 +139,41 @@ describe("ModalWithTabs", () => {
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
});
test("passes proper props to Modal component", () => {
render(
<ModalWithTabs
open={true}
setOpen={vi.fn()}
tabs={mockTabs}
closeOnOutsideClick={true}
size="md"
restrictOverflow={true}
/>
);
test("passes proper props to Dialog components", () => {
render(<ModalWithTabs open={true} setOpen={vi.fn()} tabs={mockTabs} closeOnOutsideClick={true} />);
const modalElement = screen.getByTestId("modal-component");
expect(modalElement).toHaveAttribute("data-no-padding", "true");
expect(modalElement).toHaveAttribute("data-size", "md");
expect(modalElement).toHaveAttribute("data-restrict-overflow", "true");
expect(modalElement).toHaveAttribute("data-close-outside", "true");
const dialogContentElement = screen.getByTestId("dialog-content");
expect(dialogContentElement).toHaveAttribute("data-disable-close-outside", "true");
});
test("uses default values for optional props", () => {
render(<ModalWithTabs open={true} setOpen={vi.fn()} tabs={mockTabs} />);
const modalElement = screen.getByTestId("modal-component");
expect(modalElement).toHaveAttribute("data-size", "lg");
expect(modalElement).toHaveAttribute("data-restrict-overflow", "false");
const dialogElement = screen.getByTestId("dialog-component");
expect(dialogElement).toBeInTheDocument();
});
test("renders without label and description when not provided", () => {
render(<ModalWithTabs open={true} setOpen={vi.fn()} tabs={mockTabs} />);
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
// The title and description elements exist but should be empty
expect(screen.getByTestId("dialog-title")).toBeEmptyDOMElement();
expect(screen.getByTestId("dialog-description")).toBeEmptyDOMElement();
});
test("applies correct styling to active and inactive tabs", () => {
render(<ModalWithTabs {...defaultProps} />);
const tab1Button = screen.getByText("Tab 1").closest("button");
const tab2Button = screen.getByText("Tab 2").closest("button");
// First tab should have active styling
expect(tab1Button).toHaveClass("border-brand-dark", "border-b-2", "font-semibold", "text-slate-900");
// Second tab should have inactive styling
expect(tab2Button).toHaveClass("text-slate-500");
expect(tab2Button).not.toHaveClass("border-brand-dark", "border-b-2", "font-semibold", "text-slate-900");
});
});
@@ -1,4 +1,11 @@
import { Modal } from "@/modules/ui/components/modal";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useEffect, useState } from "react";
interface ModalWithTabsProps {
@@ -9,8 +16,6 @@ interface ModalWithTabsProps {
description?: string;
tabs: TabProps[];
closeOnOutsideClick?: boolean;
size?: "md" | "lg";
restrictOverflow?: boolean;
}
interface TabProps {
@@ -26,8 +31,6 @@ export const ModalWithTabs = ({
label,
description,
closeOnOutsideClick,
size = "lg",
restrictOverflow = false,
}: ModalWithTabsProps) => {
const [activeTab, setActiveTab] = useState(0);
@@ -42,41 +45,31 @@ export const ModalWithTabs = ({
}, [open]);
return (
<Modal
open={open}
setOpen={setOpen}
noPadding
closeOnOutsideClick={closeOnOutsideClick}
size={size}
restrictOverflow={restrictOverflow}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="mr-20 flex items-center justify-between truncate p-6">
<div className="flex items-center space-x-2">
{icon && <div className="mr-1.5 h-6 w-6 text-slate-500">{icon}</div>}
<div>
{label && <div className="text-xl font-medium text-slate-700">{label}</div>}
{description && <div className="text-sm text-slate-500">{description}</div>}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick={closeOnOutsideClick}>
<DialogHeader>
{icon}
<DialogTitle>{label}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex h-full w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
key={index}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
</div>
<div className="flex h-full w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
key={index}
className={`mr-4 px-1 pb-3 pt-6 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 p-6">{tabs[activeTab].children}</div>
</div>
</Modal>
<div className="flex-1 pt-4">{tabs[activeTab].children}</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -1,192 +0,0 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Modal } from ".";
// Mock the Dialog components from radix-ui
vi.mock("@radix-ui/react-dialog", async () => {
const actual = await vi.importActual<typeof import("@radix-ui/react-dialog")>("@radix-ui/react-dialog");
return {
...actual,
Root: ({ children, open, onOpenChange }: any) => (
<div data-testid="dialog-root" data-state={open ? "open" : "closed"}>
{open && children}
<button data-testid="mock-close-trigger" onClick={() => onOpenChange(false)}>
Close Dialog
</button>
</div>
),
Portal: ({ children }: any) => <div data-testid="dialog-portal">{children}</div>,
Overlay: ({ className, ...props }: any) => (
<div data-testid="dialog-overlay" className={className} {...props} />
),
Content: ({ className, children, ...props }: any) => (
<div data-testid="dialog-content" className={className} {...props}>
{children}
</div>
),
Close: ({ className, children }: any) => (
<button data-testid="dialog-close" className={className}>
{children}
</button>
),
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
DialogDescription: () => <div data-testid="dialog-description" />,
};
});
describe("Modal", () => {
afterEach(() => {
cleanup();
});
test("renders nothing when open is false", () => {
render(
<Modal open={false} setOpen={() => {}}>
<div>Test Content</div>
</Modal>
);
expect(screen.queryByTestId("dialog-root")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
render(
<Modal open={true} setOpen={() => {}}>
<div data-testid="modal-content">Test Content</div>
</Modal>
);
expect(screen.getByTestId("dialog-root")).toBeInTheDocument();
expect(screen.getByTestId("dialog-portal")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("modal-content")).toBeInTheDocument();
expect(screen.getByText("Test Content")).toBeInTheDocument();
});
test("renders with title when provided", () => {
render(
<Modal open={true} setOpen={() => {}} title="Test Title">
<div>Test Content</div>
</Modal>
);
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
test("applies size classes correctly", () => {
const { rerender } = render(
<Modal open={true} setOpen={() => {}} size="lg">
<div>Test Content</div>
</Modal>
);
let content = screen.getByTestId("dialog-content");
expect(content.className).toContain("sm:max-w-[820px]");
rerender(
<Modal open={true} setOpen={() => {}} size="xl">
<div>Test Content</div>
</Modal>
);
content = screen.getByTestId("dialog-content");
expect(content.className).toContain("sm:max-w-[960px]");
expect(content.className).toContain("sm:max-h-[640px]");
rerender(
<Modal open={true} setOpen={() => {}} size="xxl">
<div>Test Content</div>
</Modal>
);
content = screen.getByTestId("dialog-content");
expect(content.className).toContain("sm:max-w-[1240px]");
expect(content.className).toContain("sm:max-h-[760px]");
});
test("applies noPadding class when noPadding is true", () => {
render(
<Modal open={true} setOpen={() => {}} noPadding>
<div>Test Content</div>
</Modal>
);
const content = screen.getByTestId("dialog-content");
expect(content.className).not.toContain("px-4 pt-5 pb-4 sm:p-6");
});
test("applies the blur class to overlay when blur is true", () => {
render(
<Modal open={true} setOpen={() => {}} blur={true}>
<div>Test Content</div>
</Modal>
);
const overlay = screen.getByTestId("dialog-overlay");
expect(overlay.className).toContain("backdrop-blur-md");
});
test("does not apply the blur class to overlay when blur is false", () => {
render(
<Modal open={true} setOpen={() => {}} blur={false}>
<div>Test Content</div>
</Modal>
);
const overlay = screen.getByTestId("dialog-overlay");
expect(overlay.className).not.toContain("backdrop-blur-md");
});
test("hides close button when hideCloseButton is true", () => {
render(
<Modal open={true} setOpen={() => {}} hideCloseButton={true}>
<div>Test Content</div>
</Modal>
);
const closeButton = screen.getByTestId("dialog-close");
expect(closeButton.className).toContain("!hidden");
});
test("calls setOpen when dialog is closed", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
render(
<Modal open={true} setOpen={setOpen}>
<div>Test Content</div>
</Modal>
);
await user.click(screen.getByTestId("mock-close-trigger"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("applies restrictOverflow class when restrictOverflow is true", () => {
render(
<Modal open={true} setOpen={() => {}} restrictOverflow={true}>
<div>Test Content</div>
</Modal>
);
const content = screen.getByTestId("dialog-content");
expect(content.className).not.toContain("overflow-hidden");
});
test("applies custom className when provided", () => {
const customClass = "test-custom-class";
render(
<Modal open={true} setOpen={() => {}} className={customClass}>
<div>Test Content</div>
</Modal>
);
const content = screen.getByTestId("dialog-content");
expect(content.className).toContain(customClass);
});
});
@@ -1,137 +0,0 @@
import { cn } from "@/lib/cn";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, blur, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
blur && "backdrop-blur-md",
"fixed inset-0 z-50 bg-opacity-30",
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn"
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends Pick<
ModalProps,
"blur" | "noPadding" | "size" | "hideCloseButton" | "closeOnOutsideClick" | "title" | "restrictOverflow"
> {}
const sizeClassName = {
md: "sm:max-w-xl",
lg: "sm:max-w-[820px]",
xl: "sm:max-w-[960px] sm:max-h-[640px]",
xxl: "sm:max-w-[1240px] sm:max-h-[760px]",
};
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
>(
(
{
className,
children,
blur,
noPadding,
size,
hideCloseButton,
restrictOverflow = false,
closeOnOutsideClick,
title,
...props
},
ref
) => (
<DialogPortal>
<DialogOverlay blur={blur} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-2 sm:w-full sm:max-w-xl",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn",
size && sizeClassName && sizeClassName[size],
!restrictOverflow && "overflow-hidden",
className
)}
{...props}
onPointerDownOutside={(e) => {
if (!closeOnOutsideClick) {
e.preventDefault();
}
}}>
<DialogPrimitive.DialogTitle>
{title && <p className="mb-4 text-xl font-bold text-slate-500">{title}</p>}
</DialogPrimitive.DialogTitle>
<DialogPrimitive.DialogDescription></DialogPrimitive.DialogDescription>
{children}
<DialogPrimitive.Close
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block",
hideCloseButton && "!hidden"
)}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
interface ModalProps {
open: boolean;
setOpen: (v: boolean) => void;
children: React.ReactNode;
title?: string;
noPadding?: boolean;
blur?: boolean;
closeOnOutsideClick?: boolean;
className?: string;
size?: "md" | "lg" | "xl" | "xxl";
hideCloseButton?: boolean;
restrictOverflow?: boolean;
}
export const Modal = ({
open,
setOpen,
children,
blur = true,
size = "md",
noPadding,
hideCloseButton = false,
closeOnOutsideClick = true,
title,
className,
restrictOverflow = false,
}: ModalProps) => {
if (!open) return null;
return (
<DialogPrimitive.Root open={open} onOpenChange={(open: boolean) => setOpen(open)} modal>
<DialogContent
blur={blur}
size={size}
noPadding={noPadding}
hideCloseButton={hideCloseButton}
closeOnOutsideClick={closeOnOutsideClick}
title={title}
className={className}
restrictOverflow={restrictOverflow}>
{children}
</DialogContent>
</DialogPrimitive.Root>
);
};
@@ -29,7 +29,7 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
<FormItem>
<FormControl>
<div>
<Label className="font-semibold">{t("environments.actions.what_is_the_user_doing")}</Label>
<Label>{t("environments.actions.what_is_the_user_doing")}</Label>
<TabToggle
disabled={isReadOnly}
id="userAction"

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