Compare commits

..

9 Commits

Author SHA1 Message Date
Jakob Schott
75d170bce5 chore: removed unnecessary text bullet point from dialog (#6180) 2025-07-07 15:29:44 +00:00
Piyush Gupta
16caae6dd6 chore: upgrade to storybook 9 (#6141) 2025-07-07 09:55:22 +00:00
Kshitij Sharma
a490600479 fix: ensure date question respects question color styling (#6155)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-07 00:43:21 -07:00
Suraj
be28641722 fix: changing project name doesn't update in the sidebar and project selector (#6130)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-07 05:36:17 +00:00
Dhruwang Jariwala
4fdea3221b feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-04 14:17:40 +00:00
Jakob Schott
fef30c54b2 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>
2025-07-04 11:44:36 +00:00
Johannes
75362eac7a chore: updating contribution docs (#6157) 2025-07-04 04:56:14 -07:00
Dhruwang Jariwala
6e3b224944 chore: sunset card shadow color (#6152) 2025-07-04 10:44:32 +00:00
Aditya
ef1be219b4 fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-04 08:47:49 +00:00
192 changed files with 9206 additions and 6584 deletions

View File

@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path:
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
## 🚀 Aspiring Features

View File

@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
- Note: For the time being, we can only facilitate code contributions as an exception.
## All Thanks To Our Contributors

View File

@@ -14,10 +14,9 @@ const config: StorybookConfig = {
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -1,5 +1,21 @@
import type { Preview } from "@storybook/react";
import type { Preview } from "@storybook/react-vite";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
// Create a Storybook-specific Tolgee decorator
const withTolgee = (Story: any) => {
const tolgee = TolgeeBase().init({
tagNewKeys: [], // No branch tagging in Storybook
});
return React.createElement(
TolgeeProvider,
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -10,6 +26,7 @@ const preview: Preview = {
},
},
},
decorators: [withTolgee],
};
export default preview;

View File

@@ -14,23 +14,19 @@
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"vite": "6.3.5"
"storybook": "9.0.15",
"vite": "6.3.5",
"@storybook/addon-docs": "9.0.15"
}
}

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/blocks";
import { Meta } from "@storybook/addon-docs/blocks";
import Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png";

View File

@@ -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
);
});
});

View File

@@ -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()}
/>
</>
);

View File

@@ -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")}

View File

@@ -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();
});
});

View File

@@ -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>
</>
);
};

View File

@@ -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>,

View File

@@ -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>
);
};

View File

@@ -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>")

View File

@@ -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>
);
};

View File

@@ -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);
});

View File

@@ -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>
);
};

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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", () => {

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -1,13 +1,15 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -33,6 +35,9 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
@@ -51,6 +56,9 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />

View File

@@ -1,18 +1,23 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});

View File

@@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props),
}));
const mockPanelInfoViewComponent = vi.fn();
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
// Mock getSurveyUrl to return a predictable URL
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
@@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
};
});
@@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => {
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
user: mockUser,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: true,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
@@ -171,9 +171,6 @@ describe("ShareEmbedSurvey", () => {
</div>
)
);
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
@@ -205,43 +202,15 @@ describe("ShareEmbedSurvey", () => {
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
});
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
// Panel view currently just shows a title, no component is rendered
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -267,7 +236,7 @@ describe("ShareEmbedSurvey", () => {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.length).toBe(4);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
@@ -297,24 +266,21 @@ describe("ShareEmbedSurvey", () => {
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
// Panel view currently just shows a title
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
});
test("renders correct label for link tab based on singleUse survey property", () => {

View File

@@ -12,15 +12,16 @@ import {
LinkIcon,
MailIcon,
SmartphoneIcon,
UserIcon,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
@@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps {
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareEmbedSurvey = ({
@@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({
modalView,
setOpen,
user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
const environmentId = survey.environmentId;
@@ -52,6 +59,7 @@ export const ShareEmbedSurvey = ({
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
@@ -60,8 +68,8 @@ export const ShareEmbedSurvey = ({
[t, isSingleUseLinkSurvey, survey.type]
);
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
@@ -80,7 +88,7 @@ export const ShareEmbedSurvey = ({
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
setActiveId(tabs[4].id);
}
}, [survey.type, tabs]);
@@ -93,7 +101,7 @@ export const ShareEmbedSurvey = ({
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
setOpen(open);
if (!open) {
setShowView("start");
@@ -101,10 +109,6 @@ export const ShareEmbedSurvey = ({
router.refresh();
};
const handleInitialPageButton = () => {
setShowView("start");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
@@ -166,22 +170,28 @@ export const ShareEmbedSurvey = ({
</div>
</div>
) : showView === "embed" ? (
<EmbedView
handleInitialPageButton={handleInitialPageButton}
tabs={survey.type === "link" ? tabs : [tabs[3]]}
disableBack={false}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
<EmbedView
tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null}
</DialogContent>
</Dialog>

View File

@@ -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")

View File

@@ -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>
);
};

View File

@@ -15,6 +15,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -25,6 +26,9 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
interface ModalState {
@@ -41,6 +45,9 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
@@ -175,6 +182,9 @@ export const SurveyAnalysisCTA = ({
setOpen={setOpen}
user={user}
modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
))}
<SuccessMessage environment={environment} survey={survey} />

View File

@@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
@@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
}));
const mockTabs = [
@@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs,
activeId: "email",
setActiveId: vi.fn(),
@@ -67,7 +97,9 @@ const defaultProps = {
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("EmbedView", () => {
@@ -76,11 +108,6 @@ describe("EmbedView", () => {
vi.clearAllMocks();
});
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent

View File

@@ -2,33 +2,32 @@
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { TSegment } from "@formbricks/types/segment";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab";
import { PersonalLinksTab } from "./personal-links-tab";
interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string;
disableBack: boolean;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const EmbedView = ({
handleInitialPageButton,
tabs,
disableBack,
activeId,
setActiveId,
environmentId,
@@ -38,18 +37,45 @@ export const EmbedView = ({
publicDomain,
setSurveyUrl,
locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: EmbedViewProps) => {
const { t } = useTranslate();
const renderActiveTab = () => {
switch (activeId) {
case "email":
return <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return (
<div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4">
{survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
@@ -75,21 +101,7 @@ export const EmbedView = ({
)}
<div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
{renderActiveTab()}
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button

View File

@@ -1,108 +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 { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});

View File

@@ -1,98 +0,0 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,519 @@
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PersonalLinksTab } from "./personal-links-tab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
generatePersonalLinksAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-loading={loading}
className={className}
{...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/date-picker", () => ({
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
<div data-testid="date-picker">
<input
data-testid="date-input"
type="date"
value={date ? date.toISOString().split("T")[0] : ""}
onChange={(e) => {
const newDate = e.target.value ? new Date(e.target.value) : null;
updateSurveyDate(newDate);
}}
/>
<button data-testid="clear-date" onClick={() => onClearDate()}>
Clear
</button>
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/select", () => {
let globalOnValueChange: ((value: string) => void) | null = null;
return {
Select: ({ children, value, onValueChange, disabled }: any) => {
globalOnValueChange = onValueChange;
return (
<div data-testid="select" data-disabled={disabled} data-value={value}>
<div data-testid="select-current-value">{value || "Select option"}</div>
{children}
</div>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div
data-testid="select-item"
data-value={value}
onClick={() => {
if (globalOnValueChange) {
globalOnValueChange(value);
}
}}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock icons
vi.mock("lucide-react", () => ({
DownloadIcon: () => <div data-testid="download-icon" />,
KeyIcon: () => <div data-testid="key-icon" />,
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href, target, rel }: any) => (
<a data-testid="link" href={href} target={target} rel={rel}>
{children}
</a>
),
}));
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
const mockToast = vi.mocked(toast);
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
// Mock segments data
const mockSegments = [
{
id: "segment1",
title: "Public Segment 1",
isPrivate: false,
description: "Test segment 1",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment2",
title: "Public Segment 2",
isPrivate: false,
description: "Test segment 2",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment3",
title: "Private Segment",
isPrivate: true,
description: "Test private segment",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
];
const defaultProps = {
environmentId: "test-env-id",
surveyId: "test-survey-id",
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
// Helper function to trigger select change
const selectOption = (value: string) => {
const selectItems = screen.getAllByTestId("select-item");
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
if (targetItem) {
fireEvent.click(targetItem);
}
};
describe("PersonalLinksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
render(<PersonalLinksTab {...defaultProps} />);
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
test("renders info alert with correct content", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
);
});
test("filters out private segments and shows only public segments", () => {
render(<PersonalLinksTab {...defaultProps} />);
const selectItems = screen.getAllByTestId("select-item");
expect(selectItems).toHaveLength(2); // Only public segments
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
});
test("shows no segments message when no public segments available", () => {
const propsWithPrivateSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segment
};
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
test("enables button when segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
// Initially disabled
expect(screen.getByTestId("button")).toBeDisabled();
// Select a segment
selectOption("segment1");
// Should now be enabled
expect(screen.getByTestId("button")).not.toBeDisabled();
});
test("handles date selection correctly", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const testDate = "2024-12-31";
fireEvent.change(dateInput, { target: { value: testDate } });
expect(dateInput).toHaveValue(testDate);
});
test("clears date when clear button is clicked", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const clearButton = screen.getByTestId("clear-date");
// Set a date first
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
// Clear the date
fireEvent.click(clearButton);
expect(dateInput).toHaveValue("");
});
test("sets minimum date to tomorrow", () => {
render(<PersonalLinksTab {...defaultProps} />);
const minDateElement = screen.getByTestId("min-date");
// Should have some ISO date string for a future date
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
test("successfully generates and downloads links", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 5,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify action was called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("generates links with expiry date when date is selected", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 3,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date (10 days from now)
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 9-10 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
});
test("handles error response from generatePersonalLinksAction", async () => {
const mockErrorResult = {
serverError: "Test error message",
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Wait for the action to be called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Wait for error handling
await waitFor(() => {
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
duration: 5000,
id: "generating-links",
});
});
});
test("shows generating state when triggered", async () => {
// Mock a promise that resolves quickly
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("button is disabled when no segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("button is disabled when no public segments are available", () => {
const propsWithNoPublicSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segments
};
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("handles empty segments array", () => {
const propsWithEmptySegments = {
...defaultProps,
segments: [],
};
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});
test("calculates expiration days correctly for different dates", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "test.csv",
count: 1,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date to 5 days from now
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 4-5 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
});
});

View File

@@ -0,0 +1,231 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
updateSurveyDate,
}: {
date: Date | null;
updateSurveyDate: (date: Date | null) => void;
}) => {
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const handleDateUpdate = (date: Date) => {
updateSurveyDate(date);
};
return (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
};
export const PersonalLinksTab = ({
environmentId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
// Utility function for file downloads
const downloadFile = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateLinks = async () => {
if (!selectedSegment || isGenerating) return;
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage, {
duration: 5000,
id: "generating-links",
});
}
setIsGenerating(false);
};
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
);
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
</div>
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
);
};

View File

@@ -101,7 +101,7 @@ const mockProject = {
highlightBorderColor: null,
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
questionColor: { light: "#FFFFFF", dark: "#000000" },
inputColor: { light: "#FFFFFF", dark: "#000000" },
inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
@@ -123,7 +123,7 @@ const mockComputedStyling = {
inputBorderColor: "#000000",
cardBackgroundColor: "#FFFFFF",
cardBorderColor: "#EEEEEE",
cardShadowColor: "#AAAAAA",
highlightBorderColor: null,
thankYouCardIconColor: "#007BFF",
thankYouCardIconBgColor: "#DDDDDD",

View File

@@ -2,10 +2,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -36,6 +38,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) {
throw new Error(t("common.user_not_found"));
}
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -54,6 +58,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />

View File

@@ -1,7 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getDisplay } from "@/lib/display/service";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
@@ -98,14 +97,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
return responses.badRequestResponse("Invalid file upload response");
}
// check display
if (responseInputData.displayId) {
const display = await getDisplay(responseInputData.displayId);
if (!display) {
return responses.notFoundResponse("Display", responseInputData.displayId, true);
}
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -2,7 +2,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getDisplay } from "@/lib/display/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
@@ -105,14 +104,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// check display
if (responseInputData.displayId) {
const display = await getDisplay(responseInputData.displayId);
if (!display) {
return responses.notFoundResponse("Display", responseInputData.displayId, true);
}
}
let response: TResponse;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -62,19 +62,3 @@ export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
throw error;
}
};
export const getDisplay = reactCache(async (displayId: string): Promise<{ id: string } | null> => {
validateInputs([displayId, ZId]);
try {
const display = await prisma.display.findUnique({
where: { id: displayId },
select: { id: true },
});
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});

View File

@@ -8,7 +8,6 @@ export const COLOR_DEFAULTS = {
inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc",
cardShadowColor: "#000000",
highlightBorderColor: "#64748b",
} as const;
@@ -32,9 +31,6 @@ export const defaultStyling: TProjectStyling = {
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
cardShadowColor: {
light: COLOR_DEFAULTS.cardShadowColor,
},
isLogoHidden: false,
highlightBorderColor: undefined,
isDarkModeEnabled: false,

View File

@@ -1,5 +1,8 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags";
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
@@ -110,7 +113,7 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
const result = await createTag("env1", "New Tag");
expect(result).toEqual(mockTag);
expect(result).toEqual({ ok: true, data: mockTag });
expect(prisma.tag.create).toHaveBeenCalledWith({
data: {
name: "New Tag",
@@ -118,5 +121,30 @@ describe("Tag Service", () => {
},
});
});
test("should handle duplicate tag name error", async () => {
// const duplicateError = new Error("Unique constraint failed");
// (duplicateError as any).code = "P2002";
const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "4.0.0",
});
vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError);
const result = await createTag("env1", "Duplicate Tag");
expect(result).toEqual({
ok: false,
error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS },
});
});
test("should handle general database errors", async () => {
const generalError = new Error("Database connection failed");
vi.mocked(prisma.tag.create).mockRejectedValue(generalError);
const result = await createTag("env1", "New Tag");
expect(result).toStrictEqual({
ok: false,
error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR },
});
});
});
});

View File

@@ -1,7 +1,11 @@
import "server-only";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -42,7 +46,10 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
}
});
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
export const createTag = async (
environmentId: string,
name: string
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
validateInputs([environmentId, ZId], [name, ZString]);
try {
@@ -53,8 +60,19 @@ export const createTag = async (environmentId: string, name: string): Promise<TT
},
});
return tag;
return ok(tag);
} catch (error) {
throw error;
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
code: TagError.TAG_NAME_ALREADY_EXISTS,
message: "Tag with this name already exists",
});
}
}
return err({
code: TagError.UNEXPECTED_ERROR,
message: error.message,
});
}
};

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
"upgrade_successful": "Upgrade erfolgreich"
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
},
"common": {
"accepted": "Akzeptiert",
"account": "Konto",
@@ -135,7 +139,6 @@
"app_survey": "App-Umfrage",
"apply_filters": "Filter anwenden",
"are_you_sure": "Bist Du sicher?",
"are_you_sure_this_action_cannot_be_undone": "Bist Du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"attributes": "Attribute",
"avatar": "Avatar",
"back": "Zurück",
@@ -204,7 +207,6 @@
"formbricks_version": "Formbricks Version",
"full_name": "Name",
"gathering_responses": "Antworten sammeln",
"general": "Allgemein",
"go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen",
"hidden": "Versteckt",
@@ -313,6 +315,7 @@
"question_id": "Frage-ID",
"questions": "Fragen",
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
@@ -374,7 +377,6 @@
"switch_to": "Wechseln zu {environment}",
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
"table_settings": "Tabelleinstellungen",
"tags": "Tags",
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
@@ -412,7 +414,6 @@
"website_survey": "Website-Umfrage",
"weekly_summary": "Wöchentliche Zusammenfassung",
"welcome_card": "Willkommenskarte",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorised_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion auszuführen.",
@@ -597,6 +598,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 +635,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",
@@ -700,7 +703,6 @@
"select_a_database": "Datenbank auswählen",
"select_a_field_to_map": "Wähle ein Feld zum Zuordnen aus",
"select_a_survey_question": "Wähle eine Umfragefrage aus",
"sync_responses_with_a_notion_database": "Antworten mit einer Datenbank in Notion synchronisieren",
"update_connection": "Notion erneut verbinden",
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
},
@@ -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:",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes",
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
"caution_explanation_all_data_as_download": "Alle Daten, einschließlich früherer Antworten, stehen als Download zur Verfügung.",
"caution_explanation_intro": "Wir verstehen, dass du vielleicht noch Änderungen vornehmen möchtest. Hier erfährst du, was passiert, wenn du das tust:",
"caution_explanation_new_responses_separated": "Neue Antworten werden separat gesammelt.",
"caution_explanation_only_new_responses_in_summary": "Nur neue Antworten erscheinen in der Umfragezusammenfassung.",
"caution_explanation_responses_are_safe": "Vorhandene Antworten bleiben sicher.",
"caution_recommendation": "Das Bearbeiten deiner Umfrage kann zu Dateninkonsistenzen in der Umfragezusammenfassung führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_explanation_new_responses_separated": "Antworten vor der Änderung werden möglicherweise nicht oder nur teilweise in der Umfragezusammenfassung berücksichtigt.",
"caution_explanation_only_new_responses_in_summary": "Alle Daten, einschließlich früherer Antworten, bleiben auf der Umfrageübersichtsseite als Download verfügbar.",
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
"changes_saved": "Änderungen gespeichert.",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
@@ -1708,6 +1709,7 @@
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
"create_single_use_links": "Single-Use Links erstellen",
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
"custom_range": "Benutzerdefinierter Bereich...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "Auf Website einbetten",
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
"embed_survey": "Umfrage einbetten",
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
"expiry_date_optional": "Ablaufdatum (optional)",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
"generate_and_download_links": "Links generieren und herunterladen",
"generate_personal_links_description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu. Eine CSV-Datei Ihrer persönlichen Links inklusive relevanter Kontaktinformationen wird automatisch heruntergeladen.",
"generate_personal_links_title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
"generating_links": "Links werden generiert",
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49",
"hide_embed_code": "Einbettungscode ausblenden",
"how_to_create_a_panel": "Wie man ein Panel erstellt",
"how_to_create_a_panel_step_1": "Schritt 1: Erstelle ein Konto bei Prolific",
"how_to_create_a_panel_step_1_description": "Erstelle ein Konto bei Prolific und bestätige deine E-Mail-Adresse.",
"how_to_create_a_panel_step_2": "Schritt 2: Eine Studie erstellen",
"how_to_create_a_panel_step_2_description": "Bei Prolific erstellst Du eine neue Studie, bei der Du dein bevorzugtes Publikum basierend auf Hunderten von Merkmalen auswählen kannst.",
"how_to_create_a_panel_step_3": "Schritt 3: Verbinde deine Umfrage",
"how_to_create_a_panel_step_3_description": "Richte in deiner Formbricks-Umfrage versteckte Felder ein, um nachzuverfolgen, welcher Teilnehmer welche Antwort gegeben hat.",
"how_to_create_a_panel_step_4": "Schritt 4: Starte deine Studie",
"how_to_create_a_panel_step_4_description": "Sobald alles eingerichtet ist, kannst Du deine Studie starten. Innerhalb weniger Stunden wirst Du die ersten Antworten erhalten.",
"impressions": "Eindrücke",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"includes_all": "Beinhaltet alles",
@@ -1757,12 +1757,18 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
"mobile_app": "Mobile App",
"no_responses_found": "Keine Antworten gefunden",
"no_segments_available": "Keine Segmente verfügbar",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"personal_links": "Persönliche Links",
"personal_links_upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
"personal_links_upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"personal_links_work_with_segments": "Persönliche Links funktionieren mit Segmenten.",
"publish_to_web": "Im Web veröffentlichen",
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"results_are_public": "Ergebnisse sind öffentlich",
"select_segment": "Segment auswählen",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"send_preview": "Vorschau senden",
@@ -1805,13 +1812,7 @@
"view_site": "Seite ansehen",
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8",
"web_app": "Web-App",
"what_is_a_panel": "Was ist ein Panel?",
"what_is_a_panel_answer": "Ein Panel ist eine Gruppe von Teilnehmern, die basierend auf Merkmalen wie Alter, Beruf, Geschlecht usw. ausgewählt werden.",
"what_is_prolific": "Was ist Prolific?",
"what_is_prolific_answer": "Wir arbeiten mit Prolific zusammen, um dir Zugang zu einem Pool von über 200.000 geprüften Teilnehmern zu geben.",
"whats_next": "Was kommt als Nächstes?",
"when_do_i_need_it": "Wann brauche ich das?",
"when_do_i_need_it_answer": "Wenn Du keinen Zugang zu genügend Leuten hast, die deiner Zielgruppe entsprechen, macht es Sinn, für ein Panel zu bezahlen.",
"you_can_do_a_lot_more_with_links_surveys": "Mit Links-Umfragen kannst Du viel mehr machen \uD83D\uDCA1",
"your_survey_is_public": "Deine Umfrage ist öffentlich",
"youre_not_plugged_in_yet": "Du bist noch nicht verbunden!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
"upgrade_successful": "Upgrade successful"
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
},
"common": {
"accepted": "Accepted",
"account": "Account",
@@ -135,7 +139,6 @@
"app_survey": "App Survey",
"apply_filters": "Apply filters",
"are_you_sure": "Are you sure?",
"are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.",
"attributes": "Attributes",
"avatar": "Avatar",
"back": "Back",
@@ -204,7 +207,6 @@
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
"gathering_responses": "Gathering responses",
"general": "General",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"hidden": "Hidden",
@@ -313,6 +315,7 @@
"question_id": "Question ID",
"questions": "Questions",
"read_docs": "Read Docs",
"recipients": "Recipients",
"remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
@@ -374,7 +377,6 @@
"switch_to": "Switch to {environment}",
"table_items_deleted_successfully": "{type}s deleted successfully",
"table_settings": "Table settings",
"tags": "Tags",
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
@@ -412,7 +414,6 @@
"website_survey": "Website Survey",
"weekly_summary": "Weekly summary",
"welcome_card": "Welcome card",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.",
@@ -597,6 +598,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 +635,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",
@@ -700,7 +703,6 @@
"select_a_database": "Select Database",
"select_a_field_to_map": "Select a field to map",
"select_a_survey_question": "Select a survey question",
"sync_responses_with_a_notion_database": "Sync responses with a Notion Database",
"update_connection": "Reconnect Notion",
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
},
@@ -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:",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
"card_border_color": "Card border color",
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
"caution_edit_published_survey": "Edit a published survey?",
"caution_explanation_all_data_as_download": "All data, including past responses are available as download.",
"caution_explanation_intro": "We understand you might still want to make changes. Heres what happens if you do: ",
"caution_explanation_new_responses_separated": "New responses are collected separately.",
"caution_explanation_only_new_responses_in_summary": "Only new responses appear in the survey summary.",
"caution_explanation_responses_are_safe": "Existing responses remain safe.",
"caution_recommendation": "Editing your survey may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_explanation_new_responses_separated": "Responses before the change may not or only partially be included in the survey summary.",
"caution_explanation_only_new_responses_in_summary": "All data, including past responses, remain available as download on the survey summary page.",
"caution_explanation_responses_are_safe": "Older and newer responses get mixed which can lead to misleading data summaries.",
"caution_recommendation": "This may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_text": "Changes will lead to inconsistencies",
"centered_modal_overlay_color": "Centered modal overlay color",
"change_anyway": "Change anyway",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
"changes_saved": "Changes saved.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
@@ -1708,6 +1709,7 @@
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results",
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
"create_single_use_links": "Create single-use links",
"create_single_use_links_description": "Accept only one submission per link. Here is how.",
"custom_range": "Custom range...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "Embed on website",
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
"embed_survey": "Embed survey",
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
"expiry_date_optional": "Expiry date (optional)",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
"filtered_responses_excel": "Filtered responses (Excel)",
"formbricks_email_survey_preview": "Formbricks Email Survey Preview",
"generate_and_download_links": "Generate & download links",
"generate_personal_links_description": "Generate personal links for a segment and match survey responses to each contact. A CSV of you personal links incl. relevant contact information will be downloaded automatically.",
"generate_personal_links_title": "Maximize insights with personal survey links",
"generating_links": "Generating links",
"generating_links_toast": "Generating links, download will start soon…",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"hide_embed_code": "Hide embed code",
"how_to_create_a_panel": "How to create a panel",
"how_to_create_a_panel_step_1": "Step 1: Create an account with Prolific",
"how_to_create_a_panel_step_1_description": "Create an account with Prolific and verify your email address.",
"how_to_create_a_panel_step_2": "Step 2: Create a study",
"how_to_create_a_panel_step_2_description": "At Prolific, you create a new study where you can pick your preferred audience based on hundreds of characteristics.",
"how_to_create_a_panel_step_3": "Step 3: Connect your survey",
"how_to_create_a_panel_step_3_description": "Set up hidden fields in your Formbricks survey to track which participant provided which answer.",
"how_to_create_a_panel_step_4": "Step 4: Launch your study",
"how_to_create_a_panel_step_4_description": "Once everything is setup, you can launch your study. Within a few hours youll receive the first responses.",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"includes_all": "Includes all",
@@ -1757,12 +1757,18 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"link_to_public_results_copied": "Link to public results copied",
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
"mobile_app": "Mobile app",
"no_responses_found": "No responses found",
"no_segments_available": "No segments available",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"personal_links": "Personal links",
"personal_links_upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
"personal_links_upgrade_prompt_title": "Use personal links with a higher plan",
"personal_links_work_with_segments": "Personal links work with segments.",
"publish_to_web": "Publish to web",
"publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"results_are_public": "Results are public",
"select_segment": "Select segment",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"send_preview": "Send preview",
@@ -1805,13 +1812,7 @@
"view_site": "View site",
"waiting_for_response": "Waiting for a response \uD83E\uDDD8",
"web_app": "Web app",
"what_is_a_panel": "What is a panel?",
"what_is_a_panel_answer": "A panel is a group of participants selected based on characteristics such as age, profession, gender, etc.",
"what_is_prolific": "What is Prolific?",
"what_is_prolific_answer": "We're partnering with Prolific to give you access to a pool of over 200.000 vetted participants.",
"whats_next": "What's next?",
"when_do_i_need_it": "When do I need it?",
"when_do_i_need_it_answer": "If you dont have access to enough people who match your target audience, it makes sense to pay for access to a panel.",
"you_can_do_a_lot_more_with_links_surveys": "You can do a lot more with links surveys \uD83D\uDCA1",
"your_survey_is_public": "Your survey is public",
"youre_not_plugged_in_yet": "You're not plugged in yet!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
"upgrade_successful": "Mise à niveau réussie"
},
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": {
"accepted": "Accepté",
"account": "Compte",
@@ -135,7 +139,6 @@
"app_survey": "Sondage d'application",
"apply_filters": "Appliquer des filtres",
"are_you_sure": "Es-tu sûr ?",
"are_you_sure_this_action_cannot_be_undone": "Êtes-vous sûr ? Cette action ne peut pas être annulée.",
"attributes": "Attributs",
"avatar": "Avatar",
"back": "Retour",
@@ -204,7 +207,6 @@
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
"gathering_responses": "Collecte des réponses",
"general": "Général",
"go_back": "Retourner",
"go_to_dashboard": "Aller au tableau de bord",
"hidden": "Caché",
@@ -313,6 +315,7 @@
"question_id": "ID de la question",
"questions": "Questions",
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
@@ -374,7 +377,6 @@
"switch_to": "Passer à {environment}",
"table_items_deleted_successfully": "{type}s supprimés avec succès",
"table_settings": "Réglages de table",
"tags": "Étiquettes",
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
@@ -412,7 +414,6 @@
"website_survey": "Sondage de site web",
"weekly_summary": "Résumé hebdomadaire",
"welcome_card": "Carte de bienvenue",
"yes": "Oui",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorised_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
@@ -597,6 +598,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 +635,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",
@@ -700,7 +703,6 @@
"select_a_database": "Sélectionner la base de données",
"select_a_field_to_map": "Sélectionnez un champ à mapper",
"select_a_survey_question": "Sélectionnez une question d'enquête",
"sync_responses_with_a_notion_database": "Synchroniser les réponses avec une base de données Notion",
"update_connection": "Reconnecter Notion",
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
},
@@ -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 :",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte",
"card_border_color": "Couleur de la bordure de la carte",
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte",
"casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier",
"caution_edit_published_survey": "Modifier un sondage publié ?",
"caution_explanation_all_data_as_download": "Toutes les données, y compris les réponses passées, sont disponibles en téléchargement.",
"caution_explanation_intro": "Nous comprenons que vous souhaitiez encore apporter des modifications. Voici ce qui se passe si vous le faites : ",
"caution_explanation_new_responses_separated": "Les nouvelles réponses sont collectées séparément.",
"caution_explanation_only_new_responses_in_summary": "Seules les nouvelles réponses apparaissent dans le résumé de l'enquête.",
"caution_explanation_responses_are_safe": "Les réponses existantes restent en sécurité.",
"caution_recommendation": "Modifier votre enquête peut entraîner des incohérences dans le résumé de l'enquête. Nous vous recommandons de dupliquer l'enquête à la place.",
"caution_explanation_new_responses_separated": "Les réponses avant le changement peuvent ne pas être ou ne faire partie que partiellement du résumé de l'enquête.",
"caution_explanation_only_new_responses_in_summary": "Toutes les données, y compris les réponses passées, restent disponibles en téléchargement sur la page de résumé de l'enquête.",
"caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.",
"caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
"changes_saved": "Modifications enregistrées.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
@@ -1708,6 +1709,7 @@
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
"create_single_use_links": "Créer des liens à usage unique",
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
"custom_range": "Plage personnalisée...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "Incorporer sur le site web",
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
"embed_survey": "Intégrer l'enquête",
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
"expiry_date_optional": "Date d'expiration (facultatif)",
"failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
"filtered_responses_excel": "Réponses filtrées (Excel)",
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
"generate_and_download_links": "Générer et télécharger les liens",
"generate_personal_links_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact. Un fichier CSV de vos liens personnels incluant les informations de contact pertinentes sera téléchargé automatiquement.",
"generate_personal_links_title": "Maximisez les insights avec des liens d'enquête personnels",
"generating_links": "Génération de liens",
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49",
"hide_embed_code": "Cacher le code d'intégration",
"how_to_create_a_panel": "Comment créer un panneau",
"how_to_create_a_panel_step_1": "Étape 1 : Créez un compte avec Prolific",
"how_to_create_a_panel_step_1_description": "Créez un compte avec Prolific et vérifiez votre adresse e-mail.",
"how_to_create_a_panel_step_2": "Étape 2 : Créer une étude",
"how_to_create_a_panel_step_2_description": "Chez Prolific, vous créez une nouvelle étude où vous pouvez choisir votre audience préférée en fonction de centaines de caractéristiques.",
"how_to_create_a_panel_step_3": "Étape 3 : Connectez votre enquête",
"how_to_create_a_panel_step_3_description": "Configurez des champs cachés dans votre enquête Formbricks pour suivre quel participant a fourni quelle réponse.",
"how_to_create_a_panel_step_4": "Étape 4 : Lancez votre étude",
"how_to_create_a_panel_step_4_description": "Une fois que tout est configuré, vous pouvez lancer votre étude. Dans quelques heures, vous recevrez les premières réponses.",
"impressions": "Impressions",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"includes_all": "Comprend tous",
@@ -1757,12 +1757,18 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"link_to_public_results_copied": "Lien vers les résultats publics copié",
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
"mobile_app": "Application mobile",
"no_responses_found": "Aucune réponse trouvée",
"no_segments_available": "Aucun segment disponible",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"personal_links": "Liens personnels",
"personal_links_upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"personal_links_upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"personal_links_work_with_segments": "Les liens personnels fonctionnent avec les segments.",
"publish_to_web": "Publier sur le web",
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"results_are_public": "Les résultats sont publics.",
"select_segment": "Sélectionner le segment",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"send_preview": "Envoyer un aperçu",
@@ -1805,13 +1812,7 @@
"view_site": "Voir le site",
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8",
"web_app": "application web",
"what_is_a_panel": "Qu'est-ce qu'un panneau ?",
"what_is_a_panel_answer": "Un panel est un groupe de participants sélectionnés en fonction de caractéristiques telles que l'âge, la profession, le sexe, etc.",
"what_is_prolific": "Qu'est-ce que Prolific ?",
"what_is_prolific_answer": "Nous nous associons à Prolific pour vous donner accès à un panel de plus de 200 000 participants vérifiés.",
"whats_next": "Qu'est-ce qui vient ensuite ?",
"when_do_i_need_it": "Quand en ai-je besoin ?",
"when_do_i_need_it_answer": "Si vous n'avez pas accès à suffisamment de personnes correspondant à votre public cible, il est logique de payer pour accéder à un panel.",
"you_can_do_a_lot_more_with_links_surveys": "Vous pouvez faire beaucoup plus avec des sondages par lien \uD83D\uDCA1",
"your_survey_is_public": "Votre enquête est publique.",
"youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida"
},
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
},
"common": {
"accepted": "Aceito",
"account": "conta",
@@ -135,7 +139,6 @@
"app_survey": "Pesquisa de App",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem certeza? Essa ação não pode ser desfeita.",
"attributes": "atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -204,7 +207,6 @@
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
"gathering_responses": "Recolhendo respostas",
"general": "geral",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"hidden": "Escondido",
@@ -313,6 +315,7 @@
"question_id": "ID da Pergunta",
"questions": "Perguntas",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
@@ -374,7 +377,6 @@
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
"table_settings": "Arrumação da mesa",
"tags": "etiquetas",
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
@@ -412,7 +414,6 @@
"website_survey": "Pesquisa de Site",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Você não tem autorização para fazer isso.",
@@ -597,6 +598,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 +635,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",
@@ -700,7 +703,6 @@
"select_a_database": "Selecionar Banco de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
"sync_responses_with_a_notion_database": "Sincronizar respostas com um banco de dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
},
@@ -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:",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que você ainda pode querer fazer alterações. Aqui está o que acontece se você fizer:",
"caution_explanation_new_responses_separated": "Novas respostas são coletadas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo da pesquisa.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar sua pesquisa pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_explanation_new_responses_separated": "Respostas antes da mudança podem não ser ou apenas parcialmente incluídas no resumo da pesquisa.",
"caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo da pesquisa.",
"caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
"changes_saved": "Mudanças salvas.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1550,7 +1551,7 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "redondeza",
"roundness": "Circularidade",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "linhas",
"save_and_close": "Salvar e Fechar",
@@ -1586,7 +1587,7 @@
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
"straight": "hétero",
"straight": "Alinhado",
"style_the_question_texts_descriptions_and_input_fields": "Estilize os textos das perguntas, descrições e campos de entrada.",
"style_the_survey_card": "Estilize o cartão da pesquisa.",
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
@@ -1708,6 +1709,7 @@
"congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
"create_single_use_links": "Crie links de uso único",
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
"embed_survey": "Incorporar pesquisa",
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
"generate_and_download_links": "Gerar & baixar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato. Um CSV dos seus links pessoais com as informações de contato relevantes será baixado automaticamente.",
"generate_personal_links_title": "Maximize insights com links de pesquisa personalizados",
"generating_links": "Gerando links",
"generating_links_toast": "Gerando links, o download começará em breve…",
"go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49",
"hide_embed_code": "Esconder código de incorporação",
"how_to_create_a_panel": "Como criar um painel",
"how_to_create_a_panel_step_1": "Passo 1: Crie uma conta no Prolific",
"how_to_create_a_panel_step_1_description": "Cria uma conta no Prolific e verifica teu e-mail.",
"how_to_create_a_panel_step_2": "Passo 2: Crie um estudo",
"how_to_create_a_panel_step_2_description": "Na Prolific, você cria um novo estudo onde pode escolher seu público preferido com base em centenas de características.",
"how_to_create_a_panel_step_3": "Passo 3: Conecte sua pesquisa",
"how_to_create_a_panel_step_3_description": "Configure campos ocultos na sua pesquisa do Formbricks para rastrear qual participante forneceu qual resposta.",
"how_to_create_a_panel_step_4": "Passo 4: Lançar seu estudo",
"how_to_create_a_panel_step_4_description": "Depois que tudo estiver configurado, você pode iniciar seu estudo. Em algumas horas, você vai receber as primeiras respostas.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"includes_all": "Inclui tudo",
@@ -1757,12 +1757,18 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"link_to_public_results_copied": "Link pros resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
"mobile_app": "app de celular",
"no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Nenhum segmento disponível",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
"personal_links_upgrade_prompt_title": "Use links pessoais com um plano superior",
"personal_links_work_with_segments": "Links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar prévia",
@@ -1805,13 +1812,7 @@
"view_site": "Ver site",
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8",
"web_app": "aplicativo web",
"what_is_a_panel": "O que é um painel?",
"what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, gênero, etc.",
"what_is_prolific": "O que é Prolific?",
"what_is_prolific_answer": "Estamos fazendo parceria com a Prolific pra te dar acesso a um grupo de mais de 200.000 participantes verificados.",
"whats_next": "E agora?",
"when_do_i_need_it": "Quando eu preciso disso?",
"when_do_i_need_it_answer": "Se você não tem acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar por acesso a um painel.",
"you_can_do_a_lot_more_with_links_surveys": "Você pode fazer muito mais com pesquisas de links \uD83D\uDCA1",
"your_survey_is_public": "Sua pesquisa é pública",
"youre_not_plugged_in_yet": "Você ainda não tá conectado!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida"
},
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
},
"common": {
"accepted": "Aceite",
"account": "Conta",
@@ -135,7 +139,6 @@
"app_survey": "Inquérito da Aplicação",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem a certeza? Esta ação não pode ser desfeita.",
"attributes": "Atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -204,7 +207,6 @@
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
"gathering_responses": "A recolher respostas",
"general": "Geral",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"hidden": "Oculto",
@@ -313,6 +315,7 @@
"question_id": "ID da pergunta",
"questions": "Perguntas",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
@@ -374,7 +377,6 @@
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s eliminados com sucesso",
"table_settings": "Configurações da tabela",
"tags": "Etiquetas",
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
@@ -412,7 +414,6 @@
"website_survey": "Inquérito do Website",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Não está autorizado para realizar esta ação.",
@@ -597,6 +598,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 +635,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",
@@ -700,7 +703,6 @@
"select_a_database": "Selecionar Base de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Selecione uma pergunta do inquérito",
"sync_responses_with_a_notion_database": "Sincronizar respostas com uma Base de Dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
},
@@ -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:",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar um inquérito publicado?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que ainda pode querer fazer alterações. Eis o que acontece se o fizer:",
"caution_explanation_new_responses_separated": "As novas respostas são recolhidas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo do inquérito.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar o seu inquérito pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_explanation_new_responses_separated": "Respostas antes da alteração podem não estar incluídas ou estar apenas parcialmente incluídas no resumo do inquérito.",
"caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo do inquérito.",
"caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
"changes_saved": "Alterações guardadas.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1708,6 +1709,7 @@
"congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
"create_single_use_links": "Criar links de uso único",
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
"embed_survey": "Incorporar inquérito",
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
"generate_and_download_links": "Gerar & descarregar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto. Um ficheiro CSV dos seus links pessoais, incluindo a informação relevante de contacto, será descarregado automaticamente.",
"generate_personal_links_title": "Maximize os insights com links pessoais de inquérito",
"generating_links": "Gerando links",
"generating_links_toast": "A gerar links, o download começará em breve…",
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49",
"hide_embed_code": "Ocultar código de incorporação",
"how_to_create_a_panel": "Como criar um painel",
"how_to_create_a_panel_step_1": "Passo 1: Crie uma conta com a Prolific",
"how_to_create_a_panel_step_1_description": "Crie uma conta no Prolific e verifique o seu endereço de email.",
"how_to_create_a_panel_step_2": "Passo 2: Criar um estudo",
"how_to_create_a_panel_step_2_description": "No Prolific, cria um novo estudo onde pode escolher o seu público preferido com base em centenas de características.",
"how_to_create_a_panel_step_3": "Passo 3: Conecte o seu inquérito",
"how_to_create_a_panel_step_3_description": "Configure campos ocultos no seu inquérito Formbricks para rastrear qual participante forneceu qual resposta.",
"how_to_create_a_panel_step_4": "Passo 4: Lançar o seu estudo",
"how_to_create_a_panel_step_4_description": "Depois de tudo configurado, pode lançar o seu estudo. Dentro de algumas horas, receberá as primeiras respostas.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
"includes_all": "Inclui tudo",
@@ -1757,12 +1757,18 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"link_to_public_results_copied": "Link para resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
"mobile_app": "Aplicação móvel",
"no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Sem segmentos disponíveis",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"personal_links_upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"personal_links_work_with_segments": "Os links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar pré-visualização",
@@ -1805,13 +1812,7 @@
"view_site": "Ver site",
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8",
"web_app": "Aplicação web",
"what_is_a_panel": "O que é um painel?",
"what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, género, etc.",
"what_is_prolific": "O que é o Prolific?",
"what_is_prolific_answer": "Estamos a colaborar com a Prolific para lhe dar acesso a um grupo de mais de 200.000 participantes verificados.",
"whats_next": "O que se segue?",
"when_do_i_need_it": "Quando é que preciso disso?",
"when_do_i_need_it_answer": "Se não tiver acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar pelo acesso a um painel.",
"you_can_do_a_lot_more_with_links_surveys": "Pode fazer muito mais com inquéritos de links \uD83D\uDCA1",
"your_survey_is_public": "O seu inquérito é público",
"youre_not_plugged_in_yet": "Ainda não está ligado!"

View File

@@ -108,6 +108,10 @@
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
"upgrade_successful": "升級成功"
},
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
},
"common": {
"accepted": "已接受",
"account": "帳戶",
@@ -135,7 +139,6 @@
"app_survey": "應用程式問卷",
"apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?",
"are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。",
"attributes": "屬性",
"avatar": "頭像",
"back": "返回",
@@ -204,7 +207,6 @@
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
"gathering_responses": "收集回應中",
"general": "一般",
"go_back": "返回",
"go_to_dashboard": "前往儀表板",
"hidden": "隱藏",
@@ -313,6 +315,7 @@
"question_id": "問題 ID",
"questions": "問題",
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
@@ -374,7 +377,6 @@
"switch_to": "切換至 '{'environment'}'",
"table_items_deleted_successfully": "'{'type'}' 已成功刪除",
"table_settings": "表格設定",
"tags": "標籤",
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
@@ -412,7 +414,6 @@
"website_survey": "網站問卷",
"weekly_summary": "每週摘要",
"welcome_card": "歡迎卡片",
"yes": "是",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。",
@@ -597,6 +598,7 @@
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
@@ -633,6 +635,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": "連結新表格",
@@ -700,7 +703,6 @@
"select_a_database": "選取資料庫",
"select_a_field_to_map": "選取要對應的欄位",
"select_a_survey_question": "選取問卷問題",
"sync_responses_with_a_notion_database": "與 Notion 資料庫同步回應",
"update_connection": "重新連線 Notion",
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
@@ -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": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1296,17 +1299,16 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
"caution_edit_published_survey": "編輯已發佈的調查?",
"caution_explanation_all_data_as_download": "所有數據,包括過去的回應,都可以下載。",
"caution_explanation_intro": "我們了解您可能仍然想要進行更改。如果您這樣做,將會發生以下情況:",
"caution_explanation_new_responses_separated": "新回應會分開收集。",
"caution_explanation_only_new_responses_in_summary": "只有新的回應會出現在調查摘要。",
"caution_explanation_responses_are_safe": "現有回應仍然安全。",
"caution_recommendation": "編輯您的調查可能導致調查摘要中的數據不一致。我們建議複製調查。",
"caution_explanation_new_responses_separated": "更改前的回應可能未被納入或只有部分包含在調查摘要中。",
"caution_explanation_only_new_responses_in_summary": "所有數據,包括過去的回應,仍可在調查摘要頁面下載。",
"caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。",
"caution_recommendation": "可能導致調查摘要中的數據不一致。我們建議複製這個調查。",
"caution_text": "變更會導致不一致",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"change_anyway": "仍然變更",
@@ -1321,7 +1323,6 @@
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
"change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
"changes_saved": "已儲存變更。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
@@ -1708,6 +1709,7 @@
"congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結",
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
"create_single_use_links": "建立單次使用連結",
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
"custom_range": "自訂範圍...",
@@ -1726,23 +1728,21 @@
"embed_on_website": "嵌入網站",
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
"embed_survey": "嵌入問卷",
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
"expiry_date_optional": "到期日 (可選)",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
"filtered_responses_excel": "篩選回應 (Excel)",
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
"generate_and_download_links": "生成 & 下載 連結",
"generate_personal_links_description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。含 有 相關 聯絡信息 的 個人 連結 CSV 會 自動 下載。",
"generate_personal_links_title": "透過個人化調查連結最大化洞察",
"generating_links": "生成 連結",
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
"go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49",
"hide_embed_code": "隱藏嵌入程式碼",
"how_to_create_a_panel": "如何建立小組",
"how_to_create_a_panel_step_1": "步驟 1使用 Prolific 建立帳戶",
"how_to_create_a_panel_step_1_description": "使用 Prolific 建立帳戶並驗證您的電子郵件地址。",
"how_to_create_a_panel_step_2": "步驟 2建立研究",
"how_to_create_a_panel_step_2_description": "在 Prolific 中,您建立一個新的研究,您可以在其中根據數百個特徵選擇您偏好的受眾。",
"how_to_create_a_panel_step_3": "步驟 3連線您的問卷",
"how_to_create_a_panel_step_3_description": "在您的 Formbricks 問卷中設定隱藏欄位,以追蹤哪個參與者提供了哪個答案。",
"how_to_create_a_panel_step_4": "步驟 4啟動您的研究",
"how_to_create_a_panel_step_4_description": "設定完成後,您可以啟動您的研究。在幾個小時內,您就會收到第一個回應。",
"impressions": "曝光數",
"impressions_tooltip": "問卷已檢視的次數。",
"includes_all": "包含全部",
@@ -1757,12 +1757,18 @@
"last_quarter": "上一季",
"last_year": "去年",
"link_to_public_results_copied": "已複製公開結果的連結",
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式",
"no_responses_found": "找不到回應",
"no_segments_available": "沒有可用的區段",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"personal_links": "個人 連結",
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
"publish_to_web": "發布至網站",
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
@@ -1771,6 +1777,7 @@
"quickstart_web_apps": "快速入門Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"results_are_public": "結果是公開的",
"select_segment": "選擇 區隔",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"send_preview": "發送預覽",
@@ -1805,13 +1812,7 @@
"view_site": "檢視網站",
"waiting_for_response": "正在等待回應 \uD83E\uDDD8",
"web_app": "Web 應用程式",
"what_is_a_panel": "什麼是小組?",
"what_is_a_panel_answer": "小組是一組根據年齡、職業、性別等特徵選取的參與者。",
"what_is_prolific": "什麼是 Prolific",
"what_is_prolific_answer": "我們正在與 Prolific 合作,為您提供超過 200,000 名經過審核的參與者。",
"whats_next": "下一步是什麼?",
"when_do_i_need_it": "我何時需要它?",
"when_do_i_need_it_answer": "如果您無法存取足夠的符合您目標受眾的人員,則可以付費存取小組。",
"you_can_do_a_lot_more_with_links_surveys": "使用連結問卷,您可以做更多事情 \uD83D\uDCA1",
"your_survey_is_public": "您的問卷是公開的",
"youre_not_plugged_in_yet": "您尚未插入任何內容!"

View File

@@ -13,6 +13,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl}
readOnly
/>
) : (
//loading state

View File

@@ -50,8 +50,14 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result;
if (result.ok) {
ctx.auditLoggingCtx.tagId = result.data.id;
ctx.auditLoggingCtx.newObject = result.data;
} else {
ctx.auditLoggingCtx.newObject = null;
}
return result;
}
)

View File

@@ -1,3 +1,5 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
@@ -150,7 +152,9 @@ describe("ResponseTagsWrapper", () => {
});
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
vi.mocked(createTagAction).mockResolvedValueOnce({
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
} as any);
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render(
<ResponseTagsWrapper
@@ -176,7 +180,10 @@ describe("ResponseTagsWrapper", () => {
test("handles createTagAction failure and shows toast error", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
data: {
ok: false,
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
},
} as any);
render(
<ResponseTagsWrapper

View File

@@ -1,6 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Button } from "@/modules/ui/components/button";
import { Tag } from "@/modules/ui/components/tag";
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
@@ -58,6 +59,57 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
return () => clearTimeout(timeoutId);
}, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data?.ok) {
const tag = createTagResponse.data.data;
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
}
return;
}
if (createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
setSearchValue("");
return;
}
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
};
return (
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
{!isReadOnly && (
@@ -93,46 +145,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={async (tagName) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data) {
setTagsState((prevTags) => [
...prevTags,
{
tagId: createTagResponse.data?.id ?? "",
tagName: createTagResponse.data?.name ?? "",
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: createTagResponse.data.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
}
} else {
const errorMessage = getFormattedErrorMessage(createTagResponse);
if (errorMessage.includes("Unique constraint failed on the fields")) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setSearchValue("");
}
}}
createTag={handleCreateTag}
addTag={(tagId) => {
setTagsState((prevTags) => [
...prevTags,

View File

@@ -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" && (

View File

@@ -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")}
/>
</>
);

View File

@@ -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>
</>
);
};

View File

@@ -10,6 +10,12 @@ vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
TokenExpiredError: class TokenExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = "TokenExpiredError";
}
},
},
}));
@@ -145,8 +151,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
});
@@ -166,8 +172,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
});

View File

@@ -3,6 +3,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
@@ -73,11 +74,22 @@ export const verifyContactSurveyToken = (
surveyId,
});
} catch (error) {
console.error("Error verifying contact survey token:", error);
logger.error("Error verifying contact survey token:", error);
// Check if the error is specifically a JWT expiration error
if (error instanceof jwt.TokenExpiredError) {
return err({
type: "bad_request",
message: "Survey link has expired",
details: [{ field: "token", issue: "token_expired" }],
});
}
// Handle other JWT errors or general validation errors
return err({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
message: "Invalid survey token",
details: [{ field: "token", issue: "invalid_token" }],
});
}
};

View File

@@ -6,10 +6,31 @@ import {
buildContactWhereClause,
createContactsFromCSV,
deleteContact,
generatePersonalLinks,
getContact,
getContacts,
getContactsInSegment,
} from "./contacts";
// Mock additional dependencies for the new functions
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
getSegment: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
segmentFilterToPrismaQuery: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-survey-link", () => ({
getContactSurveyLink: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
@@ -31,11 +52,18 @@ vi.mock("@formbricks/database", () => ({
},
},
}));
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
vi.mock("@/lib/constants", () => ({
ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
}));
const environmentId = "env1";
const contactId = "contact1";
const userId = "user1";
const environmentId = "cm123456789012345678901237";
const contactId = "cm123456789012345678901238";
const userId = "cm123456789012345678901239";
const mockContact: Contact & {
attributes: { value: string; attributeKey: { key: string; name: string } }[];
} = {
@@ -159,7 +187,7 @@ describe("createContactsFromCSV", () => {
.mockResolvedValueOnce([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1",
@@ -183,12 +211,12 @@ describe("createContactsFromCSV", () => {
test("skips duplicate contact with 'skip' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
@@ -206,12 +234,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
environmentId,
@@ -239,12 +267,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
] as any);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
@@ -326,8 +354,333 @@ describe("buildContactWhereClause", () => {
});
test("returns where clause without search", () => {
const environmentId = "env-1";
const environmentId = "cm123456789012345678901240";
const result = buildContactWhereClause(environmentId);
expect(result).toEqual({ environmentId });
});
});
describe("getContactsInSegment", () => {
const mockSegmentId = "cm123456789012345678901235";
const mockEnvironmentId = "cm123456789012345678901236";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contacts when segment and filters are valid", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any;
const mockWhereClause = {
environmentId: mockEnvironmentId,
attributes: {
some: {
attributeKey: { key: "email" },
value: "test@example.com",
},
},
};
// Mock the dependencies
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: mockWhereClause },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
]);
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: mockWhereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["userId", "firstName", "lastName", "email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
});
test("returns null when segment is not found", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when segment filter to prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: false,
error: { type: "bad_request" },
} as any);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("handles errors gracefully", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull(); // The function catches errors and returns null
});
});
describe("generatePersonalLinks", () => {
const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
const mockExpirationDays = 7;
beforeEach(() => {
vi.clearAllMocks();
});
test("returns null when getContactsInSegment fails", async () => {
// Mock getSegment to fail which will cause getContactsInSegment to return null
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toBeNull();
});
test("returns empty array when no contacts in segment", async () => {
// Mock successful segment retrieval but no contacts
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toEqual([]);
});
test("generates personal links for contacts successfully", async () => {
// Mock all the dependencies that getContactsInSegment needs
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any);
// Mock getContactSurveyLink to return successful results
vi.mocked(getContactSurveyLink)
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link1",
})
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link2",
});
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
surveyUrl: "https://example.com/survey/link1",
expirationDays: mockExpirationDays,
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
surveyUrl: "https://example.com/survey/link2",
expirationDays: mockExpirationDays,
},
]);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
});
});

View File

@@ -1,9 +1,13 @@
import "server-only";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
@@ -15,6 +19,76 @@ import {
} from "../types/contact";
import { transformPrismaContact } from "./utils";
export const getContactsInSegment = reactCache(async (segmentId: string) => {
try {
const segment = await getSegment(segmentId);
if (!segment) {
return null;
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return null;
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const requiredAttributes = ["userId", "firstName", "lastName", "email"];
const contacts = await prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: requiredAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
attributes,
};
});
return contactsWithAttributes;
} catch (error) {
logger.error(error, "Failed to get contacts in segment");
return null;
}
});
const selectContact = {
id: true,
createdAt: true,
@@ -418,3 +492,37 @@ export const createContactsFromCSV = async (
throw error;
}
};
export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
const contactsResult = await getContactsInSegment(segmentId);
if (!contactsResult) {
return null;
}
// Generate survey links for each contact
const contactLinks = contactsResult
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expirationDays,
};
})
.filter(Boolean);
return contactLinks;
};

View File

@@ -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);
});
});

View File

@@ -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>
);
}

View File

@@ -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 () => {

View File

@@ -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>
</>
);
}

View File

@@ -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("");
});
});

View File

@@ -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>
);
};

View File

@@ -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 && (
<>

View File

@@ -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} />);

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -39,7 +39,7 @@ describe("updateProjectBranding", () => {
inputBorderColor: { light: "#cbd5e1" },
cardBackgroundColor: { light: "#ffffff" },
cardBorderColor: { light: "#f8fafc" },
cardShadowColor: { light: "#000000" },
isLogoHidden: false,
isDarkModeEnabled: false,
background: { bg: "#fff", bgType: "color" as const },

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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} />);

View File

@@ -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>
);
};

View File

@@ -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();
});

View File

@@ -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>
);
};

View File

@@ -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 () => {

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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 && (

View File

@@ -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(),

View File

@@ -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>
</>
);
};

View File

@@ -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>

View File

@@ -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);
}}>

View File

@@ -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();
});

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