diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index 200b02c322..0a5439d6d8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -94,7 +94,7 @@ const getQuestionColumnsData = ( return (
{choiceIds.map((choiceId, index) => ( - + ))}
); @@ -195,6 +195,7 @@ const getQuestionColumnsData = ( responseData={responseValue} language={language} isExpanded={isExpanded} + showId={false} /> ); }, @@ -246,6 +247,7 @@ const getQuestionColumnsData = ( responseData={responseValue} language={language} isExpanded={isExpanded} + showId={false} /> ); }, @@ -312,7 +314,7 @@ export const generateResponseTableColumns = ( header: t("common.status"), cell: ({ row }) => { const status = row.original.status; - return ; + return ; }, }; @@ -325,9 +327,10 @@ export const generateResponseTableColumns = ( const tagsArray = tags.map((tag) => tag.name); return ( ({ value: tag }))} isExpanded={isExpanded} icon={} + showId={false} /> ); } 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 index 5793f8d1d9..e15259a9c1 100644 --- 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 @@ -7,6 +7,13 @@ vi.mock("@/modules/ui/components/avatars", () => ({ PersonAvatar: ({ personId }: any) =>
{personId}
, })); vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () =>
})); +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); describe("MultipleChoiceSummary", () => { afterEach(() => { @@ -160,8 +167,8 @@ describe("MultipleChoiceSummary", () => { /> ); const btns = screen.getAllByRole("button"); - expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections"); - expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection"); + expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%"); + expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%"); }); test("places choice with others after one without when reversed inputs", () => { @@ -272,4 +279,127 @@ describe("MultipleChoiceSummary", () => { ["O5"] ); }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q6", + headline: "H6", + type: "multipleChoiceSingle", + choices: [ + { id: "choice1", label: { default: "Option A" } }, + { id: "choice2", label: { default: "Option B" } }, + ], + }, + choices: { + "Option A": { value: "Option A", count: 5, percentage: 50, others: [] }, + "Option B": { value: "Option B", count: 5, percentage: 50, others: [] }, + }, + type: "multipleChoiceSingle", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + expect(idBadges[0]).toHaveTextContent("ID: choice1"); + expect(idBadges[1]).toHaveTextContent("ID: choice2"); + }); + + test("getChoiceIdByValue function correctly maps values to IDs", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q8", + headline: "H8", + type: "multipleChoiceMulti", + choices: [ + { id: "id-apple", label: { default: "Apple" } }, + { id: "id-banana", label: { default: "Banana" } }, + { id: "id-cherry", label: { default: "Cherry" } }, + ], + }, + choices: { + Apple: { value: "Apple", count: 3, percentage: 30, others: [] }, + Banana: { value: "Banana", count: 4, percentage: 40, others: [] }, + Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] }, + }, + type: "multipleChoiceMulti", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + + // Check that each badge has the correct ID + const expectedMappings = [ + { text: "Banana", id: "id-banana" }, // Highest count appears first + { text: "Apple", id: "id-apple" }, + { text: "Cherry", id: "id-cherry" }, + ]; + + expectedMappings.forEach(({ text, id }, index) => { + expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument(); + expect(idBadges[index]).toHaveAttribute("data-id", id); + }); + }); + + test("handles choices with special characters in labels", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q9", + headline: "H9", + type: "multipleChoiceSingle", + choices: [ + { id: "special-1", label: { default: "Option & Choice" } }, + { id: "special-2", label: { default: "Choice with 'quotes'" } }, + ], + }, + choices: { + "Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] }, + "Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] }, + }, + type: "multipleChoiceSingle", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "special-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "special-2"); + }); }); 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 45ef0d3614..129f03eb16 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 @@ -1,8 +1,10 @@ "use client"; +import { getChoiceIdByValue } from "@/lib/response/utils"; import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { useTranslate } from "@tolgee/react"; import { InboxIcon } from "lucide-react"; @@ -84,90 +86,95 @@ export const MultipleChoiceSummary = ({ } />
- {results.map((result, resultsIdx) => ( - -
-
- -
- - {result.others && result.others.length > 0 && ( -
-
-
- {t("environments.surveys.summary.other_values_found")} -
-
{surveyType === "app" && t("common.user")}
+
+
- {result.others - .filter((otherValue) => otherValue.value !== "") - .slice(0, visibleOtherResponses) - .map((otherValue, idx) => ( -
- {surveyType === "link" && ( -
- {otherValue.value} -
- )} - {surveyType === "app" && otherValue.contact && ( - -
+ + {result.others && result.others.length > 0 && ( +
+
+
+ {t("environments.surveys.summary.other_values_found")} +
+
{surveyType === "app" && t("common.user")}
+
+ {result.others + .filter((otherValue) => otherValue.value !== "") + .slice(0, visibleOtherResponses) + .map((otherValue, idx) => ( +
+ {surveyType === "link" && ( +
{otherValue.value}
-
- {otherValue.contact.id && } - - {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)} - -
- - )} + )} + {surveyType === "app" && otherValue.contact && ( + +
+ {otherValue.value} +
+
+ {otherValue.contact.id && } + + {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)} + +
+ + )} +
+ ))} + {visibleOtherResponses < result.others.length && ( +
+
- ))} - {visibleOtherResponses < result.others.length && ( -
- -
- )} -
- )} - - ))} + )} +
+ )} + + ); + })}
); 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 index 732f03dcdc..2392285462 100644 --- 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 @@ -1,7 +1,11 @@ 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 { + TSurvey, + TSurveyPictureSelectionQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; import { PictureChoiceSummary } from "./PictureChoiceSummary"; vi.mock("@/modules/ui/components/progress-bar", () => ({ @@ -12,6 +16,19 @@ vi.mock("@/modules/ui/components/progress-bar", () => ({ vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: ({ additionalInfo }: any) =>
{additionalInfo}
, })); +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + +vi.mock("@/lib/response/utils", () => ({ + getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => { + return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other"; + }, +})); // mock next image vi.mock("next/image", () => ({ @@ -88,4 +105,73 @@ describe("PictureChoiceSummary", () => { expect(screen.getByTestId("header")).toBeEmptyDOMElement(); }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found via imageUrl", () => { + const choices = [ + { id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 }, + { id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 }, + ]; + const questionSummary = { + choices, + question: { + id: "q2", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "Picture Question", + allowMulti: true, + choices: [ + { id: "pic-choice-1", imageUrl: "https://example.com/img1.png" }, + { id: "pic-choice-2", imageUrl: "https://example.com/img2.png" }, + ], + }, + selectionCount: 10, + } as any; + + render( {}} />); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2"); + expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1"); + expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2"); + }); + + test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => { + const choices = [ + { id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 }, + { id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 }, + { id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 }, + ]; + const questionSummary = { + choices, + question: { + id: "q4", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "Photo Selection", + allowMulti: true, + choices: [ + { id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" }, + { id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" }, + { id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" }, + ], + }, + selectionCount: 6, + } as any; + + render( {}} />); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + expect(idBadges[0]).toHaveAttribute("data-id", "photo-a"); + expect(idBadges[1]).toHaveAttribute("data-id", "photo-b"); + expect(idBadges[2]).toHaveAttribute("data-id", "photo-c"); + + // Verify the images are also rendered correctly + const images = screen.getAllByRole("img"); + expect(images).toHaveLength(3); + expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg"); + expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg"); + expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg"); + }); }); 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 e13789e2f0..2e225714e4 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 @@ -1,5 +1,7 @@ "use client"; +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { useTranslate } from "@tolgee/react"; import { InboxIcon } from "lucide-react"; @@ -29,6 +31,7 @@ interface PictureChoiceSummaryProps { export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => { const results = questionSummary.choices; const { t } = useTranslate(); + return (
- {results.map((result, index) => ( - - ))} + + + ); + })}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx index 69f080f1c7..a93c27c15e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types"; import { RankingSummary } from "./RankingSummary"; // Mock dependencies @@ -12,17 +12,32 @@ vi.mock("../lib/utils", () => ({ convertFloatToNDecimal: (value: number) => value.toFixed(2), })); +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + describe("RankingSummary", () => { afterEach(() => { cleanup(); }); const survey = {} as TSurvey; - const surveyType: TSurveyType = "app"; test("renders ranking results in correct order", () => { const questionSummary = { - question: { id: "q1", headline: "Rank the following" }, + question: { + id: "q1", + headline: "Rank the following", + choices: [ + { id: "choice1", label: { default: "Option A" } }, + { id: "choice2", label: { default: "Option B" } }, + { id: "choice3", label: { default: "Option C" } }, + ], + }, choices: { option1: { value: "Option A", avgRanking: 1.5, others: [] }, option2: { value: "Option B", avgRanking: 2.3, others: [] }, @@ -30,7 +45,7 @@ describe("RankingSummary", () => { }, } as unknown as TSurveyQuestionSummaryRanking; - render(); + render(); expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); @@ -51,43 +66,13 @@ describe("RankingSummary", () => { expect(screen.getByText("#2.30")).toBeInTheDocument(); }); - test("renders 'other values found' section when others exist", () => { - const questionSummary = { - question: { id: "q1", headline: "Rank the following" }, - choices: { - option1: { - value: "Option A", - avgRanking: 1.0, - others: [{ value: "Other value", count: 2 }], - }, - }, - } as unknown as TSurveyQuestionSummaryRanking; - - render(); - - expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument(); - }); - - test("shows 'User' column in other values section for app survey type", () => { - const questionSummary = { - question: { id: "q1", headline: "Rank the following" }, - choices: { - option1: { - value: "Option A", - avgRanking: 1.0, - others: [{ value: "Other value", count: 1 }], - }, - }, - } as unknown as TSurveyQuestionSummaryRanking; - - render(); - - expect(screen.getByText("common.user")).toBeInTheDocument(); - }); - test("doesn't show 'User' column for link survey type", () => { const questionSummary = { - question: { id: "q1", headline: "Rank the following" }, + question: { + id: "q1", + headline: "Rank the following", + choices: [{ id: "choice1", label: { default: "Option A" } }], + }, choices: { option1: { value: "Option A", @@ -97,8 +82,132 @@ describe("RankingSummary", () => { }, } as unknown as TSurveyQuestionSummaryRanking; - render(); + render(); expect(screen.queryByText("common.user")).not.toBeInTheDocument(); }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found via label", () => { + const questionSummary = { + question: { + id: "q2", + headline: "Rank these options", + choices: [ + { id: "rank-choice-1", label: { default: "First Option" } }, + { id: "rank-choice-2", label: { default: "Second Option" } }, + { id: "rank-choice-3", label: { default: "Third Option" } }, + ], + }, + choices: { + option1: { value: "First Option", avgRanking: 1.5, others: [] }, + option2: { value: "Second Option", avgRanking: 2.1, others: [] }, + option3: { value: "Third Option", avgRanking: 2.8, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2"); + expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3"); + expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1"); + expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2"); + expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3"); + }); + + test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => { + const questionSummary = { + question: { + id: "q4", + headline: "Rate importance", + choices: [ + { id: "importance-high", label: { default: "Very Important" } }, + { id: "importance-medium", label: { default: "Somewhat Important" } }, + { id: "importance-low", label: { default: "Not Important" } }, + ], + }, + choices: { + option1: { value: "Very Important", avgRanking: 1.2, others: [] }, + option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] }, + option3: { value: "Not Important", avgRanking: 2.8, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + + // Should be ordered by avgRanking (ascending) + expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2 + expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0 + expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8 + + expect(idBadges[0]).toHaveAttribute("data-id", "importance-high"); + expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium"); + expect(idBadges[2]).toHaveAttribute("data-id", "importance-low"); + }); + + test("handles mixed choices with and without matching IDs", () => { + const questionSummary = { + question: { + id: "q5", + headline: "Mixed options", + choices: [ + { id: "valid-choice-1", label: { default: "Valid Option" } }, + { id: "valid-choice-2", label: { default: "Another Valid Option" } }, + ], + }, + choices: { + option1: { value: "Valid Option", avgRanking: 1.5, others: [] }, + option2: { value: "Unknown Option", avgRanking: 2.0, others: [] }, + option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges + + // Check that all options are still displayed + expect(screen.getByText("Valid Option")).toBeInTheDocument(); + expect(screen.getByText("Unknown Option")).toBeInTheDocument(); + expect(screen.getByText("Another Valid Option")).toBeInTheDocument(); + + // Check that only the valid choices have badges + expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "other"); + expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2"); + }); + + test("handles special characters in choice labels", () => { + const questionSummary = { + question: { + id: "q6", + headline: "Special characters test", + choices: [ + { id: "special-1", label: { default: "Option with 'quotes'" } }, + { id: "special-2", label: { default: "Option & Ampersand" } }, + ], + }, + choices: { + option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] }, + option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "special-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "special-2"); + + expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument(); + expect(screen.getByText("Option & Ampersand")).toBeInTheDocument(); + }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx index 7f70556ef9..95e0106eed 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx @@ -1,15 +1,16 @@ +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { useTranslate } from "@tolgee/react"; -import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types"; import { convertFloatToNDecimal } from "../lib/utils"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; interface RankingSummaryProps { questionSummary: TSurveyQuestionSummaryRanking; - surveyType: TSurveyType; survey: TSurvey; } -export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingSummaryProps) => { +export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => { // sort by count and transform to array const { t } = useTranslate(); const results = Object.values(questionSummary.choices).sort((a, b) => { @@ -20,35 +21,30 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
- {results.map((result, resultsIdx) => ( -
-
-
-
- #{resultsIdx + 1} -
{result.value}
- - - #{convertFloatToNDecimal(result.avgRanking, 2)} + {results.map((result, resultsIdx) => { + const choiceId = getChoiceIdByValue(result.value, questionSummary.question); + return ( +
+
+
+
+
+ #{resultsIdx + 1} +
{result.value}
+ {choiceId && } +
+ + + #{convertFloatToNDecimal(result.avgRanking, 2)} + + {t("environments.surveys.summary.average")} - {t("environments.surveys.summary.average")} - +
- - {result.others && result.others.length > 0 && ( -
-
-
- {t("environments.surveys.summary.other_values_found")} -
-
{surveyType === "app" && t("common.user")}
-
-
- )} -
- ))} + ); + })}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index f3df77903d..513eaa9015 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -244,7 +244,6 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local ); diff --git a/apps/web/lib/response/utils.ts b/apps/web/lib/response/utils.ts index b09bdfaafb..6590b3c821 100644 --- a/apps/web/lib/response/utils.ts +++ b/apps/web/lib/response/utils.ts @@ -9,7 +9,13 @@ import { TSurveyContactAttributes, TSurveyMetaFieldFilter, } from "@formbricks/types/responses"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { + TSurvey, + TSurveyMultipleChoiceQuestion, + TSurveyPictureSelectionQuestion, + TSurveyQuestion, + TSurveyRankingQuestion, +} from "@formbricks/types/surveys/types"; import { processResponseData } from "../responses"; import { getTodaysDateTimeFormatted } from "../time"; import { getFormattedDateTimeString } from "../utils/datetime"; @@ -81,6 +87,17 @@ export const extractChoiceIdsFromResponse = ( return []; }; +export const getChoiceIdByValue = ( + value: string, + question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion +) => { + if (question.type === "pictureSelection") { + return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other"; + } + + return question.choices.find((choice) => choice.label.default === value)?.id ?? "other"; +}; + export const calculateTtcTotal = (ttc: TResponseTtc) => { const result = { ...ttc }; result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index e8d254ca54..dc61090b40 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "Es ist nur eine Datei erlaubt", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.", "option_id": "Option-ID", + "option_ids": "Option-IDs", "or": "oder", "organization": "Organisation", "organization_id": "Organisations-ID", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 6bba350c13..fe6c2d4686 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "Only one file is allowed", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.", "option_id": "Option ID", + "option_ids": "Option IDs", "or": "or", "organization": "Organization", "organization_id": "Organization ID", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index cb32020605..5de39cf97f 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "Un seul fichier est autorisé", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.", "option_id": "Identifiant de l'option", + "option_ids": "Identifiants des options", "or": "ou", "organization": "Organisation", "organization_id": "ID de l'organisation", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 14746d5d69..f0283dcb21 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "É permitido apenas um arquivo", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.", "option_id": "ID da opção", + "option_ids": "IDs da Opção", "or": "ou", "organization": "organização", "organization_id": "ID da Organização", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 5801bdebbf..2f6eabec3b 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "Apenas um ficheiro é permitido", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.", "option_id": "ID de Opção", + "option_ids": "IDs de Opção", "or": "ou", "organization": "Organização", "organization_id": "ID da Organização", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index b083345cf7..2de91c374f 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -280,6 +280,7 @@ "only_one_file_allowed": "僅允許一個檔案", "only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。", "option_id": "選項 ID", + "option_ids": "選項 IDs", "or": "或", "organization": "組織", "organization_id": "組織 ID", diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx index 8a4e40bc2e..175d93ee8b 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx @@ -12,9 +12,10 @@ vi.mock("@/modules/ui/components/file-upload-response", () => ({ ), })); vi.mock("@/modules/ui/components/picture-selection-response", () => ({ - PictureSelectionResponse: ({ selected, isExpanded }: any) => ( -
- PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) + PictureSelectionResponse: ({ selected, isExpanded, showId }: any) => ( +
+ PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) showId:{" "} + {String(showId)}
), })); @@ -22,10 +23,28 @@ vi.mock("@/modules/ui/components/array-response", () => ({ ArrayResponse: ({ value }: any) =>
{value.join(",")}
, })); vi.mock("@/modules/ui/components/response-badges", () => ({ - ResponseBadges: ({ items }: any) =>
{items.join(",")}
, + ResponseBadges: ({ items, showId }: any) => ( +
+ {Array.isArray(items) + ? items + .map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item)) + .join(",") + : items}{" "} + showId: {String(showId)} +
+ ), })); vi.mock("@/modules/ui/components/ranking-response", () => ({ - RankingResponse: ({ value }: any) =>
{value.join(",")}
, + RankingResponse: ({ value, showId }: any) => ( +
+ {Array.isArray(value) + ? value + .map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item)) + .join(",") + : value}{" "} + showId: {String(showId)} +
+ ), })); vi.mock("@/modules/analysis/utils", () => ({ renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text), @@ -50,7 +69,14 @@ describe("RenderResponse", () => { }); const defaultSurvey = { languages: [] } as any; - const defaultQuestion = { id: "q1", type: "Unknown" } as any; + const defaultQuestion = { + id: "q1", + type: "Unknown", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + } as any; const dummyLanguage = "default"; test("returns '-' for empty responseData (string)", () => { @@ -60,6 +86,7 @@ describe("RenderResponse", () => { question={defaultQuestion} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(container.textContent).toBe("-"); @@ -72,6 +99,7 @@ describe("RenderResponse", () => { question={defaultQuestion} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(container.textContent).toBe("-"); @@ -84,6 +112,7 @@ describe("RenderResponse", () => { question={defaultQuestion} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(container.textContent).toBe("-"); @@ -92,7 +121,13 @@ describe("RenderResponse", () => { test("renders RatingResponse for 'Rating' question with number", () => { const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] }; render( - + ); expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4"); }); @@ -106,6 +141,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByText(/formatted_/)).toBeInTheDocument(); @@ -119,6 +155,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent( @@ -134,6 +171,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2"); @@ -149,6 +187,7 @@ describe("RenderResponse", () => { question={question} survey={{ languages: [] } as any} language={dummyLanguage} + showId={false} /> ); expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument(); @@ -163,6 +202,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2"); @@ -176,6 +216,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value"); @@ -184,7 +225,13 @@ describe("RenderResponse", () => { test("renders ResponseBadges for 'Consent' question (number)", () => { const question = { ...defaultQuestion, type: "consent" }; render( - + ); expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5"); }); @@ -197,56 +244,67 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click"); }); test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => { - const question = { ...defaultQuestion, type: "multipleChoiceSingle" }; + const question = { ...defaultQuestion, type: "multipleChoiceSingle", choices: [] }; render( ); expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1"); }); test("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => { - const question = { ...defaultQuestion, type: "multipleChoiceMulti" }; + const question = { ...defaultQuestion, type: "multipleChoiceMulti", choices: [] }; render( ); - expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1,opt2"); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1:other,opt2:other"); }); test("renders ResponseBadges for 'NPS' question (number)", () => { const question = { ...defaultQuestion, type: "nps" }; render( - + ); - expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9"); + // NPS questions render as simple text, not ResponseBadges + expect(screen.getByText("9")).toBeInTheDocument(); }); test("renders RankingResponse for 'Ranking' question", () => { - const question = { ...defaultQuestion, type: "ranking" }; + const question = { ...defaultQuestion, type: "ranking", choices: [] }; render( ); - expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first,second"); + expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first:other,second:other showId: false"); }); test("renders default branch for unknown question type with string", () => { @@ -257,6 +315,7 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByText("hyper:some text")).toBeInTheDocument(); @@ -270,8 +329,178 @@ describe("RenderResponse", () => { question={question} survey={defaultSurvey} language={dummyLanguage} + showId={false} /> ); expect(screen.getByText("a, b")).toBeInTheDocument(); }); + + // New tests for showId functionality + test("passes showId prop to PictureSelectionResponse", () => { + const question = { + ...defaultQuestion, + type: "pictureSelection", + choices: [{ id: "choice1", imageUrl: "url1" }], + }; + render( + + ); + const component = screen.getByTestId("PictureSelectionResponse"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + }); + + test("passes showId prop to RankingResponse with choice ID extraction", () => { + const question = { + ...defaultQuestion, + type: "ranking", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("RankingResponse"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + // Should extract choice IDs and pass them as value objects + expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2"); + }); + + test("handles ranking response with missing choice IDs", () => { + const question = { + ...defaultQuestion, + type: "ranking", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("RankingResponse"); + expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other"); + }); + + test("passes showId prop to ResponseBadges for multiple choice single", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceSingle", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + expect(component).toHaveTextContent("Option 1:choice1"); + }); + + test("passes showId prop to ResponseBadges for multiple choice multi", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2"); + }); + + test("handles multiple choice with missing choice IDs", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other"); + }); + + test("passes showId=false to components when showId is false", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "false"); + expect(component).toHaveTextContent("showId: false"); + // Should still extract IDs but showId=false + expect(component).toHaveTextContent("Option 1:choice1"); + }); + + test("handles questions without choices property", () => { + const question = { ...defaultQuestion, type: "multipleChoiceSingle" }; // No choices property + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveTextContent("Option 1:choice1"); + }); }); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx index 2250f9b33e..bde279305a 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx @@ -1,5 +1,6 @@ import { cn } from "@/lib/cn"; import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils"; +import { getChoiceIdByValue } from "@/lib/response/utils"; import { processResponseData } from "@/lib/responses"; import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { capitalizeFirstLetter } from "@/lib/utils/strings"; @@ -27,6 +28,7 @@ interface RenderResponseProps { survey: TSurvey; language: string | null; isExpanded?: boolean; + showId: boolean; } export const RenderResponse: React.FC = ({ @@ -35,6 +37,7 @@ export const RenderResponse: React.FC = ({ survey, language, isExpanded = true, + showId, }) => { if ( (typeof responseData === "string" && responseData === "") || @@ -81,6 +84,7 @@ export const RenderResponse: React.FC = ({ choices={(question as TSurveyPictureSelectionQuestion).choices} selected={responseData} isExpanded={isExpanded} + showId={showId} /> ); } @@ -121,9 +125,10 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } @@ -132,9 +137,10 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } @@ -143,26 +149,43 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } break; case TSurveyQuestionTypeEnum.MultipleChoiceMulti: case TSurveyQuestionTypeEnum.MultipleChoiceSingle: - case TSurveyQuestionTypeEnum.NPS: + case TSurveyQuestionTypeEnum.Ranking: if (typeof responseData === "string" || typeof responseData === "number") { - return ; + const choiceId = getChoiceIdByValue(responseData.toString(), question); + return ( + + ); } else if (Array.isArray(responseData)) { - return ; + const itemsArray = responseData.map((choice) => { + const choiceId = getChoiceIdByValue(choice, question); + return { value: choice, id: choiceId }; + }); + return ( + <> + {questionType === TSurveyQuestionTypeEnum.Ranking ? ( + + ) : ( + + )} + + ); } break; - case TSurveyQuestionTypeEnum.Ranking: - if (Array.isArray(responseData)) { - return ; - } + default: if ( typeof responseData === "string" || diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx index fcfb6c61de..db23dee7a1 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx @@ -76,7 +76,7 @@ export const SingleResponseCardBody = ({
{isValidValue(response.data[question.id]) ? (
-

+

{formatTextWithSlashes( parseRecallInfo( getLocalizedValue(question.headline, "default"), @@ -92,6 +92,7 @@ export const SingleResponseCardBody = ({ survey={survey} responseData={response.data[question.id]} language={response.language} + showId={true} />

diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx index 13a26263f3..b773940a23 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx @@ -3,6 +3,7 @@ import { timeSince } from "@/lib/time"; import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; @@ -162,19 +163,21 @@ export const SingleResponseCardHeader = ({ {response.contact?.id ? ( user ? (

{displayIdentifier}

+ {response.contact.userId && } ) : ( -
+

{displayIdentifier}

+ {response.contact.userId && }
) ) : ( diff --git a/apps/web/modules/survey/editor/components/advanced-settings.test.tsx b/apps/web/modules/survey/editor/components/advanced-settings.test.tsx index b10365f00f..0645446c71 100644 --- a/apps/web/modules/survey/editor/components/advanced-settings.test.tsx +++ b/apps/web/modules/survey/editor/components/advanced-settings.test.tsx @@ -43,6 +43,16 @@ vi.mock("@/modules/survey/editor/components/conditional-logic", () => ({ ), })); +vi.mock("@/modules/survey/editor/components/option-ids", () => ({ + OptionIds: ({ question, selectedLanguageCode }: any) => ( +
+ {question.id} + {question.type} + {selectedLanguageCode} +
+ ), +})); + vi.mock("@/modules/survey/editor/components/update-question-id", () => ({ UpdateQuestionId: ({ question, questionIdx, localSurvey, updateQuestion }: any) => (
@@ -110,6 +120,7 @@ describe("AdvancedSettings", () => { questionIdx={questionIdx} localSurvey={mockSurvey} updateQuestion={mockUpdateQuestion} + selectedLanguageCode="en" /> ); @@ -175,6 +186,7 @@ describe("AdvancedSettings", () => { questionIdx={questionIdx} localSurvey={mockSurvey} updateQuestion={mockUpdateQuestion} + selectedLanguageCode="fr" /> ); @@ -259,6 +271,7 @@ describe("AdvancedSettings", () => { questionIdx={questionIdx} localSurvey={mockSurvey} updateQuestion={mockUpdateQuestion} + selectedLanguageCode="de" />
); @@ -409,4 +422,230 @@ describe("AdvancedSettings", () => { expect(mockUpdateQuestion).toHaveBeenCalledTimes(2); expect(mockUpdateQuestion).toHaveBeenLastCalledWith(1, { id: "new-id" }); }); + + // New tests for OptionIds functionality + test("renders OptionIds component for multiple choice single questions", () => { + const mockQuestion = { + id: "mc-question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Multiple Choice Question" }, + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey1", + questions: [mockQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.getByTestId("option-ids")).toBeInTheDocument(); + expect(screen.getByTestId("option-ids-question-id")).toHaveTextContent("mc-question"); + expect(screen.getByTestId("option-ids-question-type")).toHaveTextContent("multipleChoiceSingle"); + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("en"); + }); + + test("renders OptionIds component for multiple choice multi questions", () => { + const mockQuestion = { + id: "mcm-question", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice Multi Question" }, + choices: [ + { id: "choice1", label: { default: "Option A" } }, + { id: "choice2", label: { default: "Option B" } }, + { id: "choice3", label: { default: "Option C" } }, + ], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey2", + questions: [mockQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.getByTestId("option-ids")).toBeInTheDocument(); + expect(screen.getByTestId("option-ids-question-id")).toHaveTextContent("mcm-question"); + expect(screen.getByTestId("option-ids-question-type")).toHaveTextContent("multipleChoiceMulti"); + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("fr"); + }); + + test("renders OptionIds component for picture selection questions", () => { + const mockQuestion = { + id: "pic-question", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Picture Selection Question" }, + choices: [ + { id: "pic1", imageUrl: "https://example.com/img1.jpg" }, + { id: "pic2", imageUrl: "https://example.com/img2.jpg" }, + ], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey3", + questions: [mockQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.getByTestId("option-ids")).toBeInTheDocument(); + expect(screen.getByTestId("option-ids-question-id")).toHaveTextContent("pic-question"); + expect(screen.getByTestId("option-ids-question-type")).toHaveTextContent("pictureSelection"); + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("de"); + }); + + test("renders OptionIds component for ranking questions", () => { + const mockQuestion = { + id: "rank-question", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Ranking Question" }, + choices: [ + { id: "rank1", label: { default: "First Option" } }, + { id: "rank2", label: { default: "Second Option" } }, + { id: "rank3", label: { default: "Third Option" } }, + ], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey4", + questions: [mockQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.getByTestId("option-ids")).toBeInTheDocument(); + expect(screen.getByTestId("option-ids-question-id")).toHaveTextContent("rank-question"); + expect(screen.getByTestId("option-ids-question-type")).toHaveTextContent("ranking"); + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("es"); + }); + + test("does not render OptionIds component for non-choice question types", () => { + const openTextQuestion = { + id: "open-text-question", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text Question" }, + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey5", + questions: [openTextQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.queryByTestId("option-ids")).not.toBeInTheDocument(); + expect(screen.getByTestId("conditional-logic")).toBeInTheDocument(); + expect(screen.getByTestId("update-question-id")).toBeInTheDocument(); + }); + + test("does not render OptionIds component for rating questions", () => { + const ratingQuestion = { + id: "rating-question", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rating Question" }, + scale: 5, + range: [1, 5], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey6", + questions: [ratingQuestion], + } as unknown as TSurvey; + + render( + + ); + + expect(screen.queryByTestId("option-ids")).not.toBeInTheDocument(); + expect(screen.getByTestId("conditional-logic")).toBeInTheDocument(); + expect(screen.getByTestId("update-question-id")).toBeInTheDocument(); + }); + + test("passes correct selectedLanguageCode to OptionIds component", () => { + const mockQuestion = { + id: "test-question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Test Question" }, + choices: [{ id: "choice1", label: { default: "Option 1" } }], + } as unknown as TSurveyQuestion; + + const mockSurvey = { + id: "survey8", + questions: [mockQuestion], + } as unknown as TSurvey; + + const { rerender } = render( + + ); + + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("ja"); + + // Test with different language code + rerender( + + ); + + expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("zh"); + }); }); diff --git a/apps/web/modules/survey/editor/components/advanced-settings.tsx b/apps/web/modules/survey/editor/components/advanced-settings.tsx index 0677305985..3ad46b911a 100644 --- a/apps/web/modules/survey/editor/components/advanced-settings.tsx +++ b/apps/web/modules/survey/editor/components/advanced-settings.tsx @@ -1,12 +1,14 @@ import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic"; +import { OptionIds } from "@/modules/survey/editor/components/option-ids"; import { UpdateQuestionId } from "@/modules/survey/editor/components/update-question-id"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; interface AdvancedSettingsProps { question: TSurveyQuestion; questionIdx: number; localSurvey: TSurvey; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + selectedLanguageCode: string; } export const AdvancedSettings = ({ @@ -14,7 +16,14 @@ export const AdvancedSettings = ({ questionIdx, localSurvey, updateQuestion, + selectedLanguageCode, }: AdvancedSettingsProps) => { + const showOptionIds = + question.type === TSurveyQuestionTypeEnum.PictureSelection || + question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti || + question.type === TSurveyQuestionTypeEnum.Ranking; + return (
+ + {showOptionIds && }
); }; diff --git a/apps/web/modules/survey/editor/components/option-ids.test.tsx b/apps/web/modules/survey/editor/components/option-ids.test.tsx new file mode 100644 index 0000000000..05783d47bd --- /dev/null +++ b/apps/web/modules/survey/editor/components/option-ids.test.tsx @@ -0,0 +1,177 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { OptionIds } from "./option-ids"; + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((label: any, _languageCode: string) => label.default || ""), +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id, label }: { id: string; label?: string }) => ( +
+ {label ? `${label}: ${id}` : id} +
+ ), +})); + +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ + children, + htmlFor, + className, + }: { + children: React.ReactNode; + htmlFor?: string; + className?: string; + }) => ( + + ), +})); + +vi.mock("next/image", () => ({ + __esModule: true, + default: ({ src, alt, className }: { src: string; alt: string; className: string }) => ( + {alt} + ), +})); + +describe("OptionIds", () => { + const mockMultipleChoiceQuestion: TSurveyQuestion = { + id: "question1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Test Question" }, + required: false, + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + { id: "choice3", label: { default: "Option 3" } }, + ], + }; + + const mockRankingQuestion: TSurveyQuestion = { + id: "question2", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Ranking Question" }, + required: false, + choices: [ + { id: "rank1", label: { default: "First Choice" } }, + { id: "rank2", label: { default: "Second Choice" } }, + ], + }; + + const mockPictureSelectionQuestion: TSurveyQuestion = { + id: "question3", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Picture Question" }, + required: false, + allowMulti: false, + choices: [ + { id: "pic1", imageUrl: "https://example.com/image1.jpg" }, + { id: "pic2", imageUrl: "https://example.com/image2.jpg" }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders multiple choice question option IDs", () => { + render(); + + expect(screen.getByTestId("label")).toBeInTheDocument(); + expect(screen.getByText("common.option_ids")).toBeInTheDocument(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[0]).toHaveAttribute("data-label", "Option 1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + expect(idBadges[1]).toHaveAttribute("data-label", "Option 2"); + expect(idBadges[2]).toHaveAttribute("data-id", "choice3"); + expect(idBadges[2]).toHaveAttribute("data-label", "Option 3"); + }); + + test("renders multiple choice multi question option IDs", () => { + const multiChoiceQuestion = { + ...mockMultipleChoiceQuestion, + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + } as TSurveyQuestion; + + render(); + + expect(screen.getByTestId("label")).toBeInTheDocument(); + expect(screen.getByText("common.option_ids")).toBeInTheDocument(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + }); + + test("renders ranking question option IDs", () => { + render(); + + expect(screen.getByTestId("label")).toBeInTheDocument(); + expect(screen.getByText("common.option_ids")).toBeInTheDocument(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "rank1"); + expect(idBadges[0]).toHaveAttribute("data-label", "First Choice"); + expect(idBadges[1]).toHaveAttribute("data-id", "rank2"); + expect(idBadges[1]).toHaveAttribute("data-label", "Second Choice"); + }); + + test("renders picture selection question option IDs with images", () => { + render(); + + expect(screen.getByTestId("label")).toBeInTheDocument(); + expect(screen.getByText("common.option_ids")).toBeInTheDocument(); + + const images = screen.getAllByTestId("choice-image"); + expect(images).toHaveLength(2); + expect(images[0]).toHaveAttribute("src", "https://example.com/image1.jpg"); + expect(images[0]).toHaveAttribute("alt", "Choice pic1"); + expect(images[1]).toHaveAttribute("src", "https://example.com/image2.jpg"); + expect(images[1]).toHaveAttribute("alt", "Choice pic2"); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "pic1"); + expect(idBadges[1]).toHaveAttribute("data-id", "pic2"); + }); + + test("handles picture selection with missing imageUrl", () => { + const questionWithMissingImage = { + ...mockPictureSelectionQuestion, + choices: [ + { id: "pic1", imageUrl: "https://example.com/image1.jpg" }, + { id: "pic2", imageUrl: "" }, + ], + } as TSurveyQuestion; + + render(); + + const images = screen.getAllByTestId("choice-image"); + expect(images).toHaveLength(1); + expect(images[0]).toHaveAttribute("src", "https://example.com/image1.jpg"); + // Next.js Image component doesn't render src attribute when imageUrl is empty + }); + + test("uses correct language code for localized values", () => { + const getLocalizedValueMock = vi.mocked(getLocalizedValue); + + render(); + + expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 1" }, "fr"); + expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 2" }, "fr"); + expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 3" }, "fr"); + }); +}); diff --git a/apps/web/modules/survey/editor/components/option-ids.tsx b/apps/web/modules/survey/editor/components/option-ids.tsx new file mode 100644 index 0000000000..b437079fab --- /dev/null +++ b/apps/web/modules/survey/editor/components/option-ids.tsx @@ -0,0 +1,68 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; +import { Label } from "@/modules/ui/components/label"; +import { useTranslate } from "@tolgee/react"; +import Image from "next/image"; +import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +interface OptionIdsProps { + question: TSurveyQuestion; + selectedLanguageCode: string; +} + +export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) => { + const { t } = useTranslate(); + + const renderChoiceIds = () => { + switch (question.type) { + case TSurveyQuestionTypeEnum.MultipleChoiceSingle: + case TSurveyQuestionTypeEnum.MultipleChoiceMulti: + case TSurveyQuestionTypeEnum.Ranking: + return ( +
+ {question.choices.map((choice) => ( +
+ +
+ ))} +
+ ); + + case TSurveyQuestionTypeEnum.PictureSelection: + return ( +
+ {question.choices.map((choice) => { + const imageUrl = choice.imageUrl; + if (!imageUrl) return null; + return ( +
+
+ {`Choice +
+ +
+ ); + })} +
+ ); + + default: + return <>; + } + }; + + return ( +
+ +
{renderChoiceIds()}
+
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/question-card.tsx b/apps/web/modules/survey/editor/components/question-card.tsx index 63dc7e9301..fdab7e1cdd 100644 --- a/apps/web/modules/survey/editor/components/question-card.tsx +++ b/apps/web/modules/survey/editor/components/question-card.tsx @@ -559,6 +559,7 @@ export const QuestionCard = ({ questionIdx={questionIdx} localSurvey={localSurvey} updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} /> diff --git a/apps/web/modules/ui/components/picture-selection-response/index.test.tsx b/apps/web/modules/ui/components/picture-selection-response/index.test.tsx index cf1de432f1..f85294c4a9 100644 --- a/apps/web/modules/ui/components/picture-selection-response/index.test.tsx +++ b/apps/web/modules/ui/components/picture-selection-response/index.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; import { PictureSelectionResponse } from "./index"; @@ -7,7 +7,16 @@ import { PictureSelectionResponse } from "./index"; vi.mock("next/image", () => ({ __esModule: true, default: ({ src, alt, className }: { src: string; alt: string; className: string }) => ( - {alt} + {alt} + ), +})); + +// Mock the IdBadge component +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
), })); @@ -33,7 +42,7 @@ describe("PictureSelectionResponse", () => { test("renders images for selected choices", () => { const { container } = render( - + ); const images = container.querySelectorAll("img"); @@ -44,13 +53,20 @@ describe("PictureSelectionResponse", () => { test("renders nothing when selected is not an array", () => { // @ts-ignore - Testing invalid prop type - const { container } = render(); + const { container } = render( + + ); expect(container.firstChild).toBeNull(); }); test("handles expanded layout", () => { const { container } = render( - + ); const wrapper = container.firstChild as HTMLElement; @@ -59,7 +75,12 @@ describe("PictureSelectionResponse", () => { test("handles non-expanded layout", () => { const { container } = render( - + ); const wrapper = container.firstChild as HTMLElement; @@ -68,10 +89,75 @@ describe("PictureSelectionResponse", () => { test("handles choices not in the mapping", () => { const { container } = render( - + ); const images = container.querySelectorAll("img"); expect(images).toHaveLength(1); // Only one valid image should be rendered }); + + test("shows IdBadge when showId=true", () => { + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + }); + + test("does not show IdBadge when showId=false", () => { + render( + + ); + + expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument(); + }); + + test("applies column layout when showId=true", () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("flex-col"); + }); + + test("does not apply column layout when showId=false", () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).not.toHaveClass("flex-col"); + }); + + test("renders images and IdBadges in same container when showId=true", () => { + render( + + ); + + const images = screen.getAllByTestId("choice-image"); + const idBadges = screen.getAllByTestId("id-badge"); + + expect(images).toHaveLength(2); + expect(idBadges).toHaveLength(2); + + // Both images and badges should be in the same container + const containers = screen.getAllByText("ID: choice1")[0].closest("div"); + expect(containers).toBeInTheDocument(); + }); + + test("handles default props correctly", () => { + render(); + + const images = screen.getAllByTestId("choice-image"); + expect(images).toHaveLength(1); + expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/modules/ui/components/picture-selection-response/index.tsx b/apps/web/modules/ui/components/picture-selection-response/index.tsx index 5d21f7bc8e..487cd179d9 100644 --- a/apps/web/modules/ui/components/picture-selection-response/index.tsx +++ b/apps/web/modules/ui/components/picture-selection-response/index.tsx @@ -1,18 +1,21 @@ "use client"; import { cn } from "@/lib/cn"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import Image from "next/image"; interface PictureSelectionResponseProps { choices: { id: string; imageUrl: string }[]; selected: string | number | string[]; isExpanded?: boolean; + showId: boolean; } export const PictureSelectionResponse = ({ choices, selected, isExpanded = true, + showId, }: PictureSelectionResponseProps) => { if (typeof selected !== "object") return null; @@ -25,17 +28,22 @@ export const PictureSelectionResponse = ({ ); return ( -
+
{selected.map((id) => ( -
+
{choiceImageMapping[id] && ( - {choiceImageMapping[id].split("/").pop() + <> +
+ {choiceImageMapping[id].split("/").pop() +
+ {showId && } + )}
))} diff --git a/apps/web/modules/ui/components/ranking-response/index.test.tsx b/apps/web/modules/ui/components/ranking-response/index.test.tsx index 2da2a20943..8f4aac43c1 100644 --- a/apps/web/modules/ui/components/ranking-response/index.test.tsx +++ b/apps/web/modules/ui/components/ranking-response/index.test.tsx @@ -1,17 +1,30 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { RankingResponse } from "./index"; +// Mock the IdBadge component +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + describe("RankingResponse", () => { afterEach(() => { cleanup(); }); - test("renders ranked items correctly", () => { - const rankedItems = ["Apple", "Banana", "Cherry"]; + test("renders ranked items correctly with new object format", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + { value: "Cherry", id: "choice3" }, + ]; - render(); + render(); expect(screen.getByText("#1")).toBeInTheDocument(); expect(screen.getByText("#2")).toBeInTheDocument(); @@ -21,10 +34,57 @@ describe("RankingResponse", () => { expect(screen.getByText("Cherry")).toBeInTheDocument(); }); - test("applies expanded layout", () => { - const rankedItems = ["Apple", "Banana"]; + test("renders ranked items with undefined id", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: undefined }, + { value: "Cherry", id: "choice3" }, + ]; - const { container } = render(); + render(); + + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); // Only items with defined ids should have badges + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice3"); + }); + + test("shows IdBadge when showId=true", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + ]; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + }); + + test("does not show IdBadge when showId=false", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + ]; + + render(); + + expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument(); + }); + + test("applies expanded layout", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + ]; + + const { container } = render(); const parentDiv = container.firstChild; expect(parentDiv).not.toHaveClass("flex"); @@ -32,19 +92,44 @@ describe("RankingResponse", () => { }); test("applies non-expanded layout", () => { - const rankedItems = ["Apple", "Banana"]; + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + ]; - const { container } = render(); + const { container } = render(); const parentDiv = container.firstChild; expect(parentDiv).toHaveClass("flex"); expect(parentDiv).toHaveClass("space-x-2"); }); - test("handles empty values", () => { - const rankedItems = ["Apple", "", "Cherry"]; + test("applies column layout when showId=true", () => { + const rankedItems = [{ value: "Apple", id: "choice1" }]; - render(); + const { container } = render(); + + const parentDiv = container.firstChild; + expect(parentDiv).toHaveClass("flex-col"); + }); + + test("does not apply column layout when showId=false", () => { + const rankedItems = [{ value: "Apple", id: "choice1" }]; + + const { container } = render(); + + const parentDiv = container.firstChild; + expect(parentDiv).not.toHaveClass("flex-col"); + }); + + test("handles empty values", () => { + const rankedItems = [ + { value: "Apple", id: "choice1" }, + { value: "", id: "choice2" }, + { value: "Cherry", id: "choice3" }, + ]; + + render(); expect(screen.getByText("#1")).toBeInTheDocument(); expect(screen.getByText("#3")).toBeInTheDocument(); @@ -54,9 +139,13 @@ describe("RankingResponse", () => { }); test("displays items in the correct order", () => { - const rankedItems = ["First", "Second", "Third"]; + const rankedItems = [ + { value: "First", id: "choice1" }, + { value: "Second", id: "choice2" }, + { value: "Third", id: "choice3" }, + ]; - render(); + render(); const rankNumbers = screen.getAllByText(/^#\d$/); const rankItems = screen.getAllByText(/(First|Second|Third)/); @@ -72,11 +161,33 @@ describe("RankingResponse", () => { }); test("renders with RTL support", () => { - const rankedItems = ["תפוח", "בננה", "דובדבן"]; + const rankedItems = [ + { value: "תפוח", id: "choice1" }, + { value: "בננה", id: "choice2" }, + { value: "דובדבן", id: "choice3" }, + ]; - const { container } = render(); + const { container } = render(); const parentDiv = container.firstChild as HTMLElement; expect(parentDiv).toHaveAttribute("dir", "auto"); }); + + test("renders items and badges together when showId=true", () => { + const rankedItems = [ + { value: "First", id: "choice1" }, + { value: "Second", id: "choice2" }, + ]; + + render(); + + // Check that both items and badges are rendered + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + }); }); diff --git a/apps/web/modules/ui/components/ranking-response/index.tsx b/apps/web/modules/ui/components/ranking-response/index.tsx index ac75ad566d..8756daae0b 100644 --- a/apps/web/modules/ui/components/ranking-response/index.tsx +++ b/apps/web/modules/ui/components/ranking-response/index.tsx @@ -1,19 +1,24 @@ import { cn } from "@/lib/cn"; +import { IdBadge } from "@/modules/ui/components/id-badge"; interface RankingResponseProps { - value: string[]; + value: { value: string; id: string | undefined }[]; isExpanded: boolean; + showId: boolean; } -export const RankingResponse = ({ value, isExpanded }: RankingResponseProps) => { +export const RankingResponse = ({ value, isExpanded, showId }: RankingResponseProps) => { return ( -
+
{value.map( (item, index) => - item && ( -
- #{index + 1} -
{item}
+ item.value && ( +
+ #{index + 1} +
{item.value}
+ {item.id && showId && }
) )} diff --git a/apps/web/modules/ui/components/response-badges/index.test.tsx b/apps/web/modules/ui/components/response-badges/index.test.tsx index d52550c597..98d538db11 100644 --- a/apps/web/modules/ui/components/response-badges/index.test.tsx +++ b/apps/web/modules/ui/components/response-badges/index.test.tsx @@ -1,16 +1,25 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { ResponseBadges } from "./index"; +// Mock the IdBadge component +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + describe("ResponseBadges", () => { afterEach(() => { cleanup(); }); - test("renders string items correctly", () => { - const items = ["Apple", "Banana", "Cherry"]; - render(); + test("renders items with value property correctly", () => { + const items = [{ value: "Apple" }, { value: "Banana" }, { value: "Cherry" }]; + render(); expect(screen.getByText("Apple")).toBeInTheDocument(); expect(screen.getByText("Banana")).toBeInTheDocument(); @@ -28,37 +37,109 @@ describe("ResponseBadges", () => { }); }); - test("renders number items correctly", () => { - const items = [1, 2, 3]; - render(); + test("renders number items with value property correctly", () => { + const items = [{ value: 1 }, { value: 2 }, { value: 3 }]; + render(); expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.getByText("2")).toBeInTheDocument(); expect(screen.getByText("3")).toBeInTheDocument(); }); - test("applies expanded layout when isExpanded=true", () => { - const items = ["Apple", "Banana", "Cherry"]; + test("renders items with id property and shows IdBadge when showId=true", () => { + const items = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + { value: "Cherry" }, // No id property + ]; + render(); - const { container } = render(); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + + // Should show IdBadges for items with id + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + }); + + test("does not render IdBadge when showId=false", () => { + const items = [ + { value: "Apple", id: "choice1" }, + { value: "Banana", id: "choice2" }, + ]; + render(); + + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + + // Should not show IdBadges when showId=false + expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument(); + }); + + test("does not render IdBadge when item has no id property", () => { + const items = [{ value: "Apple" }, { value: "Banana" }]; + render(); + + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + + // Should not show IdBadges when items have no id + expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument(); + }); + + test("applies expanded layout when isExpanded=true", () => { + const items = [{ value: "Apple" }, { value: "Banana" }, { value: "Cherry" }]; + + const { container } = render(); const wrapper = container.firstChild; expect(wrapper).toHaveClass("flex-wrap"); }); test("does not apply expanded layout when isExpanded=false", () => { - const items = ["Apple", "Banana", "Cherry"]; + const items = [{ value: "Apple" }, { value: "Banana" }, { value: "Cherry" }]; - const { container } = render(); + const { container } = render(); const wrapper = container.firstChild; expect(wrapper).not.toHaveClass("flex-wrap"); }); - test("applies default styles correctly", () => { - const items = ["Apple"]; + test("applies column layout when showId=true", () => { + const items = [{ value: "Apple", id: "choice1" }]; - const { container } = render(); + const { container } = render(); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass("flex-col"); + }); + + test("does not apply column layout when showId=false", () => { + const items = [{ value: "Apple", id: "choice1" }]; + + const { container } = render(); + + const wrapper = container.firstChild; + expect(wrapper).not.toHaveClass("flex-col"); + }); + + test("renders with icon when provided", () => { + const items = [{ value: "Apple" }]; + const icon = 📱; + + render(); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + expect(screen.getByText("📱")).toBeInTheDocument(); + }); + + test("applies default styles correctly", () => { + const items = [{ value: "Apple" }]; + + const { container } = render(); const wrapper = container.firstChild; expect(wrapper).toHaveClass("my-1"); diff --git a/apps/web/modules/ui/components/response-badges/index.tsx b/apps/web/modules/ui/components/response-badges/index.tsx index 6204b5f50c..607ce09d78 100644 --- a/apps/web/modules/ui/components/response-badges/index.tsx +++ b/apps/web/modules/ui/components/response-badges/index.tsx @@ -1,20 +1,30 @@ import { cn } from "@/lib/cn"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import React from "react"; interface ResponseBadgesProps { - items: string[] | number[]; + items: { value: string | number; id?: string }[]; isExpanded?: boolean; icon?: React.ReactNode; + showId: boolean; } -export const ResponseBadges: React.FC = ({ items, isExpanded = false, icon }) => { +export const ResponseBadges: React.FC = ({ + items, + isExpanded = false, + icon, + showId, +}) => { return ( -
+
{items.map((item, index) => ( - - {icon && {icon}} - {item} - +
+ + {icon && {icon}} + {item.value} + + {item.id && showId && } +
))}
); diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index 1112180d6e..30915eac1a 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -141,8 +141,8 @@ test.describe("JS Package Test", async () => { await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible(); await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible(); await expect(page.getByText("CTR100%")).toBeVisible(); - await expect(page.getByText("Somewhat disappointed100%")).toBeVisible(); - await expect(page.getByText("Founder100%")).toBeVisible(); + await expect(page.getByText("Somewhat disappointed")).toBeVisible(); + await expect(page.getByText("Founder")).toBeVisible(); await expect(page.getByText("People who believe that PMF").first()).toBeVisible(); await expect(page.getByText("Much higher response rates!").first()).toBeVisible(); await expect(page.getByText("Make this end to end test").first()).toBeVisible();