diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..91aac327de --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.vscode/settings.json b/.vscode/settings.json index 22650b0892..10bac75fe3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx new file mode 100644 index 0000000000..df1a9130c9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx @@ -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 ? ( +
+ +
+ ) : null, + }) +); +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, onDelete }) => + open ? ( +
+ + +
+ ) : 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( + + ); + expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument(); + expect(screen.getByText(/link_new_table/)).toBeInTheDocument(); + }); + + test("open add modal", async () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + await userEvent.click(screen.getByText(/delete_integration/)); + await userEvent.click(screen.getByText("confirm")); + const { toast } = await import("react-hot-toast"); + expect(toast.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx index 96afb5f93b..4e0c8c937c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx @@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => { {integrationData.length ? (
- {tableHeaders.map((header, idx) => ( - {integrationData.map((data, index) => ( -
{ setDefaultValues({ base: data.baseId, @@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
{timeSince(data.createdAt.toString(), props.locale)}
-
+ ))}
) : ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx new file mode 100644 index 0000000000..d77ac85ac8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx @@ -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 ? ( +
+ + +
+ ) : null, +})); + +vi.mock("@/modules/ui/components/empty-space-filler", () => ({ + EmptySpaceFiller: ({ emptyMessage }: any) =>
{emptyMessage}
, +})); + +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( + + ); + + expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument(); + expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument(); + }); + + test("click link new sheet", async () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx index eb5aa50a54..a1876d3fbd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx @@ -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 ( -
{ editIntegration(index); }}> @@ -124,7 +123,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx new file mode 100644 index 0000000000..0c0c05c0a0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx index 34d4d3aa75..702cd02c8e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx @@ -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 ( -
{ editIntegration(index); }}> @@ -132,7 +132,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx new file mode 100644 index 0000000000..1c2f2e2712 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx @@ -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 ? ( +
+ + +
+ ) : null, +})); +vi.mock("@/modules/ui/components/empty-space-filler", () => ({ + EmptySpaceFiller: ({ emptyMessage }: any) =>
{emptyMessage}
, +})); + +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( + + ); + expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument(); + expect(screen.getByText(/link_channel/)).toBeInTheDocument(); + }); + + test("link channel triggers handlers", async () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx index a1f52064ce..33a0693a06 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx @@ -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 ( -
{ editIntegration(index); }}> @@ -141,7 +139,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx new file mode 100644 index 0000000000..77ce5f41ca --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx @@ -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 => + ({ + column: { + id, + getSize: () => size, + getIsFirstColumn: () => first, + getIsLastColumn: () => last, + getStart: () => 0, + columnDef: { cell: () => content }, + }, + id, + getContext: () => ({}), + }) as unknown as Cell; + +const makeRow = (id: string, selected = false): Row => + ({ id, getIsSelected: () => selected }) as unknown as Row; + +describe("ResponseTableCell", () => { + afterEach(() => { + cleanup(); + }); + + test("renders cell content", () => { + const cell = makeCell("col1"); + const row = makeRow("r1"); + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + const inner = container.querySelector("div > div"); + expect(inner).toHaveClass("h-full"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx index 2fab91a2e6..5cdc2294f1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx @@ -35,11 +35,13 @@ export const ResponseTableCell = ({ // Conditional rendering of maximize icon const renderMaximizeIcon = cell.column.id === "createdAt" && ( -
-
+ ); return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx new file mode 100644 index 0000000000..f97f35b5e4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx @@ -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: () =>
QuestionSummaryHeader
, + }) +); + +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(); + 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(); + 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(); + 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(); + expect(screen.getByText(/1 common\.response/)).toBeInTheDocument(); + expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx index 1234f0f906..3bffa7e961 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx @@ -41,11 +41,11 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu return (
-
+
{summaryItems.map((summaryItem) => { return ( -
setFilter( @@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx new file mode 100644 index 0000000000..35e5c134a2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx @@ -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: () =>
QuestionSummaryHeader
, + }) +); + +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(); + + // 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"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx index 59f19364be..e038e7c0aa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx @@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma - + {columns.map((column) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( - ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx new file mode 100644 index 0000000000..5793f8d1d9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx @@ -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) =>
{personId}
, +})); +vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () =>
})); + +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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + + 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( + + ); + + const btn = screen.getByText("2 - O5"); + await userEvent.click(btn); + expect(setFilter).toHaveBeenCalledWith( + "q5", + "H5", + "multipleChoiceMulti", + "environments.surveys.summary.includes_either", + ["O5"] + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index edd9e6e02d..235ba3e422 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -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 doesn’t, push the one with others to the end + if (aHasOthers && !bHasOthers) return 1; + if (!aHasOthers && bHasOthers) return -1; + + // if they’re “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 = ({ />
{results.map((result, resultsIdx) => ( -
- 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] - ) - }> -
-
-

- {results.length - resultsIdx} - {result.value} -

-
-

- {convertFloatToNDecimal(result.percentage, 2)}% + +

-
- -
+
+ +
+ {result.others && result.others.length > 0 && ( -
e.stopPropagation()}> +
{t("environments.surveys.summary.other_values_found")} @@ -124,11 +130,9 @@ export const MultipleChoiceSummary = ({ .filter((otherValue) => otherValue.value !== "") .slice(0, visibleOtherResponses) .map((otherValue, idx) => ( -
+
{surveyType === "link" && ( -
+
{otherValue.value}
)} @@ -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">
{otherValue.value} @@ -163,7 +166,7 @@ export const MultipleChoiceSummary = ({ )}
)} -
+ ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx new file mode 100644 index 0000000000..125c4e6754 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx @@ -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 }) => ( +
{`${progress}-${barColor}`}
+ ), + HalfCircle: ({ value }: { value: number }) =>
{value}
, +})); +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +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( {}} />); + 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(); + await userEvent.click(screen.getByText(group)); + expect(setFilter).toHaveBeenCalledWith( + baseQuestion.id, + baseQuestion.headline, + baseQuestion.type, + cmp, + vals + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index dd01c999a4..948d41e34e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -62,14 +62,17 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro return (
-
+
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( -
applyFilter(group)}> + ))}
-
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx new file mode 100644 index 0000000000..732f03dcdc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx @@ -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 }) => ( +
+ ), +})); +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ additionalInfo }: any) =>
{additionalInfo}
, +})); + +// mock next image +vi.mock("next/image", () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element + default: ({ src }: { src: string }) => , +})); + +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( {}} />); + + 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(); + + 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( {}} />); + + expect(screen.getByTestId("header")).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index a942d1c2dd..fc7a7b2268 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -43,10 +43,10 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic ) : undefined } /> -
+
{results.map((result, index) => ( -
setFilter( @@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic

-
+ ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx new file mode 100644 index 0000000000..da1e77641c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx @@ -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) =>
{additionalInfo}
, +})); + +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(); + 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(); + 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(); + expect(screen.getByText("common.dismissed")).toBeDefined(); + expect(screen.getByText("1 common.response")).toBeDefined(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index d2de76387d..2234a70584 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -50,10 +50,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
} /> -
+
{questionSummary.choices.map((result) => ( -
setFilter( @@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm

-
+ ))}
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx new file mode 100644 index 0000000000..6c7b0b63bf --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx @@ -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: () =>
, + ChevronUpIcon: () =>
, +})); +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( + {}} + 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 ( + + ); + }; + render(); + // 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( + {}} + 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 ( + + ); + }; + render(); + 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( + {}} + surveySummary={dispZero} + isLoading={false} + /> + ); + expect(screen.getAllByText("-")).toHaveLength(1); + }); + + test("renders '-' for totalResponses=0", () => { + const totZero = { ...baseSummary, totalResponses: 0 }; + render( + {}} + surveySummary={totZero} + isLoading={false} + /> + ); + expect(screen.getAllByText("-")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index 6f3cae5f45..b1ee890bf1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -71,6 +71,8 @@ export const SummaryMetadata = ({ ttcAverage, } = surveySummary; const { t } = useTranslate(); + const displayCountValue = dropOffCount === 0 ? - : dropOffCount; + return (
@@ -99,9 +101,7 @@ export const SummaryMetadata = ({ -
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"> +
{t("environments.surveys.summary.drop_offs")} {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( @@ -112,20 +112,20 @@ export const SummaryMetadata = ({ {isLoading ? (
- ) : dropOffCount === 0 ? ( - - ) : ( - dropOffCount + displayCountValue )}
{!isLoading && ( - + )}
@@ -135,6 +135,7 @@ export const SummaryMetadata = ({
+ { + 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(); + expect(screen.getAllByText(/common.select\.../).length).toBe(2); + }); + + test("calls onChangeFilterValue when selecting filter", async () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await userEvent.click(screen.getAllByRole("button")[1]); + expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index 3c9e819f13..5dbe2e1e1d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -81,6 +81,39 @@ export const QuestionFilterComboBox = ({ .includes(searchQuery.toLowerCase()) ); + const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? ( +

{filterComboBoxValue}

+ ) : ( +
+ {typeof filterComboBoxValue !== "string" && + filterComboBoxValue?.map((o, index) => ( + + ))} +
+ ); + + 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 (
{filterOptions && filterOptions?.length <= 1 ? ( @@ -130,39 +163,37 @@ export const QuestionFilterComboBox = ({ )}
!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) ? ( -

{filterComboBoxValue}

- ) : ( -
- {typeof filterComboBoxValue !== "string" && - filterComboBoxValue?.map((o, index) => ( - - ))} -
- ) + {filterComboBoxValue && filterComboBoxValue.length > 0 ? ( + filterComboBoxItem ) : ( -

{t("common.select")}...

+ )} -
+
+
{open && ( @@ -183,21 +214,7 @@ export const QuestionFilterComboBox = ({ {filteredOptions?.map((o, index) => ( { - !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} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx new file mode 100644 index 0000000000..fa12d8920c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx @@ -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 = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" }; + render( {}} />); + expect(screen.getByText("Q1")).toBeInTheDocument(); + }); + + test("opens dropdown, selects an option, and closes", async () => { + let currentSelected: Partial = {}; + const onChange = vi.fn((option) => { + currentSelected = option; + }); + + const { rerender } = render( + + ); + + // 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(); + + // 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 + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index 485b29d71b..b0f8f704cd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -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 { - 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 ( -
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 )}
-
+
{open && (
{t("common.no_result_found")} {options?.map((data) => ( - <> + {data?.option.length > 0 && ( {data.header}

}> @@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question ))}
)} - +
))}
diff --git a/apps/web/lib/utils/billing.ts b/apps/web/lib/utils/billing.ts index dc930adc5e..58d88764cf 100644 --- a/apps/web/lib/utils/billing.ts +++ b/apps/web/lib/utils/billing.ts @@ -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 => { diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx index 44b81d7889..44d79cee52 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx @@ -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) => ( -
{ @@ -36,7 +36,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo setShowLanguageSelect(false); }}> {getLanguageLabel(surveyLanguage.language.code, locale)} -
+ ))}
)} diff --git a/apps/web/modules/ui/components/progress-bar/index.test.tsx b/apps/web/modules/ui/components/progress-bar/index.test.tsx new file mode 100644 index 0000000000..6fddfba0b7 --- /dev/null +++ b/apps/web/modules/ui/components/progress-bar/index.test.tsx @@ -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(); + 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(); + 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(); + const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement; + expect(innerDiv.style.width).toBe("100%"); + }); + + test("handles progress less than 0%", () => { + const { container } = render(); + 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(); + 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(); + + // 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(); + // 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(); + 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(); + 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"); + }); +}); diff --git a/apps/web/modules/ui/components/progress-bar/index.tsx b/apps/web/modules/ui/components/progress-bar/index.tsx index 0bcbd60074..adc68d87e7 100644 --- a/apps/web/modules/ui/components/progress-bar/index.tsx +++ b/apps/web/modules/ui/components/progress-bar/index.tsx @@ -9,11 +9,24 @@ interface ProgressBarProps { } export const ProgressBar: React.FC = ({ 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 ( -
+
+ style={{ width: `${maxWidth}%`, transition: "width 0.5s ease-out" }}>
); }; diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 4f41c02d5e..5abfa0b2e9 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -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/**",
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
+

{rowLabel}

@@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma percentage, questionSummary.data[rowIndex].totalResponsesForRow )}> -
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma ) }> {percentage} -
+