mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-29 18:20:07 -05:00
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:
+158
-80
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-1
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+2
-3
@@ -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")}
|
||||
|
||||
+35
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+28
-31
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-4
@@ -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>,
|
||||
|
||||
+195
-147
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+24
-11
@@ -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>")
|
||||
|
||||
+59
-56
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+37
-9
@@ -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);
|
||||
});
|
||||
|
||||
+77
-80
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-11
@@ -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();
|
||||
|
||||
+51
-41
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+19
-10
@@ -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", () => {
|
||||
|
||||
+72
-59
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+24
-15
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+25
-35
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+17
-4
@@ -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")
|
||||
|
||||
+65
-63
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+46
-5
@@ -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();
|
||||
|
||||
+47
-65
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+40
-4
@@ -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();
|
||||
});
|
||||
|
||||
+194
-200
@@ -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>
|
||||
);
|
||||
|
||||
+135
-132
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
@@ -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 && (
|
||||
|
||||
+28
-10
@@ -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(),
|
||||
|
||||
+35
-16
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-18
@@ -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>
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}}>
|
||||
|
||||
+31
-4
@@ -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();
|
||||
});
|
||||
|
||||
+29
-49
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+35
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+30
-42
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
-3
@@ -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'", () => {
|
||||
|
||||
+20
-20
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user