fix: refactor clickable divs into buttons (#5489)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
victorvhs017
2025-04-25 15:13:15 +07:00
committed by GitHub
parent 5eca30e513
commit 40fa7a69c0
35 changed files with 1991 additions and 167 deletions

26
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,26 @@
# 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,9 +1,4 @@
{
"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

@@ -0,0 +1,151 @@
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, idx) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
</div>
))}
</div>
{integrationData.map((data, index) => (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
<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"
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>
</div>
</button>
))}
</div>
) : (

View File

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

View File

@@ -0,0 +1,91 @@
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);
const integrationArray = notionIntegration
? notionIntegration.config.data
? notionIntegration.config.data
: []
: [];
let integrationArray: TIntegrationNotionConfigData[] = [];
if (notionIntegration?.config.data) {
integrationArray = notionIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
<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"
onClick={() => {
editIntegration(index);
}}>
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</div>
</button>
);
})}
</div>

View File

@@ -0,0 +1,158 @@
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,8 +6,7 @@ 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 { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { T, useTranslate } from "@tolgee/react";
import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
const integrationArray = slackIntegration
? slackIntegration.config.data
? slackIntegration.config.data
: []
: [];
let integrationArray: TIntegrationSlackConfigData[] = [];
if (slackIntegration?.config.data) {
integrationArray = slackIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<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"
<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"
onClick={() => {
editIntegration(index);
}}>
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</div>
</button>
);
})}
</div>

View File

@@ -0,0 +1,165 @@
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,11 +35,13 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<div
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"
<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"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</div>
</button>
);
return (

View File

@@ -0,0 +1,80 @@
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

@@ -41,11 +41,11 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<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">
{summaryItems.map((summaryItem) => {
return (
<div
className="group cursor-pointer"
<button
className="group w-full 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>
</div>
</button>
);
})}
</div>

View File

@@ -0,0 +1,47 @@
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

@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<table className="mx-auto border-collapse cursor-default text-left">
<thead>
<tr>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>
@@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
percentage,
questionSummary.data[rowIndex].totalResponsesForRow
)}>
<div
<button
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"
onClick={() =>
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)
}>
{percentage}
</div>
</button>
</TooltipRenderer>
</td>
))}

View File

@@ -0,0 +1,275 @@
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 { useState } from "react";
import { Fragment, useState } from "react";
import {
TI18nString,
TSurvey,
@@ -45,10 +45,15 @@ 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) => {
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'
const aHasOthers = (a.others?.length ?? 0) > 0;
const bHasOthers = (b.others?.length ?? 0) > 0;
return b.count - a.count; // Sort by count
// 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;
});
const handleLoadMore = (e: React.MouseEvent) => {
@@ -80,40 +85,41 @@ 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) => (
<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)}%
<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")}
</p>
</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")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
<div className="mt-4 rounded-lg border border-slate-200">
<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")}
@@ -124,11 +130,9 @@ export const MultipleChoiceSummary = ({
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={idx} dir="auto">
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<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">
<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">
<span>{otherValue.value}</span>
</div>
)}
@@ -139,7 +143,6 @@ 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>
@@ -163,7 +166,7 @@ export const MultipleChoiceSummary = ({
)}
</div>
)}
</div>
</Fragment>
))}
</div>
</div>

View File

@@ -0,0 +1,60 @@
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

@@ -62,14 +62,17 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<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">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<button
className="w-full 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">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -87,11 +90,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</div>
</button>
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<div className="flex justify-center pt-4 pb-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -0,0 +1,91 @@
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

@@ -43,10 +43,10 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<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, index) => (
<div
className="cursor-pointer hover:opacity-80"
<button
className="w-full 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} />
</div>
</button>
))}
</div>
</div>

View File

@@ -0,0 +1,87 @@
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

@@ -50,10 +50,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div>
}
/>
<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">
{questionSummary.choices.map((result) => (
<div
className="cursor-pointer hover:opacity-80"
<button
className="w-full 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} />
</div>
</button>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (

View File

@@ -0,0 +1,135 @@
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

@@ -71,6 +71,8 @@ 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">
@@ -99,9 +101,7 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<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-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-sm">
<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>
) : (
dropOffCount
displayCountValue
)}
</span>
{!isLoading && (
<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">
<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)}>
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</button>
)}
</div>
</div>
@@ -135,6 +135,7 @@ export const SummaryMetadata = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}

View File

@@ -0,0 +1,88 @@
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,6 +81,39 @@ 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 ? (
@@ -130,39 +163,37 @@ 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",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
!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>
)
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<p className="text-slate-400">{t("common.select")}...</p>
<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>
)}
<div>
<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="ml-2 h-4 w-4 opacity-50" />
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</div>
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
@@ -183,21 +214,7 @@ export const QuestionFilterComboBox = ({
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
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);
}}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>

View File

@@ -0,0 +1,55 @@
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 * as React from "react";
import { Fragment, useRef, useState } 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] = React.useState(false);
const [open, setOpen] = useState(false);
const { t } = useTranslate();
const commandRef = React.useRef(null);
const [inputValue, setInputValue] = React.useState("");
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<div
<button
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>
</div>
</button>
<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">
<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

@@ -9,7 +9,8 @@ export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date =
} 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);
const subscriptionDay = periodStart.getDate();
// 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 => {

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) => (
<div
<button
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)}
</div>
</button>
))}
</div>
)}

View File

@@ -0,0 +1,105 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { HalfCircle, ProgressBar } from ".";
describe("ProgressBar", () => {
afterEach(() => {
cleanup();
});
test("renders with default height and correct progress", () => {
const { container } = render(<ProgressBar progress={0.5} barColor="bg-blue-500" />);
const outerDiv = container.firstChild as HTMLElement;
const innerDiv = outerDiv.firstChild as HTMLElement;
expect(outerDiv).toHaveClass("h-5"); // Default height
expect(outerDiv).toHaveClass("w-full rounded-full bg-slate-200");
expect(innerDiv).toHaveClass("h-full rounded-full bg-blue-500");
expect(innerDiv.style.width).toBe("50%");
});
test("renders with specified height (h-2)", () => {
const { container } = render(<ProgressBar progress={0.75} barColor="bg-green-500" height={2} />);
const outerDiv = container.firstChild as HTMLElement;
const innerDiv = outerDiv.firstChild as HTMLElement;
expect(outerDiv).toHaveClass("h-2"); // Specified height
expect(innerDiv).toHaveClass("bg-green-500");
expect(innerDiv.style.width).toBe("75%");
});
test("caps progress at 100%", () => {
const { container } = render(<ProgressBar progress={1.2} barColor="bg-red-500" />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv.style.width).toBe("100%");
});
test("handles progress less than 0%", () => {
const { container } = render(<ProgressBar progress={-0.1} barColor="bg-yellow-500" />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv.style.width).toBe("0%");
});
test("applies barColor class", () => {
const testColor = "bg-purple-600";
const { container } = render(<ProgressBar progress={0.3} barColor={testColor} />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv).toHaveClass(testColor);
});
});
describe("HalfCircle", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with a given value", () => {
const testValue = 50;
const { getByText, container } = render(<HalfCircle value={testValue} />);
// Check if boundary values and the main value are rendered
expect(getByText("-100")).toBeInTheDocument();
expect(getByText("100")).toBeInTheDocument();
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
// Check rotation calculation: normalized = (50 + 100) / 200 = 0.75; mapped = (0.75 * 180 - 180) = -45deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv).toBeInTheDocument();
expect(rotatingDiv.style.rotate).toBe("-45deg");
});
test("renders correctly with value -100", () => {
const testValue = -100;
const { getAllByText, getByText, container } = render(<HalfCircle value={testValue} />);
// Check boundary labels
expect(getAllByText("-100")[0]).toBeInTheDocument();
expect(getByText("100")).toBeInTheDocument();
// Check the main value using a more specific selector
const mainValueElement = container.querySelector(".text-2xl.text-black");
expect(mainValueElement).toBeInTheDocument();
expect(mainValueElement?.textContent).toBe(Math.round(testValue).toString());
// normalized = (-100 + 100) / 200 = 0; mapped = (0 * 180 - 180) = -180deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("-180deg");
});
test("renders correctly with value 100", () => {
const testValue = 100;
const { getAllByText, container } = render(<HalfCircle value={testValue} />);
expect(getAllByText(Math.round(testValue).toString())[0]).toBeInTheDocument();
// normalized = (100 + 100) / 200 = 1; mapped = (1 * 180 - 180) = 0deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("0deg");
});
test("renders correctly with value 0", () => {
const testValue = 0;
const { getByText, container } = render(<HalfCircle value={testValue} />);
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
// normalized = (0 + 100) / 200 = 0.5; mapped = (0.5 * 180 - 180) = -90deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("-90deg");
});
});

View File

@@ -9,11 +9,24 @@ interface ProgressBarProps {
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, barColor, height = 5 }) => {
const heightClass = () => {
switch (height) {
case 2:
return "h-2";
case 5:
return "h-5";
default:
return "";
}
};
const maxWidth = Math.floor(Math.max(0, Math.min(progress, 1)) * 100);
return (
<div className={cn(height === 2 ? "h-2" : height === 5 ? "h-5" : "", "w-full rounded-full bg-slate-200")}>
<div className={cn(heightClass(), "w-full rounded-full bg-slate-200")}>
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}></div>
style={{ width: `${maxWidth}%`, transition: "width 0.5s ease-out" }}></div>
</div>
);
};

View File

@@ -35,6 +35,7 @@ export default defineConfig({
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
"modules/ui/components/alert/*.tsx",
"modules/ui/components/environmentId-base-layout/*.tsx",
"modules/ui/components/progress-bar/index.tsx",
"app/(app)/environments/**/layout.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"app/(app)/environments/**/components/PosthogIdentify.tsx",
@@ -47,6 +48,20 @@ export default defineConfig({
"app/intercom/*.tsx",
"app/sentry/*.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/NPSSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx",
"app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/google-sheets/components/ManageIntegration.tsx",
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
"modules/ee/sso/lib/**/*.ts",
"app/lib/**/*.ts",
"app/api/(internal)/insights/lib/**/*.ts",
@@ -75,8 +90,9 @@ export default defineConfig({
"modules/analysis/**/*.tsx",
"modules/analysis/**/*.ts",
"modules/survey/editor/components/end-screen-form.tsx",
"lib/utils/billing.ts",
"lib/crypto.ts",
"lib/utils/billing.ts"
"lib/utils/billing.ts",
],
exclude: [
"**/.next/**",