Compare commits

..

9 Commits

284 changed files with 1246 additions and 6239 deletions

View File

@@ -1,26 +0,0 @@
# Testing Instructions
When generating test files inside the "/app/web" path, follow these rules:
- Use vitest
- Ensure 100% code coverage
- Add as few comments as possible
- The test file should be located in the same folder as the original file
- Use the `test` function instead of `it`
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
If it's a test for a ".tsx" file, follow these extra instructions:
- Add this code inside the "describe" block and before any test:
afterEach(() => {
cleanup();
});
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.

View File

@@ -1,4 +1,9 @@
{
"github.copilot.chat.codeGeneration.instructions": [
{
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
}
],
"javascript.updateImportsOnFileMove.enabled": "always",
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.21 AS base
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
#
## step 1: Prune monorepo

View File

@@ -101,17 +101,17 @@ export const OnboardingSetupInstructions = ({
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate w-full">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>{t("common.or")}</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock>
<Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild>
@@ -125,11 +125,11 @@ export const OnboardingSetupInstructions = ({
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
<p className="mt-6 -mb-1 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock>
</div>

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -80,7 +80,7 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -218,7 +218,7 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow-sm">
{logoUrl && (
<Image
src={logoUrl}

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -36,7 +36,7 @@ export const ActionClassesTable = ({
return (
<>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
{TableHeading}
<div id="actionClassesWrapper" className="flex flex-col">
{actionClasses.length > 0 ? (

View File

@@ -14,9 +14,7 @@ export const ActionClassDataRow = ({
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div>
<div className="h-5 w-5 shrink-0 text-slate-500">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div>

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<>
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} />
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
@@ -22,7 +22,7 @@ const Loading = () => {
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>

View File

@@ -265,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-hidden"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -332,7 +332,7 @@ export const MainNavigation = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
<div
tabIndex={0}
className={cn(

View File

@@ -18,7 +18,7 @@ export const TopControlBar = ({
}: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="shadow-xs z-10">
<div className="z-10 shadow-2xs">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons
environment={environment}

View File

@@ -1,151 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
() => ({
AddIntegrationModal: ({ open, setOpenWithStates }) =>
open ? (
<div data-testid="add-modal">
<button onClick={() => setOpenWithStates(false)}>close</button>
</div>
) : null,
})
);
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
environmentId: "env1",
setIsConnected: vi.fn(),
surveys: [],
airtableArray: [],
locale: "en-US" as const,
};
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
});
test("open add modal", async () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/link_new_table/));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("list integrations and open edit modal", async () => {
const item = {
baseId: "b",
tableId: "t",
surveyId: "s",
surveyName: "S",
tableName: "T",
questions: "Q",
questionIds: ["x"],
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: false,
};
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalled();
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalled();
});
});

View File

@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
{integrationData.length ? (
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{tableHeaders.map((header, idx) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
</div>
))}
</div>
{integrationData.map((data, index) => (
<button
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
setDefaultValues({
base: data.baseId,
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
</div>
))}
</div>
) : (

View File

@@ -1,162 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
locale: "en-US" as const,
} as const;
describe("ManageIntegration (Google Sheets)", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
});
test("click link new sheet", async () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/link_new_sheet/));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("list integrations and open edit", async () => {
const item = {
spreadsheetId: "sid",
spreadsheetName: "SheetName",
surveyId: "s1",
surveyName: "Survey1",
questionIds: ["q1"],
questions: "Q",
createdAt: new Date(),
};
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText("Survey1")).toBeInTheDocument();
await userEvent.click(screen.getByText("Survey1"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
...item,
index: 0,
});
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -36,10 +36,11 @@ export const ManageIntegration = ({
}: ManageIntegrationProps) => {
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
if (googleSheetIntegration?.config.data) {
integrationArray = googleSheetIntegration.config.data;
}
const integrationArray = googleSheetIntegration
? googleSheetIntegration.config.data
? googleSheetIntegration.config.data
: []
: [];
const [isDeleting, setisDeleting] = useState(false);
const handleDeleteIntegration = async () => {
@@ -111,9 +112,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -123,7 +124,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,91 +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 type {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
} from "@formbricks/types/integration/notion";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
environment: {} as any,
locale: "en-US" as const,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
handleNotionAuthorization: vi.fn(),
};
test("shows empty state when no databases", () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [] as TIntegrationNotionConfigData[],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
});
test("renders list and handles clicks", async () => {
const data = [
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
] as unknown as TIntegrationNotionConfigData[];
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
test("update and link new buttons invoke handlers", async () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
});

View File

@@ -39,11 +39,11 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationNotionConfigData[] = [];
if (notionIntegration?.config.data) {
integrationArray = notionIntegration.config.data;
}
const integrationArray = notionIntegration
? notionIntegration.config.data
? notionIntegration.config.data
: []
: [];
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,158 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
refreshChannels: vi.fn(),
handleSlackAuthorization: vi.fn(),
showReconnectButton: false,
locale: "en-US" as const,
};
describe("ManageIntegration (Slack)", () => {
afterEach(() => cleanup());
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
});
test("link channel triggers handlers", async () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/link_channel/));
expect(baseProps.refreshChannels).toHaveBeenCalled();
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("show reconnect button and triggers authorization", async () => {
render(
<ManageIntegration
{...baseProps}
showReconnectButton={true}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "Team" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
});
test("list integrations and open edit", async () => {
const item = {
surveyName: "S",
channelName: "C",
questions: "Q",
createdAt: new Date().toISOString(),
surveyId: "s",
channelId: "c",
} as unknown as TIntegrationSlackConfigData;
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [item], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -6,7 +6,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { T, useTranslate } from "@tolgee/react";
import { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
@@ -42,10 +43,11 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationSlackConfigData[] = [];
if (slackIntegration?.config.data) {
integrationArray = slackIntegration.config.data;
}
const integrationArray = slackIntegration
? slackIntegration.config.data
? slackIntegration.config.data
: []
: [];
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -127,9 +129,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -139,7 +141,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -56,7 +56,7 @@ export const EditAlerts = ({
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<span>{t("environments.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
<HelpCircleIcon className="h-4 w-4 shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -99,7 +99,7 @@ export const EditAlerts = ({
))}
</div>
) : (
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<div className="m-2 flex h-16 items-center justify-center rounded-sm bg-slate-50 text-sm text-slate-500">
<p>{t("common.no_surveys_found")}</p>
</div>
)}

View File

@@ -11,7 +11,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
const { t } = useTranslate();
return (
<div>
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-xs md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?

View File

@@ -105,7 +105,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path

View File

@@ -97,7 +97,7 @@ const Page = async (props) => {
</PageHeader>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
@@ -152,7 +152,7 @@ const Page = async (props) => {
</p>
</div>
</div>
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="p-8">
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
{t("environments.settings.enterprise.enterprise_features")}

View File

@@ -25,7 +25,7 @@ export const SettingsCard = ({
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm",
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-xs",
className
)}
id={title}>

View File

@@ -1,165 +0,0 @@
import type { Cell, Row } from "@tanstack/react-table";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { ResponseTableCell } from "./ResponseTableCell";
const makeCell = (
id: string,
size = 100,
first = false,
last = false,
content = "CellContent"
): Cell<TResponseTableData, unknown> =>
({
column: {
id,
getSize: () => size,
getIsFirstColumn: () => first,
getIsLastColumn: () => last,
getStart: () => 0,
columnDef: { cell: () => content },
},
id,
getContext: () => ({}),
}) as unknown as Cell<TResponseTableData, unknown>;
const makeRow = (id: string, selected = false): Row<TResponseTableData> =>
({ id, getIsSelected: () => selected }) as unknown as Row<TResponseTableData>;
describe("ResponseTableCell", () => {
afterEach(() => {
cleanup();
});
test("renders cell content", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(screen.getByText("CellContent")).toBeDefined();
});
test("calls setSelectedResponseId on cell click when not select column", async () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r1" } as TResponse]}
/>
);
await userEvent.click(screen.getByText("CellContent"));
expect(setSel).toHaveBeenCalledWith("r1");
});
test("does not call setSelectedResponseId on select column click", async () => {
const cell = makeCell("select");
const row = makeRow("r1");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r1" } as TResponse]}
/>
);
await userEvent.click(screen.getByText("CellContent"));
expect(setSel).not.toHaveBeenCalled();
});
test("renders maximize icon for createdAt column and handles click", async () => {
const cell = makeCell("createdAt", 120, false, false);
const row = makeRow("r2");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r2" } as TResponse]}
/>
);
const btn = screen.getByRole("button", { name: /expand response/i });
expect(btn).toBeDefined();
await userEvent.click(btn);
expect(setSel).toHaveBeenCalledWith("r2");
});
test("does not apply selected style when row.getIsSelected() is false", () => {
const cell = makeCell("col1");
const row = makeRow("r1", false);
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(container.firstChild).not.toHaveClass("bg-slate-100");
});
test("applies selected style when row.getIsSelected() is true", () => {
const cell = makeCell("col1");
const row = makeRow("r1", true);
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(container.firstChild).toHaveClass("bg-slate-100");
});
test("renders collapsed height class when isExpanded is false", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
const inner = container.querySelector("div > div");
expect(inner).toHaveClass("h-10");
});
test("renders expanded height class when isExpanded is true", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={true}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
const inner = container.querySelector("div > div");
expect(inner).toHaveClass("h-full");
});
});

View File

@@ -35,13 +35,11 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<button
type="button"
aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
<div
className="hidden shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</button>
</div>
);
return (

View File

@@ -20,7 +20,7 @@ interface AddressSummaryProps {
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -16,7 +16,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
survey={survey}
questionSummary={questionSummary}
@@ -40,7 +40,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
</>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">CTR</p>

View File

@@ -16,9 +16,9 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">

View File

@@ -1,80 +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 {
TSurvey,
TSurveyConsentQuestion,
TSurveyQuestionSummaryConsent,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { ConsentSummary } from "./ConsentSummary";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
() => ({
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
})
);
describe("ConsentSummary", () => {
afterEach(() => {
cleanup();
});
const mockSetFilter = vi.fn();
const questionSummary = {
question: {
id: "q1",
headline: { en: "Headline" },
type: TSurveyQuestionTypeEnum.Consent,
} as unknown as TSurveyConsentQuestion,
accepted: { percentage: 60.5, count: 61 },
dismissed: { percentage: 39.5, count: 40 },
} as unknown as TSurveyQuestionSummaryConsent;
const survey = {} as TSurvey;
test("renders accepted and dismissed with correct values", () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
expect(screen.getByText("common.accepted")).toBeInTheDocument();
expect(screen.getByText(/60\.5%/)).toBeInTheDocument();
expect(screen.getByText(/61/)).toBeInTheDocument();
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
expect(screen.getByText(/39\.5%/)).toBeInTheDocument();
expect(screen.getByText(/40/)).toBeInTheDocument();
});
test("calls setFilter with correct args on accepted click", async () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
await userEvent.click(screen.getByText("common.accepted"));
expect(mockSetFilter).toHaveBeenCalledWith(
"q1",
{ en: "Headline" },
TSurveyQuestionTypeEnum.Consent,
"is",
"common.accepted"
);
});
test("calls setFilter with correct args on dismissed click", async () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
await userEvent.click(screen.getByText("common.dismissed"));
expect(mockSetFilter).toHaveBeenCalledWith(
"q1",
{ en: "Headline" },
TSurveyQuestionTypeEnum.Consent,
"is",
"common.dismissed"
);
});
test("renders singular and plural response labels", () => {
const oneAndTwo = {
...questionSummary,
accepted: { percentage: questionSummary.accepted.percentage, count: 1 },
dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 },
};
render(<ConsentSummary questionSummary={oneAndTwo} survey={survey} setFilter={mockSetFilter} />);
expect(screen.getByText(/1 common\.response/)).toBeInTheDocument();
expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument();
});
});

View File

@@ -39,13 +39,13 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
},
];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<button
className="group w-full cursor-pointer"
<div
className="group cursor-pointer"
key={summaryItem.title}
onClick={() =>
setFilter(
@@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
</div>
</button>
</div>
);
})}
</div>

View File

@@ -25,7 +25,7 @@ export const ContactInfoSummary = ({
}: ContactInfoSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -46,7 +46,7 @@ export const DateQuestionSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -36,7 +36,7 @@ export const FileUploadSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -27,7 +27,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
);
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>

View File

@@ -1,47 +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 { MatrixQuestionSummary } from "./MatrixQuestionSummary";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
() => ({
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
})
);
describe("MatrixQuestionSummary", () => {
afterEach(() => {
cleanup();
});
const survey = { id: "s1" } as any;
const questionSummary = {
question: { id: "q1", headline: "Q Head", type: "matrix" },
data: [
{
rowLabel: "Row1",
totalResponsesForRow: 10,
columnPercentages: [
{ column: "Yes", percentage: 50 },
{ column: "No", percentage: 50 },
],
},
],
} as any;
test("renders headers and buttons, click triggers setFilter", async () => {
const setFilter = vi.fn();
render(<MatrixQuestionSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
// column headers
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("No")).toBeInTheDocument();
// row label
expect(screen.getByText("Row1")).toBeInTheDocument();
// buttons
const btn = screen.getAllByRole("button", { name: /50/ });
await userEvent.click(btn[0]);
expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes");
});
});

View File

@@ -45,7 +45,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
: [];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="overflow-x-auto p-6">
{/* Summary Table */}
@@ -81,9 +81,9 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
percentage,
questionSummary.data[rowIndex].totalResponsesForRow
)}>
<button
<div
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded-sm p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
setFilter(
questionSummary.question.id,
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)
}>
{percentage}
</button>
</div>
</TooltipRenderer>
</td>
))}

View File

@@ -1,275 +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 { MultipleChoiceSummary } from "./MultipleChoiceSummary";
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
cleanup();
});
const baseSurvey = { id: "s1" } as any;
const envId = "env";
test("renders header and choice button", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q",
headline: "H",
type: "multipleChoiceSingle",
choices: [{ id: "c", label: { default: "C" } }],
},
choices: { C: { value: "C", count: 1, percentage: 100, others: [] } },
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByTestId("header")).toBeDefined();
const btn = screen.getByText("1 - C");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q",
"H",
"multipleChoiceSingle",
"environments.surveys.summary.includes_either",
["C"]
);
});
test("renders others and load more for link", async () => {
const setFilter = vi.fn();
const others = Array.from({ length: 12 }, (_, i) => ({
value: `O${i}`,
contact: { id: `id${i}` },
contactAttributes: {},
}));
const q = {
question: {
id: "q2",
headline: "H2",
type: "multipleChoiceMulti",
choices: [{ id: "c2", label: { default: "X" } }],
},
choices: { X: { value: "X", count: 0, percentage: 0, others } },
type: "multipleChoiceMulti",
selectionCount: 5,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined();
expect(screen.getAllByText(/^O/)).toHaveLength(10);
await userEvent.click(screen.getByText("common.load_more"));
expect(screen.getAllByText(/^O/)).toHaveLength(12);
});
test("renders others with avatar for app", () => {
const setFilter = vi.fn();
const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }];
const q = {
question: {
id: "q3",
headline: "H3",
type: "multipleChoiceMulti",
choices: [{ id: "c3", label: { default: "L" } }],
},
choices: { L: { value: "L", count: 0, percentage: 0, others } },
type: "multipleChoiceMulti",
selectionCount: 1,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="app"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByTestId("avatar")).toBeDefined();
expect(screen.getByText("Val")).toBeDefined();
});
test("places choice without others before one with others", () => {
const setFilter = vi.fn();
const choices = {
A: { value: "A", count: 0, percentage: 0, others: [] },
B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - A");
expect(btns[1]).toHaveTextContent("1 - B");
});
test("sorts by count when neither has others", () => {
const setFilter = vi.fn();
const choices = {
X: { value: "X", count: 1, percentage: 50, others: [] },
Y: { value: "Y", count: 2, percentage: 50, others: [] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
});
test("places choice with others after one without when reversed inputs", () => {
const setFilter = vi.fn();
const choices = {
C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] },
D: { value: "D", count: 1, percentage: 0, others: [] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - D");
expect(btns[1]).toHaveTextContent("1 - C");
});
test("multi type non-other uses includes_all", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q4",
headline: "H4",
type: "multipleChoiceMulti",
choices: [
{ id: "other", label: { default: "O" } },
{ id: "c4", label: { default: "C4" } },
],
},
choices: {
O: { value: "O", count: 1, percentage: 10, others: [] },
C4: { value: "C4", count: 2, percentage: 20, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const btn = screen.getByText("2 - C4");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q4",
"H4",
"multipleChoiceMulti",
"environments.surveys.summary.includes_all",
["C4"]
);
});
test("multi type other uses includes_either", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q5",
headline: "H5",
type: "multipleChoiceMulti",
choices: [
{ id: "other", label: { default: "O5" } },
{ id: "c5", label: { default: "C5" } },
],
},
choices: {
O5: { value: "O5", count: 1, percentage: 10, others: [] },
C5: { value: "C5", count: 0, percentage: 0, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const btn = screen.getByText("2 - O5");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q5",
"H5",
"multipleChoiceMulti",
"environments.surveys.summary.includes_either",
["O5"]
);
});
});

View File

@@ -7,7 +7,7 @@ import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import Link from "next/link";
import { Fragment, useState } from "react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
@@ -45,15 +45,10 @@ export const MultipleChoiceSummary = ({
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
// sort by count and transform to array
const results = Object.values(questionSummary.choices).sort((a, b) => {
const aHasOthers = (a.others?.length ?? 0) > 0;
const bHasOthers = (b.others?.length ?? 0) > 0;
if (a.others) return 1; // Always put a after b if a has 'others'
if (b.others) return -1; // Always put b after a if b has 'others'
// if one has “others” and the other doesnt, push the one with others to the end
if (aHasOthers && !bHasOthers) return 1;
if (!aHasOthers && bHasOthers) return -1;
// if theyre “tied” on having others, fall back to count
return b.count - a.count;
return b.count - a.count; // Sort by count
});
const handleLoadMore = (e: React.MouseEvent) => {
@@ -70,7 +65,7 @@ export const MultipleChoiceSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -85,41 +80,40 @@ export const MultipleChoiceSummary = ({
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
<div
key={result.value}
className="group cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
@@ -130,9 +124,11 @@ export const MultipleChoiceSummary = ({
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
<div key={idx} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<div
key={idx}
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
@@ -143,6 +139,7 @@ export const MultipleChoiceSummary = ({
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
key={idx}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
@@ -166,7 +163,7 @@ export const MultipleChoiceSummary = ({
)}
</div>
)}
</Fragment>
</div>
))}
</div>
</div>

View File

@@ -1,60 +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 { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
import { NPSSummary } from "./NPSSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
),
HalfCircle: ({ value }: { value: number }) => <div data-testid="half-circle">{value}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
describe("NPSSummary", () => {
afterEach(() => {
cleanup();
});
const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const };
const summary = {
question: baseQuestion,
promoters: { count: 2, percentage: 50 },
passives: { count: 1, percentage: 25 },
detractors: { count: 1, percentage: 25 },
dismissed: { count: 0, percentage: 0 },
score: 25,
} as unknown as TSurveyQuestionSummaryNps;
const survey = {} as any;
test("renders header, groups, ProgressBar and HalfCircle", () => {
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={() => {}} />);
expect(screen.getByTestId("question-summary-header")).toBeDefined();
["promoters", "passives", "detractors", "dismissed"].forEach((g) =>
expect(screen.getByText(g)).toBeDefined()
);
expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined();
expect(screen.getByTestId("half-circle")).toHaveTextContent("25");
});
test.each([
["promoters", "environments.surveys.summary.includes_either", ["9", "10"]],
["passives", "environments.surveys.summary.includes_either", ["7", "8"]],
["detractors", "environments.surveys.summary.is_less_than", "7"],
["dismissed", "common.skipped", undefined],
])("clicking %s calls setFilter correctly", async (group, cmp, vals) => {
const setFilter = vi.fn();
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={setFilter} />);
await userEvent.click(screen.getByText(group));
expect(setFilter).toHaveBeenCalledWith(
baseQuestion.id,
baseQuestion.headline,
baseQuestion.type,
cmp,
vals
);
});
});

View File

@@ -60,14 +60,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
@@ -90,7 +87,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
</div>
))}
</div>

View File

@@ -60,7 +60,7 @@ export const OpenTextSummary = ({
];
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}

View File

@@ -1,91 +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 { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress }: { progress: number }) => (
<div data-testid="progress-bar" data-progress={progress} />
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
// mock next image
vi.mock("next/image", () => ({
__esModule: true,
// eslint-disable-next-line @next/next/no-img-element
default: ({ src }: { src: string }) => <img src={src} alt="" />,
}));
const survey = {} as TSurvey;
describe("PictureChoiceSummary", () => {
afterEach(() => {
cleanup();
});
test("renders choices with formatted percentages and counts", () => {
const choices = [
{ id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 },
{ id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 },
];
const questionSummary = {
choices,
question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true },
selectionCount: 3,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
expect(screen.getAllByRole("button")).toHaveLength(2);
expect(screen.getByText("33.33%")).toBeInTheDocument();
expect(screen.getByText("1 common.selection")).toBeInTheDocument();
expect(screen.getByText("2 common.selections")).toBeInTheDocument();
expect(screen.getAllByTestId("progress-bar")).toHaveLength(2);
});
test("calls setFilter with correct args on click", async () => {
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }];
const questionSummary = {
choices,
question: {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "H1",
allowMulti: true,
},
selectionCount: 10,
} as any;
const setFilter = vi.fn();
const user = userEvent.setup();
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
await user.click(screen.getByRole("button"));
expect(setFilter).toHaveBeenCalledWith(
"q1",
"H1",
TSurveyQuestionTypeEnum.PictureSelection,
"environments.surveys.summary.includes_all",
["environments.surveys.edit.picture_idx"]
);
});
test("hides additionalInfo when allowMulti is false", () => {
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }];
const questionSummary = {
choices,
question: {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "H2",
allowMulti: false,
},
selectionCount: 5,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
});

View File

@@ -30,7 +30,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -45,8 +45,8 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
<div
className="cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
@@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
</div>
))}
</div>
</div>

View File

@@ -1,7 +1,6 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
@@ -24,15 +23,31 @@ export const QuestionSummaryHeader = ({
}: HeadProps) => {
const { t } = useTranslate();
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
// formats the text to highlight specific parts of the text with slashes
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
"@",
["text-lg"]
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
)}
</h3>
</div>

View File

@@ -17,16 +17,16 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
});
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<div className="rounded-sm bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}

View File

@@ -1,87 +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 { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
import { RatingSummary } from "./RatingSummary";
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
describe("RatingSummary", () => {
afterEach(() => {
cleanup();
});
test("renders overall average and choices", () => {
const questionSummary = {
question: {
id: "q1",
scale: "star",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 3.1415,
choices: [
{ rating: 1, percentage: 50, count: 2 },
{ rating: 2, percentage: 50, count: 3 },
],
dismissed: { count: 0 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined();
expect(screen.getAllByRole("button")).toHaveLength(2);
});
test("clicking a choice calls setFilter with correct args", async () => {
const questionSummary = {
question: {
id: "q1",
scale: "number",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 2,
choices: [{ rating: 3, percentage: 100, count: 1 }],
dismissed: { count: 0 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
await userEvent.click(screen.getByRole("button"));
expect(setFilter).toHaveBeenCalledWith(
"q1",
"Headline",
"rating",
"environments.surveys.summary.is_equal_to",
"3"
);
});
test("renders dismissed section when dismissed count > 0", () => {
const questionSummary = {
question: {
id: "q1",
scale: "smiley",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 4,
choices: [],
dismissed: { count: 1 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
expect(screen.getByText("common.dismissed")).toBeDefined();
expect(screen.getByText("1 common.response")).toBeDefined();
});
});

View File

@@ -37,7 +37,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
}, [questionSummary]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -52,8 +52,8 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
<div
className="cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
@@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (

View File

@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return (
<button
onClick={scrollToTop}
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
className={`fixed right-4 bottom-4 z-1 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0"
}`}>

View File

@@ -1,11 +1,11 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TimerIcon } from "lucide-react";
import { JSX } from "react";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
interface SummaryDropOffsProps {
@@ -20,8 +20,26 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
@@ -55,9 +73,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
survey,
true,
"default"
)["default"],
"@",
["text-lg"]
)["default"]
)}
</p>
</div>

View File

@@ -1,135 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SummaryMetadata } from "./SummaryMetadata";
vi.mock("lucide-react", () => ({
ChevronDownIcon: () => <div data-testid="down" />,
ChevronUpIcon: () => <div data-testid="up" />,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children }) => <>{children}</>,
TooltipContent: ({ children }) => <>{children}</>,
}));
const baseSummary = {
completedPercentage: 50,
completedResponses: 2,
displayCount: 3,
dropOffPercentage: 25,
dropOffCount: 1,
startsPercentage: 75,
totalResponses: 4,
ttcAverage: 65000,
};
describe("SummaryMetadata", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons when isLoading=true", () => {
const { container } = render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={baseSummary}
isLoading={true}
/>
);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
});
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
surveySummary={baseSummary}
isLoading={false}
/>
);
};
render(<Wrapper />);
// impressions, starts, completed, drop_offs, ttc
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("50%")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
test("formats time correctly when < 60 seconds", () => {
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={smallSummary}
isLoading={false}
/>
);
expect(screen.getByText("5.00s")).toBeInTheDocument();
});
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
surveySummary={zeroSummary}
isLoading={false}
/>
);
};
render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1);
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
test("renders '-' for displayCount=0", () => {
const dispZero = { ...baseSummary, displayCount: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={dispZero}
isLoading={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
});
test("renders '-' for totalResponses=0", () => {
const totZero = { ...baseSummary, totalResponses: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={totZero}
isLoading={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
});
});

View File

@@ -17,7 +17,7 @@ const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-xs">
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
@@ -71,8 +71,6 @@ export const SummaryMetadata = ({
ttcAverage,
} = surveySummary;
const { t } = useTranslate();
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
return (
<div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
@@ -101,7 +99,9 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<div
onClick={() => setShowDropOffs(!showDropOffs)}
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-xs">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -112,20 +112,20 @@ export const SummaryMetadata = ({
<span className="text-2xl font-bold text-slate-800">
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : dropOffCount === 0 ? (
<span>-</span>
) : (
displayCountValue
dropOffCount
)}
</span>
{!isLoading && (
<button
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
onClick={() => setShowDropOffs(!showDropOffs)}>
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</button>
</span>
)}
</div>
</div>
@@ -135,7 +135,6 @@ export const SummaryMetadata = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}

View File

@@ -99,7 +99,7 @@ export const EmbedView = ({
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm"
? "bg-white text-slate-900 shadow-xs"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}

View File

@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-hidden">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>

View File

@@ -1,88 +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 { QuestionFilterComboBox } from "./QuestionFilterComboBox";
describe("QuestionFilterComboBox", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
filterOptions: ["A", "B"],
filterComboBoxOptions: ["X", "Y"],
filterValue: undefined,
filterComboBoxValue: undefined,
onChangeFilterValue: vi.fn(),
onChangeFilterComboBoxValue: vi.fn(),
handleRemoveMultiSelect: vi.fn(),
disabled: false,
};
test("renders select placeholders", () => {
render(<QuestionFilterComboBox {...defaultProps} />);
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
});
test("calls onChangeFilterValue when selecting filter", async () => {
render(<QuestionFilterComboBox {...defaultProps} />);
await userEvent.click(screen.getAllByRole("button")[0]);
await userEvent.click(screen.getByText("A"));
expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A");
});
test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => {
render(<QuestionFilterComboBox {...defaultProps} filterValue="A" />);
await userEvent.click(screen.getAllByRole("button")[1]);
await userEvent.click(screen.getByText("X"));
expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X");
});
test("multi-select removal works", async () => {
const props = {
...defaultProps,
type: "multipleChoiceMulti",
filterValue: "A",
filterComboBoxValue: ["X", "Y"],
};
render(<QuestionFilterComboBox {...props} />);
const removeButtons = screen.getAllByRole("button", { name: /X/i });
await userEvent.click(removeButtons[0]);
expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]);
});
test("disabled state prevents opening", async () => {
render(<QuestionFilterComboBox {...defaultProps} disabled />);
await userEvent.click(screen.getAllByRole("button")[0]);
expect(screen.queryByText("A")).toBeNull();
});
test("handles object options correctly", async () => {
const obj = { default: "Obj1", en: "ObjEN" };
const props = {
...defaultProps,
type: "multipleChoiceMulti",
filterValue: "A",
filterComboBoxOptions: [obj],
filterComboBoxValue: [],
} as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
await userEvent.click(screen.getByText("Obj1"));
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
});
test("prevent combo-box opening when filterValue is Submitted", async () => {
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
});
test("prevent combo-box opening when filterValue is Skipped", async () => {
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
});
});

View File

@@ -81,39 +81,6 @@ export const QuestionFilterComboBox = ({
.includes(searchQuery.toLowerCase())
);
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
);
const commandItemOnSelect = (o: string) => {
if (!isMultiple) {
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
} else {
onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
);
}
if (!isMultiple) {
setOpen(false);
}
};
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
@@ -163,41 +130,43 @@ export const QuestionFilterComboBox = ({
)}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
!Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
)
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</button>
</div>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<CommandList>
<div className="p-2">
<Input
@@ -214,7 +183,21 @@ export const QuestionFilterComboBox = ({
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
onSelect={() => {
!isMultiple
? onChangeFilterComboBoxValue(
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
)
: onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [
...filterComboBoxValue,
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o,
]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
);
!isMultiple && setOpen(false);
}}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>

View File

@@ -1,55 +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 { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
describe("QuestionsComboBox", () => {
afterEach(() => {
cleanup();
});
const mockOptions: QuestionOptions[] = [
{
header: OptionsType.QUESTIONS,
option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }],
},
{
header: OptionsType.TAGS,
option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }],
},
];
test("renders selected label when closed", () => {
const selected: Partial<QuestionOption> = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" };
render(<QuestionsComboBox options={mockOptions} selected={selected} onChangeValue={() => {}} />);
expect(screen.getByText("Q1")).toBeInTheDocument();
});
test("opens dropdown, selects an option, and closes", async () => {
let currentSelected: Partial<QuestionOption> = {};
const onChange = vi.fn((option) => {
currentSelected = option;
});
const { rerender } = render(
<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />
);
// Open the dropdown
await userEvent.click(screen.getByRole("button"));
expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument();
// Select an option
await userEvent.click(screen.getByText("Q1"));
// Check if onChange was called
expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]);
// Rerender with the new selected value
rerender(<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />);
// Check if the input is gone and the selected item is displayed
expect(screen.queryByPlaceholderText("common.search...")).toBeNull();
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
});
});

View File

@@ -34,7 +34,7 @@ import {
StarIcon,
User,
} from "lucide-react";
import { Fragment, useRef, useState } from "react";
import * as React from "react";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export enum OptionsType {
@@ -141,15 +141,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
};
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
const [open, setOpen] = useState(false);
const [open, setOpen] = React.useState(false);
const { t } = useTranslate();
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
const commandRef = React.useRef(null);
const [inputValue, setInputValue] = React.useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<button
<div
onClick={() => setOpen(true)}
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
{!open && selected.hasOwnProperty("label") && (
@@ -174,14 +174,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
<>
{data?.option.length > 0 && (
<CommandGroup
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
@@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
))}
</CommandGroup>
)}
</Fragment>
</>
))}
</CommandList>
</div>

View File

@@ -199,7 +199,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded-sm border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span>

View File

@@ -86,8 +86,8 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
className="focus:bg-muted cursor-pointer border border-slate-200 outline-hidden hover:border-slate-300">
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">
{t("environments.surveys.summary.share_results")}

View File

@@ -22,10 +22,6 @@ export const GET = async (
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}

View File

@@ -35,7 +35,7 @@ export const GET = async (req: NextRequest) => {
<div tw="flex rounded-2xl absolute -right-2 mt-2">
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
</div>
<div tw="flex rounded-2xl shadow ">
<div tw="flex rounded-2xl shadow-sm ">
<a
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
Begin!

View File

@@ -1,7 +0,0 @@
import {
DELETE,
GET,
PUT,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
export { GET, PUT, DELETE };

View File

@@ -1,3 +0,0 @@
import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route";
export { GET, POST };

View File

@@ -7,6 +7,7 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
decryptAES128: vi.fn(),
}));
// Mock constants

View File

@@ -1,59 +0,0 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
const key = "0".repeat(32);
const plain = "hello";
describe("crypto", () => {
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
});
test("decrypt V2 GCM payload", () => {
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
expect(symmetricDecrypt(payload, key)).toBe(plain);
});
test("decrypt legacy (single-colon) payload", () => {
const iv = randomBytes(16);
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const legacy = `${iv.toString("hex")}:${enc}`;
expect(symmetricDecrypt(legacy, key)).toBe(plain);
});
test("getHash returns a non-empty string", () => {
const h = getHash("abc");
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});

View File

@@ -1,12 +1,11 @@
import crypto from "crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
const ALGORITHM_V1 = "aes256";
const ALGORITHM_V2 = "aes-256-gcm";
const ALGORITHM = "aes256";
const INPUT_ENCODING = "utf8";
const OUTPUT_ENCODING = "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex";
const IV_LENGTH = 16; // AES blocksize
/**
@@ -18,12 +17,15 @@ const IV_LENGTH = 16; // AES blocksize
*/
export const symmetricEncrypt = (text: string, key: string) => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM_V2, _key, iv);
const iv = crypto.randomBytes(IV_LENGTH);
// @ts-ignore -- the package needs to be built
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
ciphered += cipher.final(OUTPUT_ENCODING);
const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING);
return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`;
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
return ciphertext;
};
/**
@@ -31,68 +33,38 @@ export const symmetricEncrypt = (text: string, key: string) => {
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
const symmetricDecryptV1 = (text: string, key: string): string => {
export const symmetricDecrypt = (text: string, key: string) => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const components = text.split(":");
const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext);
const iv_from_ciphertext = Buffer.from(components.shift() || "", OUTPUT_ENCODING);
// @ts-ignore -- the package needs to be built
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
deciphered += decipher.final(INPUT_ENCODING);
return deciphered;
};
/**
*
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
const symmetricDecryptV2 = (text: string, key: string): string => {
// split into [ivHex, encryptedHex, tagHex]
const [ivHex, encryptedHex, tagHex] = text.split(":");
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = Buffer.from(ivHex, OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V2, _key, iv);
decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING));
let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING);
decrypted += decipher.final(INPUT_ENCODING);
// create an aes128 encryption function
export const encryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let encrypted = cipher.update(data, "utf-8", "hex");
encrypted += cipher.final("hex");
return encrypted;
};
// create an aes128 decryption function
export const decryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let decrypted = cipher.update(data, "hex", "utf-8");
decrypted += cipher.final("utf-8");
return decrypted;
};
/**
* Decrypts an encrypted payload, automatically handling multiple encryption versions.
*
* If the payload contains exactly one “:”, it is treated as a legacy V1 format
* and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption
* via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication
* errors or bad formats).
*
* @param payload - The encrypted string to decrypt.
* @param key - The secret key used for decryption.
* @returns The decrypted plaintext.
*/
export function symmetricDecrypt(payload: string, key: string): string {
// If it's clearly V1 (only one “:”), skip straight to V1
if (payload.split(":").length === 2) {
return symmetricDecryptV1(payload, key);
}
// Otherwise try GCM first, then fall back to CBC
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
throw err;
}
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
@@ -101,7 +73,7 @@ export const generateLocalSignedUrl = (
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex");
return { signature, uuid, timestamp };
};

View File

@@ -1,143 +0,0 @@
import {
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { projectCache } from "@/lib/project/cache";
import { getProject } from "@/lib/project/service";
import { surveyCache } from "@/lib/survey/cache";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// stub out project/service and caches
vi.mock("@/lib/project/service", () => ({
getProject: vi.fn(),
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: { revalidate: vi.fn() },
}));
const fakeProject = {
id: mockProjectId,
environments: [{ id: "env1" }, { id: "env2" }],
} as TProject;
const testInputValidation = async (
service: (projectId: string, ...functionArgs: any[]) => Promise<any>,
...args: any[]
): Promise<void> => {
test("throws ValidationError on bad input", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("createLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const result = await createLanguage(mockProjectId, mockLanguageInput);
expect(result).toEqual(mockLanguage);
// projectCache.revalidate called for each env
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(createLanguage, "bad-id", {});
test("throws DatabaseError when PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.create).mockRejectedValue(err);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
});
});
describe("updateLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path updates a language", async () => {
const mockUpdatedLanguageWithSurveyLanguage = {
...mockUpdatedLanguage,
surveyLanguages: [
{
id: "surveyLanguageId",
},
],
};
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguageWithSurveyLanguage);
const result = await updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate);
expect(result).toEqual(mockUpdatedLanguage);
// caches revalidated
expect(projectCache.revalidate).toHaveBeenCalled();
expect(surveyCache.revalidate).toHaveBeenCalled();
});
describe("sad path", () => {
testInputValidation(updateLanguage, "bad-id", mockLanguageId, {});
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.update).mockRejectedValue(err);
await expect(updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
});
});
describe("deleteLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path deletes a language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const result = await deleteLanguage(mockLanguageId, mockProjectId);
expect(result).toEqual(mockLanguage);
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(deleteLanguage, "bad-id", mockProjectId);
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(err);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -0,0 +1,136 @@
import {
mockEnvironmentId,
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
test("it should throw a ValidationError if the inputs are invalid", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("Tests for createLanguage service", () => {
describe("Happy Path", () => {
test("Creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const language = await createLanguage(mockProjectId, mockLanguageInput);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(createLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.create).mockRejectedValue(errToThrow);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.create).mockRejectedValue(new Error(mockErrorMessage));
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(Error);
});
});
});
describe("Tests for updateLanguage Service", () => {
describe("Happy Path", () => {
test("Updates a language", async () => {
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguage);
const language = await updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate);
expect(language).toEqual(mockUpdatedLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(updateLanguage, "123", "123");
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.update).mockRejectedValue(errToThrow);
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
test("Throws a generic Error for other unexpected issues", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.update).mockRejectedValue(new Error(mockErrorMessage));
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
Error
);
});
});
});
describe("Tests for deleteLanguage", () => {
describe("Happy Path", () => {
test("Deletes a Language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const language = await deleteLanguage(mockLanguageId, mockProjectId);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(deleteLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(errToThrow);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.delete).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(Error);
});
});
});

View File

@@ -3,7 +3,6 @@ import { cache } from "@/lib/cache";
import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -338,8 +337,19 @@ export const getMonthlyOrganizationResponseCount = reactCache(
throw new ResourceNotFoundError("Organization", organizationId);
}
// Use the utility function to calculate the start date
const startDate = getBillingPeriodStartDate(organization.billing);
// Determine the start date based on the plan type
let startDate: Date;
if (organization.billing.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!organization.billing.periodStart) {
throw new Error("Organization billing period start is not set");
}
startDate = organization.billing.periodStart;
}
// Get all environment IDs for the organization
const projects = await getProjects(organizationId);

View File

@@ -1,176 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getBillingPeriodStartDate } from "./billing";
describe("getBillingPeriodStartDate", () => {
let originalDate: DateConstructor;
beforeEach(() => {
// Store the original Date constructor
originalDate = global.Date;
});
afterEach(() => {
// Restore the original Date constructor
global.Date = originalDate;
vi.useRealTimers();
});
test("returns first day of month for free plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "free",
periodStart: new Date("2023-01-15"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For free plans, should return first day of current month
expect(result).toEqual(new Date(2023, 2, 1));
});
test("returns correct date for monthly plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-02-10"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For monthly plans, should return periodStart directly
expect(result).toEqual(new Date("2023-02-10"));
});
test("returns current month's subscription day for yearly plans when today is after subscription day", () => {
// Mock the current date to be March 20, 2023
vi.setSystemTime(new Date(2023, 2, 20));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 15, 2023 (same day in current month)
expect(result).toEqual(new Date(2023, 2, 15));
});
test("returns previous month's subscription day for yearly plans when today is before subscription day", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 15, 2023 (same day in previous month)
expect(result).toEqual(new Date(2023, 1, 15));
});
test("handles subscription day that doesn't exist in current month (February edge case)", () => {
// Mock the current date to be February 15, 2023
vi.setSystemTime(new Date(2023, 1, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return January 31, 2023 (previous month's subscription day)
// since today (Feb 15) is less than the subscription day (31st)
expect(result).toEqual(new Date(2023, 0, 31));
});
test("handles subscription day that doesn't exist in previous month (February to March transition)", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 28, 2023 (last day of February)
// since February 2023 doesn't have a 30th day
expect(result).toEqual(new Date(2023, 1, 28));
});
test("handles subscription day that doesn't exist in previous month (leap year)", () => {
// Mock the current date to be March 10, 2024 (leap year)
vi.setSystemTime(new Date(2024, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 29, 2024 (last day of February in leap year)
expect(result).toEqual(new Date(2024, 1, 29));
});
test("handles current month with fewer days than subscription day", () => {
// Mock the current date to be April 25, 2023 (April has 30 days)
vi.setSystemTime(new Date(2023, 3, 25));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 31, 2023 (since today is before April's adjusted subscription day)
expect(result).toEqual(new Date(2023, 2, 31));
});
test("throws error when periodStart is not set for non-free plans", () => {
const organization = {
billing: {
plan: "scale",
periodStart: null,
period: "monthly",
},
};
expect(() => {
getBillingPeriodStartDate(organization.billing);
}).toThrow("billing period start is not set");
});
});

View File

@@ -1,54 +0,0 @@
import { TOrganizationBilling } from "@formbricks/types/organizations";
// Function to calculate billing period start date based on organization plan and billing period
export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date => {
const now = new Date();
if (billing.plan === "free") {
// For free plans, use the first day of the current calendar month
return new Date(now.getFullYear(), now.getMonth(), 1);
} else if (billing.period === "yearly" && billing.periodStart) {
// For yearly plans, use the same day of the month as the original subscription date
const periodStart = new Date(billing.periodStart);
// Use UTC to avoid timezone-offset shifting when parsing ISO date-only strings
const subscriptionDay = periodStart.getUTCDate();
// Helper function to get the last day of a specific month
const getLastDayOfMonth = (year: number, month: number): number => {
// Create a date for the first day of the next month, then subtract one day
return new Date(year, month + 1, 0).getDate();
};
// Calculate the adjusted day for the current month
const lastDayOfCurrentMonth = getLastDayOfMonth(now.getFullYear(), now.getMonth());
const adjustedCurrentMonthDay = Math.min(subscriptionDay, lastDayOfCurrentMonth);
// Calculate the current month's adjusted subscription date
const currentMonthSubscriptionDate = new Date(now.getFullYear(), now.getMonth(), adjustedCurrentMonthDay);
// If today is before the subscription day in the current month (or its adjusted equivalent),
// we should use the previous month's subscription day as our start date
if (now.getDate() < adjustedCurrentMonthDay) {
// Calculate previous month and year
const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
// Calculate the adjusted day for the previous month
const lastDayOfPreviousMonth = getLastDayOfMonth(prevYear, prevMonth);
const adjustedPreviousMonthDay = Math.min(subscriptionDay, lastDayOfPreviousMonth);
// Return the adjusted previous month date
return new Date(prevYear, prevMonth, adjustedPreviousMonthDay);
} else {
return currentMonthSubscriptionDate;
}
} else if (billing.period === "monthly" && billing.periodStart) {
// For monthly plans with a periodStart, use that date
return new Date(billing.periodStart);
} else {
// For other plans, use the periodStart from billing
if (!billing.periodStart) {
throw new Error("billing period start is not set");
}
return new Date(billing.periodStart);
}
};

View File

@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
className="absolute top-12 z-30 w-fit rounded-lg border bg-slate-900 p-1 text-sm text-white"
ref={languageDropdownRef}>
{enabledLanguages.map((surveyLanguage) => (
<button
<div
key={surveyLanguage.language.code}
className="rounded-md p-2 hover:cursor-pointer hover:bg-slate-700"
onClick={() => {
@@ -36,7 +36,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
setShowLanguageSelect(false);
}}>
{getLanguageLabel(surveyLanguage.language.code, locale)}
</button>
</div>
))}
</div>
)}

View File

@@ -88,7 +88,7 @@ export const QuestionSkip = ({
{status === "aborted" && (
<div className="flex">
<div
className="flex w-0.5 flex-grow items-start justify-center"
className="flex w-0.5 grow items-start justify-center"
style={{
background:
"repeating-linear-gradient(to bottom, rgb(148 163 184), rgb(148 163 184) 2px, transparent 2px, transparent 10px)", // adjust the 2px to change dot size and 10px to change space between dots

View File

@@ -100,14 +100,14 @@ export const ResponseNotes = ({
return (
<div
className={clsx(
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
"absolute w-1/4 rounded-lg border border-slate-200 shadow-xs transition-all",
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-0"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
@@ -215,7 +215,7 @@ export const ResponseNotes = ({
<textarea
rows={2}
className={cn(
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm",
"block w-full resize-none rounded-md border border-slate-100 bg-slate-50 p-2 shadow-xs focus:border-slate-500 focus:ring-0 sm:text-sm",
!isTextAreaOpen && "scale-y-0 transition-all duration-1000",
!isTextAreaOpen && "translate-y-8 transition-all duration-300",
isTextAreaOpen && "scale-y-1 transition-all duration-1000",

View File

@@ -116,7 +116,7 @@ export const SingleResponseCard = ({
<div className={clsx("group relative", isOpen && "min-h-[300px]")}>
<div
className={clsx(
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all",
"relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-xs transition-all",
pageType === "response" &&
(isOpen
? "w-3/4"

View File

@@ -47,5 +47,8 @@ describe("isSubmissionTimeMoreThan5Minutes", () => {
const currentTime = new Date();
const recentTime = new Date(currentTime.getTime() - 4 * 60 * 1000); // 4 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false);
const exact5Minutes = new Date(currentTime.getTime() - 5 * 60 * 1000); // exactly 5 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(exact5Minutes)).toBe(false);
});
});

View File

@@ -21,19 +21,11 @@ export type ExtendedSchemas = {
};
// Define a type that returns separate keys for each input type.
// It uses mapped types to create a new type based on the input schemas.
// It checks if each schema is defined and if it is a ZodObject, then infers the type from it.
// It also uses conditional types to ensure that the keys are only included if the schema is defined and valid.
// This allows for more flexibility and type safety when working with the input schemas.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends z.ZodObject<any> ? K : never]: NonNullable<
S[K]
> extends z.ZodObject<any>
? z.infer<NonNullable<S[K]>>
: never;
}
: {};
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
};
export const apiWrapper = async <S extends ExtendedSchemas>({
request,

View File

@@ -260,34 +260,6 @@ const successResponse = ({
);
};
export const createdResponse = ({
data,
meta,
cors = false,
cache = "private, no-store",
}: {
data: Object;
meta?: Record<string, unknown>;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
data,
meta,
} as ApiSuccessResponse,
{
status: 201,
headers,
}
);
};
export const multiStatusResponse = ({
data,
meta,
@@ -326,6 +298,5 @@ export const responses = {
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
createdResponse,
multiStatusResponse,
};

View File

@@ -120,7 +120,7 @@ describe("API Responses", () => {
});
test("include CORS headers when cors is true", () => {
const res = responses.unprocessableEntityResponse({ cors: true, details: [] });
const res = responses.unprocessableEntityResponse({ cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
@@ -182,38 +182,4 @@ describe("API Responses", () => {
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("createdResponse", () => {
test("return a success response with the provided data", async () => {
const data = { foo: "bar" };
const meta = { page: 1 };
const res = responses.createdResponse({ data, meta });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data).toEqual(data);
expect(body.meta).toEqual(meta);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.createdResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("multiStatusResponse", () => {
test("return a 207 response with the provided data", async () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data });
expect(res.status).toBe(207);
const body = await res.json();
expect(body.data).toEqual(data);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
});

View File

@@ -1,183 +0,0 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKey = reactCache(async (contactAttributeKeyId: string) =>
cache(
async (): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const contactAttributeKey = await prisma.contactAttributeKey.findUnique({
where: {
id: contactAttributeKeyId,
},
});
if (!contactAttributeKey) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
return ok(contactAttributeKey);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
},
[`management-getContactAttributeKey-${contactAttributeKeyId}`],
{
tags: [contactAttributeKeyCache.tag.byId(contactAttributeKeyId)],
}
)()
);
export const updateContactAttributeKey = async (
contactAttributeKeyId: string,
contactAttributeKeyInput: TContactAttributeKeyUpdateSchema
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const updatedKey = await prisma.contactAttributeKey.update({
where: {
id: contactAttributeKeyId,
},
data: contactAttributeKeyInput,
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: updatedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: updatedKey.environmentId,
key: updatedKey.key,
});
contactAttributeCache.revalidate({
key: updatedKey.key,
environmentId: updatedKey.environmentId,
});
contactCache.revalidate({
environmentId: updatedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(updatedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};
export const deleteContactAttributeKey = async (
contactAttributeKeyId: string
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const deletedKey = await prisma.contactAttributeKey.delete({
where: {
id: contactAttributeKeyId,
},
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: deletedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: deletedKey.environmentId,
key: deletedKey.key,
});
contactAttributeCache.revalidate({
key: deletedKey.key,
environmentId: deletedKey.environmentId,
});
contactCache.revalidate({
environmentId: deletedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(deletedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};

View File

@@ -1,8 +1,4 @@
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
@@ -13,7 +9,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Gets a contact attribute key from the database.",
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
contactAttributeKeyId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attribute Keys"],
@@ -22,38 +18,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
}),
},
requestBody: {
required: true,
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyUpdateSchema,
},
},
},
responses: {
"200": {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
schema: ZContactAttributeKey,
},
},
},
@@ -67,7 +32,7 @@ export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
@@ -75,7 +40,38 @@ export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
schema: ZContactAttributeKey,
},
},
},
},
};
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
},
},
},
responses: {
"200": {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},

View File

@@ -1,222 +0,0 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "../contact-attribute-key";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
contactAttribute: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
// Mock data
const mockContactAttributeKey: ContactAttributeKey = {
id: "cak123",
key: "email",
name: "Email",
description: "User's email address",
environmentId: "env123",
isUnique: true,
type: "default",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
key: "email",
name: "Email Address",
description: "User's verified email address",
};
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
describe("getContactAttributeKey", () => {
test("returns ok if contact attribute key is found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(mockContactAttributeKey);
const result = await getContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
});
test("returns err if contact attribute key not found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(null);
const result = await getContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns err on Prisma error", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getContactAttributeKey("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "DB error" }],
});
}
});
});
describe("updateContactAttributeKey", () => {
test("returns ok on successful update", async () => {
const updatedKey = { ...mockContactAttributeKey, ...mockUpdateInput };
vi.mocked(prisma.contactAttributeKey.update).mockResolvedValueOnce(updatedKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(updatedKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockUpdateInput.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateContactAttributeKey("cak999", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns conflict error if key already exists", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{ field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' },
],
});
}
});
test("returns internal_server_error if other error occurs", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Unknown error" }],
});
}
});
});
describe("deleteContactAttributeKey", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValueOnce(mockContactAttributeKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockContactAttributeKey.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Delete error" }],
});
}
});
});

View File

@@ -1,131 +0,0 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key";
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { z } from "zod";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
return responses.successResponse(res);
},
});
export const PUT = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
body: ZContactAttributeKeyUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
});
}
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
if (!updatedContactAttributeKey.ok) {
return handleApiError(request, updatedContactAttributeKey.error);
}
return responses.successResponse(updatedContactAttributeKey);
},
});
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
});
}
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
if (!deletedContactAttributeKey.ok) {
return handleApiError(request, deletedContactAttributeKey.error);
}
return responses.successResponse(deletedContactAttributeKey);
},
});

View File

@@ -1,28 +0,0 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZContactAttributeKeyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
param: {
name: "id",
in: "path",
},
});
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
key: true,
}).openapi({
ref: "contactAttributeKeyUpdate",
description: "A contact attribute key to update.",
});
export type TContactAttributeKeyUpdateSchema = z.infer<typeof ZContactAttributeKeyUpdateSchema>;

View File

@@ -1,105 +0,0 @@
import { cache } from "@/lib/cache";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache(
async (environmentIds: string[], params: TGetContactAttributeKeysFilter) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<ContactAttributeKey[]>, ApiErrorResponseV2>> => {
try {
const query = getContactAttributeKeysQuery(environmentIds, params);
const [keys, count] = await prisma.$transaction([
prisma.contactAttributeKey.findMany({
...query,
}),
prisma.contactAttributeKey.count({
where: query.where,
}),
]);
return ok({ data: keys, meta: { total: count, limit: params.limit, offset: params.skip } });
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKeys", issue: error.message }],
});
}
},
[`management-getContactAttributeKeys-${environmentIds.join(",")}-${JSON.stringify(params)}`],
{
tags: environmentIds.map((environmentId) =>
contactAttributeKeyCache.tag.byEnvironmentId(environmentId)
),
}
)()
);
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
environment: {
connect: {
id: environmentId,
},
},
name,
description,
key,
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
data: prismaData,
});
contactAttributeKeyCache.revalidate({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
return ok(createdContactAttributeKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};

View File

@@ -8,9 +8,9 @@ import {
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
@@ -18,14 +18,14 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter.sourceType(),
query: ZGetContactAttributeKeysFilter,
},
responses: {
"200": {
description: "Contact attribute keys retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZContactAttributeKey)),
schema: z.array(ZContactAttributeKey),
},
},
},
@@ -49,11 +49,6 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
responses: {
"201": {
description: "Contact attribute key created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};

View File

@@ -1,166 +0,0 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { createContactAttributeKey, getContactAttributeKeys } from "../contact-attribute-key";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
contactAttributeKey: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
revalidate: vi.fn(),
tag: {
byEnvironmentId: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const environmentIds = ["env1", "env2"];
const params: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
const fakeContactAttributeKeys = [
{ id: "key1", environmentId: "env1", name: "Key One", key: "keyOne" },
{ id: "key2", environmentId: "env1", name: "Key Two", key: "keyTwo" },
];
const count = fakeContactAttributeKeys.length;
test("returns ok response with contact attribute keys and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeContactAttributeKeys, count]);
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeContactAttributeKeys);
expect(result.data.meta).toEqual({
total: count,
limit: params.limit,
offset: params.skip,
});
}
});
test("returns error when prisma.$transaction throws", async () => {
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createContactAttributeKey", () => {
const inputContactAttributeKey: TContactAttributeKeyInput = {
environmentId: "env1",
name: "New Contact Attribute Key",
key: "newKey",
description: "Description for new key",
};
const createdContactAttributeKey: ContactAttributeKey = {
id: "key100",
environmentId: inputContactAttributeKey.environmentId,
name: inputContactAttributeKey.name,
key: inputContactAttributeKey.key,
description: inputContactAttributeKey.description,
isUnique: false,
type: "custom",
createdAt: new Date(),
updatedAt: new Date(),
};
test("creates a contact attribute key and revalidates cache", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValueOnce(createdContactAttributeKey);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(prisma.contactAttributeKey.create).toHaveBeenCalled();
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdContactAttributeKey);
}
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: 'Contact attribute key with "newKey" already exists',
},
],
});
}
});
test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [
{
field: "contactAttributeKey",
issue: "not found",
},
],
});
}
});
});

View File

@@ -1,106 +0,0 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getContactAttributeKeysQuery } from "../utils";
describe("getContactAttributeKeysQuery", () => {
const environmentId = "env-123";
const baseParams: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns query with environmentId in array when no params are provided", () => {
const environmentIds = ["env-1", "env-2"];
const result = getContactAttributeKeysQuery(environmentIds);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
});
});
test("applies common filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("applies date filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const startDate = new Date("2023-01-01");
const endDate = new Date("2023-12-31");
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
startDate,
endDate,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
createdAt: {
gte: startDate,
lte: endDate,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("handles multiple filter parameters correctly", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
environmentId,
limit: 5,
skip: 10,
sortBy: "updatedAt",
order: "asc",
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 5,
skip: 10,
orderBy: {
updatedAt: "asc",
},
});
});
});

View File

@@ -1,26 +0,0 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { Prisma } from "@prisma/client";
export const getContactAttributeKeysQuery = (
environmentIds: string[],
params?: TGetContactAttributeKeysFilter
): Prisma.ContactAttributeKeyFindManyArgs => {
let query: Prisma.ContactAttributeKeyFindManyArgs = {
where: {
environmentId: {
in: environmentIds,
},
},
};
if (!params) return query;
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ContactAttributeKeyFindManyArgs>(query, baseFilter);
}
return query;
};

View File

@@ -1,73 +0,0 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
createContactAttributeKey,
getContactAttributeKeys,
} from "@/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key";
import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
let environmentIds: string[] = [];
if (query.environmentId) {
if (!hasPermission(authentication.environmentPermissions, query.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
environmentIds = [query.environmentId];
} else {
environmentIds = authentication.environmentPermissions.map((permission) => permission.environmentId);
}
const res = await getContactAttributeKeys(environmentIds, query);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
});
}
const createContactAttributeKeyResult = await createContactAttributeKey(body);
if (!createContactAttributeKeyResult.ok) {
return handleApiError(request, createContactAttributeKeyResult.error);
}
return responses.createdResponse(createContactAttributeKeyResult);
},
});

View File

@@ -1,13 +1,18 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"),
})
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
@@ -18,15 +23,13 @@ export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
{
message: "startDate must be before endDate",
}
)
.describe("Filter for retrieving contact attribute keys");
export type TGetContactAttributeKeysFilter = z.infer<typeof ZGetContactAttributeKeysFilter>;
);
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
type: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",

View File

@@ -14,8 +14,7 @@ type HasFindMany =
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.UserFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};

View File

@@ -1,6 +1,5 @@
import { cache } from "@/lib/cache";
import { organizationCache } from "@/lib/organization/cache";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -134,7 +133,22 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
}
// Determine the start date based on the plan type
const startDate = getBillingPeriodStartDate(billing.data);
let startDate: Date;
if (billing.data.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!billing.data.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
startDate = billing.data.periodStart;
}
// Get all environment IDs for the organization
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);

View File

@@ -81,6 +81,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.createdResponse({ data: createResponseResult.data });
return responses.successResponse({ data: createResponseResult.data });
},
});

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