feat: surface option ids (#6339)

This commit is contained in:
Dhruwang Jariwala
2025-08-05 09:33:12 +05:30
committed by GitHub
parent 3b07a6d013
commit 287c45f996
31 changed files with 1689 additions and 275 deletions

View File

@@ -94,7 +94,7 @@ const getQuestionColumnsData = (
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={choiceId || index} id={choiceId} />
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
))}
</div>
);
@@ -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 <ResponseBadges items={[status]} />;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
},
};
@@ -325,9 +327,10 @@ export const generateResponseTableColumns = (
const tagsArray = tags.map((tag) => tag.name);
return (
<ResponseBadges
items={tagsArray}
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
}

View File

@@ -7,6 +7,13 @@ vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
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(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
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(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
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(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
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");
});
});

View File

@@ -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 = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
))}
)}
</div>
)}
</Fragment>
);
})}
</div>
</div>
);

View File

@@ -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) => <div data-testid="header">{additionalInfo}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
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(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
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(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
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");
});
});

View File

@@ -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 (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -44,43 +47,48 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
{results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
))}
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
);
})}
</div>
</div>
);

View File

@@ -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 }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
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(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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(<RankingSummary questionSummary={questionSummary} survey={survey} />);
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();
});
});

View File

@@ -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
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center space-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
<span>{t("environments.surveys.summary.average")}</span>
</span>
</div>
</div>
</div>
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
</div>
)}
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -244,7 +244,6 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<RankingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
surveyType={survey.type}
survey={survey}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,9 +12,10 @@ vi.mock("@/modules/ui/components/file-upload-response", () => ({
),
}));
vi.mock("@/modules/ui/components/picture-selection-response", () => ({
PictureSelectionResponse: ({ selected, isExpanded }: any) => (
<div data-testid="PictureSelectionResponse">
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"})
PictureSelectionResponse: ({ selected, isExpanded, showId }: any) => (
<div data-testid="PictureSelectionResponse" data-show-id={showId}>
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) showId:{" "}
{String(showId)}
</div>
),
}));
@@ -22,10 +23,28 @@ vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: any) => <div data-testid="ArrayResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
ResponseBadges: ({ items, showId }: any) => (
<div data-testid="ResponseBadges" data-show-id={showId}>
{Array.isArray(items)
? items
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: items}{" "}
showId: {String(showId)}
</div>
),
}));
vi.mock("@/modules/ui/components/ranking-response", () => ({
RankingResponse: ({ value }: any) => <div data-testid="RankingResponse">{value.join(",")}</div>,
RankingResponse: ({ value, showId }: any) => (
<div data-testid="RankingResponse" data-show-id={showId}>
{Array.isArray(value)
? value
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: value}{" "}
showId: {String(showId)}
</div>
),
}));
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(
<RenderResponse responseData={4} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={4}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse responseData={5} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={5}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse
responseData={"option1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse
responseData={["opt1", "opt2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse responseData={9} question={question} survey={defaultSurvey} language={dummyLanguage} />
<RenderResponse
responseData={9}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse
responseData={["first", "second"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse
responseData={["choice1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
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(
<RenderResponse
responseData={["Option 1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
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(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveTextContent("Option 1:choice1");
});
});

View File

@@ -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<RenderResponseProps> = ({
@@ -35,6 +37,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
survey,
language,
isExpanded = true,
showId,
}) => {
if (
(typeof responseData === "string" && responseData === "") ||
@@ -81,6 +84,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
isExpanded={isExpanded}
showId={showId}
/>
);
}
@@ -121,9 +125,10 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -132,9 +137,10 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -143,26 +149,43 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[capitalizeFirstLetter(responseData.toString())]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
break;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.NPS:
case TSurveyQuestionTypeEnum.Ranking:
if (typeof responseData === "string" || typeof responseData === "number") {
return <ResponseBadges items={[responseData.toString()]} isExpanded={isExpanded} />;
const choiceId = getChoiceIdByValue(responseData.toString(), question);
return (
<ResponseBadges
items={[{ value: responseData.toString(), id: choiceId }]}
isExpanded={isExpanded}
showId={showId}
/>
);
} else if (Array.isArray(responseData)) {
return <ResponseBadges items={responseData} isExpanded={isExpanded} />;
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, question);
return { value: choice, id: choiceId };
});
return (
<>
{questionType === TSurveyQuestionTypeEnum.Ranking ? (
<RankingResponse value={itemsArray} isExpanded={isExpanded} showId={showId} />
) : (
<ResponseBadges items={itemsArray} isExpanded={isExpanded} showId={showId} />
)}
</>
);
}
break;
case TSurveyQuestionTypeEnum.Ranking:
if (Array.isArray(responseData)) {
return <RankingResponse value={responseData} isExpanded={isExpanded} />;
}
default:
if (
typeof responseData === "string" ||

View File

@@ -76,7 +76,7 @@ export const SingleResponseCardBody = ({
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="text-sm text-slate-500">
<p className="mb-1 text-sm text-slate-500">
{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}
/>
</div>
</div>

View File

@@ -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 ? (
<Link
className="flex items-center"
className="flex items-center space-x-2"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</Link>
) : (
<div className="flex items-center">
<div className="flex items-center space-x-2">
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</div>
)
) : (

View File

@@ -43,6 +43,16 @@ vi.mock("@/modules/survey/editor/components/conditional-logic", () => ({
),
}));
vi.mock("@/modules/survey/editor/components/option-ids", () => ({
OptionIds: ({ question, selectedLanguageCode }: any) => (
<div data-testid="option-ids">
<span data-testid="option-ids-question-id">{question.id}</span>
<span data-testid="option-ids-question-type">{question.type}</span>
<span data-testid="option-ids-language-code">{selectedLanguageCode}</span>
</div>
),
}));
vi.mock("@/modules/survey/editor/components/update-question-id", () => ({
UpdateQuestionId: ({ question, questionIdx, localSurvey, updateQuestion }: any) => (
<div data-testid="update-question-id">
@@ -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"
/>
</div>
);
@@ -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(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="en"
/>
);
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(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="fr"
/>
);
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(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="de"
/>
);
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(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="es"
/>
);
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(
<AdvancedSettings
question={openTextQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="en"
/>
);
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(
<AdvancedSettings
question={ratingQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="en"
/>
);
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(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="ja"
/>
);
expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("ja");
// Test with different language code
rerender(
<AdvancedSettings
question={mockQuestion}
questionIdx={0}
localSurvey={mockSurvey}
updateQuestion={vi.fn()}
selectedLanguageCode="zh"
/>
);
expect(screen.getByTestId("option-ids-language-code")).toHaveTextContent("zh");
});
});

View File

@@ -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 (
<div className="flex flex-col gap-4">
<ConditionalLogic
@@ -30,6 +39,8 @@ export const AdvancedSettings = ({
localSurvey={localSurvey}
updateQuestion={updateQuestion}
/>
{showOptionIds && <OptionIds question={question} selectedLanguageCode={selectedLanguageCode} />}
</div>
);
};

View File

@@ -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 }) => (
<div data-testid="id-badge" data-id={id} data-label={label}>
{label ? `${label}: ${id}` : id}
</div>
),
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({
children,
htmlFor,
className,
}: {
children: React.ReactNode;
htmlFor?: string;
className?: string;
}) => (
<label htmlFor={htmlFor} className={className} data-testid="label">
{children}
</label>
),
}));
vi.mock("next/image", () => ({
__esModule: true,
default: ({ src, alt, className }: { src: string; alt: string; className: string }) => (
<img src={src} alt={alt} className={className} data-testid="choice-image" />
),
}));
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(<OptionIds question={mockMultipleChoiceQuestion} selectedLanguageCode="en" />);
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(<OptionIds question={multiChoiceQuestion} selectedLanguageCode="en" />);
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(<OptionIds question={mockRankingQuestion} selectedLanguageCode="en" />);
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(<OptionIds question={mockPictureSelectionQuestion} selectedLanguageCode="en" />);
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(<OptionIds question={questionWithMissingImage} selectedLanguageCode="en" />);
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(<OptionIds question={mockMultipleChoiceQuestion} selectedLanguageCode="fr" />);
expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 1" }, "fr");
expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 2" }, "fr");
expect(getLocalizedValueMock).toHaveBeenCalledWith({ default: "Option 3" }, "fr");
});
});

View File

@@ -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 (
<div className="flex flex-col gap-2">
{question.choices.map((choice) => (
<div key={choice.id}>
<IdBadge id={choice.id} label={getLocalizedValue(choice.label, selectedLanguageCode)} />
</div>
))}
</div>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<div className="flex flex-col gap-3">
{question.choices.map((choice) => {
const imageUrl = choice.imageUrl;
if (!imageUrl) return null;
return (
<div key={choice.id} className="flex items-center gap-3">
<div className="relative h-24 w-40 overflow-hidden rounded-lg bg-gray-100">
<Image
src={imageUrl}
alt={`Choice ${choice.id}`}
fill
sizes="(max-width: 768px) 100vw, 160px"
style={{ objectFit: "cover" }}
quality={75}
className="rounded-lg transition-opacity duration-200"
/>
</div>
<IdBadge id={choice.id} />
</div>
);
})}
</div>
);
default:
return <></>;
}
};
return (
<div className="space-y-3">
<Label className="text-sm font-medium text-gray-700">{t("common.option_ids")}</Label>
<div className="w-full">{renderChoiceIds()}</div>
</div>
);
};

View File

@@ -559,6 +559,7 @@ export const QuestionCard = ({
questionIdx={questionIdx}
localSurvey={localSurvey}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -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 }) => (
<img src={src} alt={alt} className={className} />
<img src={src} alt={alt} className={className} data-testid="choice-image" />
),
}));
// Mock the IdBadge component
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
@@ -33,7 +42,7 @@ describe("PictureSelectionResponse", () => {
test("renders images for selected choices", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice3"]} />
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice3"]} showId={false} />
);
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(<PictureSelectionResponse choices={mockChoices} selected="choice1" />);
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected="choice1" showId={false} />
);
expect(container.firstChild).toBeNull();
});
test("handles expanded layout", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice2"]} isExpanded={true} />
<PictureSelectionResponse
choices={mockChoices}
selected={["choice1", "choice2"]}
isExpanded={true}
showId={false}
/>
);
const wrapper = container.firstChild as HTMLElement;
@@ -59,7 +75,12 @@ describe("PictureSelectionResponse", () => {
test("handles non-expanded layout", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice2"]} isExpanded={false} />
<PictureSelectionResponse
choices={mockChoices}
selected={["choice1", "choice2"]}
isExpanded={false}
showId={false}
/>
);
const wrapper = container.firstChild as HTMLElement;
@@ -68,10 +89,75 @@ describe("PictureSelectionResponse", () => {
test("handles choices not in the mapping", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "nonExistentChoice"]} />
<PictureSelectionResponse
choices={mockChoices}
selected={["choice1", "nonExistentChoice"]}
showId={false}
/>
);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(1); // Only one valid image should be rendered
});
test("shows IdBadge when showId=true", () => {
render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice2"]} showId={true} />
);
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(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice2"]} showId={false} />
);
expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument();
});
test("applies column layout when showId=true", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1"]} showId={true} />
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("flex-col");
});
test("does not apply column layout when showId=false", () => {
const { container } = render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1"]} showId={false} />
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).not.toHaveClass("flex-col");
});
test("renders images and IdBadges in same container when showId=true", () => {
render(
<PictureSelectionResponse choices={mockChoices} selected={["choice1", "choice2"]} showId={true} />
);
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(<PictureSelectionResponse choices={mockChoices} selected={["choice1"]} showId={false} />);
const images = screen.getAllByTestId("choice-image");
expect(images).toHaveLength(1);
expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument();
});
});

View File

@@ -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 (
<div className={cn("my-1 flex gap-x-5 gap-y-4", isExpanded ? "flex-wrap" : "")}>
<div className={cn("my-1 flex gap-x-5 gap-y-4", isExpanded ? "flex-wrap" : "", showId ? "flex-col" : "")}>
{selected.map((id) => (
<div className="relative h-10 w-16" key={id}>
<div className="flex items-center gap-2" key={id}>
{choiceImageMapping[id] && (
<Image
src={choiceImageMapping[id]}
alt={choiceImageMapping[id].split("/").pop() || "Image"}
fill
style={{ objectFit: "cover" }}
className="rounded-lg"
/>
<>
<div className="relative h-10 w-16">
<Image
src={choiceImageMapping[id]}
alt={choiceImageMapping[id].split("/").pop() || "Image"}
fill
style={{ objectFit: "cover" }}
className="rounded-lg"
/>
</div>
{showId && <IdBadge id={id} />}
</>
)}
</div>
))}

View File

@@ -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 }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
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(<RankingResponse value={rankedItems} isExpanded={true} />);
render(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} />);
render(<RankingResponse value={rankedItems} isExpanded={true} showId={true} />);
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(<RankingResponse value={rankedItems} isExpanded={true} showId={true} />);
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(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
expect(screen.queryByTestId("id-badge")).not.toBeInTheDocument();
});
test("applies expanded layout", () => {
const rankedItems = [
{ value: "Apple", id: "choice1" },
{ value: "Banana", id: "choice2" },
];
const { container } = render(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={false} />);
const { container } = render(<RankingResponse value={rankedItems} isExpanded={false} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} />);
const { container } = render(<RankingResponse value={rankedItems} isExpanded={true} showId={true} />);
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(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} />);
render(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} />);
const { container } = render(<RankingResponse value={rankedItems} isExpanded={true} showId={false} />);
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(<RankingResponse value={rankedItems} isExpanded={true} showId={true} />);
// 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");
});
});

View File

@@ -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 (
<div className={cn("my-1 font-semibold text-slate-700", isExpanded ? "" : "flex space-x-2")} dir="auto">
<div
className={cn("text-slate-700", isExpanded ? "space-y-2" : "flex space-x-2", showId ? "flex-col" : "")}
dir="auto">
{value.map(
(item, index) =>
item && (
<div key={index} className="mb-1 flex items-center">
<span className="mr-2 text-slate-400">#{index + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{item}</div>
item.value && (
<div key={item.value} className="flex items-center space-x-2">
<span className="text-slate-400">#{index + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1 font-semibold">{item.value}</div>
{item.id && showId && <IdBadge id={item.id} />}
</div>
)
)}

View File

@@ -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 }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("ResponseBadges", () => {
afterEach(() => {
cleanup();
});
test("renders string items correctly", () => {
const items = ["Apple", "Banana", "Cherry"];
render(<ResponseBadges items={items} />);
test("renders items with value property correctly", () => {
const items = [{ value: "Apple" }, { value: "Banana" }, { value: "Cherry" }];
render(<ResponseBadges items={items} showId={false} />);
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(<ResponseBadges items={items} />);
test("renders number items with value property correctly", () => {
const items = [{ value: 1 }, { value: 2 }, { value: 3 }];
render(<ResponseBadges items={items} showId={false} />);
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(<ResponseBadges items={items} showId={true} />);
const { container } = render(<ResponseBadges items={items} isExpanded={true} />);
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(<ResponseBadges items={items} showId={false} />);
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(<ResponseBadges items={items} showId={true} />);
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(<ResponseBadges items={items} isExpanded={true} showId={false} />);
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(<ResponseBadges items={items} isExpanded={false} />);
const { container } = render(<ResponseBadges items={items} isExpanded={false} showId={false} />);
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(<ResponseBadges items={items} />);
const { container } = render(<ResponseBadges items={items} showId={true} />);
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(<ResponseBadges items={items} showId={false} />);
const wrapper = container.firstChild;
expect(wrapper).not.toHaveClass("flex-col");
});
test("renders with icon when provided", () => {
const items = [{ value: "Apple" }];
const icon = <span data-testid="test-icon">📱</span>;
render(<ResponseBadges items={items} icon={icon} showId={false} />);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
expect(screen.getByText("📱")).toBeInTheDocument();
});
test("applies default styles correctly", () => {
const items = [{ value: "Apple" }];
const { container } = render(<ResponseBadges items={items} showId={false} />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("my-1");

View File

@@ -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<ResponseBadgesProps> = ({ items, isExpanded = false, icon }) => {
export const ResponseBadges: React.FC<ResponseBadgesProps> = ({
items,
isExpanded = false,
icon,
showId,
}) => {
return (
<div className={cn("my-1 flex gap-2", isExpanded ? "flex-wrap" : "")}>
<div className={cn("my-1 flex gap-2", isExpanded ? "flex-wrap" : "", showId ? "flex-col" : "")}>
{items.map((item, index) => (
<span key={index} className="flex items-center rounded-md bg-slate-200 px-2 py-1 font-medium">
{icon && <span className="mr-1.5">{icon}</span>}
{item}
</span>
<div key={`${item.value}-${index}`} className={cn("flex items-center gap-2")}>
<span className="flex items-center rounded-md bg-slate-200 px-2 py-1 font-medium">
{icon && <span className="mr-1.5">{icon}</span>}
{item.value}
</span>
{item.id && showId && <IdBadge id={item.id} />}
</div>
))}
</div>
);

View File

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