Browse Source
feat: implement dynamic question renderer system with specialized input components and multi-step form flow support
front-test-2
feat: implement dynamic question renderer system with specialized input components and multi-step form flow support
front-test-2
41 changed files with 1222 additions and 6431 deletions
-
11src/app/[lang]/questions-list/[slug]/page.tsx
-
25src/app/questions-list/[slug]/page.tsx
-
275src/app/questions-list/[slug]/question-detail-client.test.tsx
-
241src/app/questions-list/[slug]/question-detail-client.tsx
-
25src/app/questions-list/page.tsx
-
6src/app/questions-list/sections-request.tsx
-
72src/components/Componentes/conditional-questions.tsx
-
54src/components/Componentes/progress-helper.ts
-
170src/components/Componentes/question-answer-storage.tsx
-
260src/components/Componentes/question-answer.test.tsx
-
12src/components/Componentes/question-birthplace.tsx
-
6src/components/Componentes/question-button.tsx
-
2src/components/Componentes/question-card.tsx
-
49src/components/Componentes/question-checkbox.tsx
-
12src/components/Componentes/question-date.tsx
-
188src/components/Componentes/question-dropdown.tsx
-
20src/components/Componentes/question-file.tsx
-
38src/components/Componentes/question-number.tsx
-
8src/components/Componentes/question-phone.tsx
-
18src/components/Componentes/question-photo.tsx
-
28src/components/Componentes/question-radio.tsx
-
29src/components/Componentes/question-renderer.tsx
-
10src/components/Componentes/question-section-flow.tsx
-
14src/components/Componentes/question-slider.tsx
-
35src/components/Componentes/question-text.tsx
-
10src/components/Componentes/question-textarea.tsx
-
49src/components/Componentes/question-title.tsx
-
4src/components/Componentes/required-steps-card.tsx
-
235src/components/Componentes/schema-question-flow.integration.test.tsx
-
1src/components/Componentes/test-questions-flow.tsx
-
114src/components/Componentes/ui-config.test.tsx
-
1067src/data/cattell-fallback.ts
-
183src/data/glasser-fallback.ts
-
345src/data/question-data.ts
-
1870src/data/questions/en.json
-
1870src/data/questions/fa.json
-
47src/data/section-slug-map.ts
-
4src/hooks/marriage/types.ts
-
9src/hooks/marriage/use-form-schema.ts
-
105src/hooks/marriage/use-section-data.ts
-
132src/lib/schema-adapter.ts
@ -1,14 +1,3 @@ |
|||||
import QuestionDetailPage from "@/app/questions-list/[slug]/page"; |
import QuestionDetailPage from "@/app/questions-list/[slug]/page"; |
||||
import { getQuestionListItems } from "@/data/question-data"; |
|
||||
import { locales } from "@/translations/config"; |
|
||||
|
|
||||
export function generateStaticParams() { |
|
||||
return locales.flatMap((lang) => |
|
||||
getQuestionListItems(lang).map((item) => ({ |
|
||||
lang, |
|
||||
slug: item.slug, |
|
||||
})), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export default QuestionDetailPage; |
export default QuestionDetailPage; |
||||
@ -0,0 +1,275 @@ |
|||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
||||
|
import QuestionDetailClient from "./question-detail-client"; |
||||
|
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell"; |
||||
|
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
||||
|
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
|
||||
|
vi.mock("next/navigation", () => ({ |
||||
|
useRouter: vi.fn(() => ({ replace: vi.fn(), push: vi.fn() })), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-cattell", () => ({ |
||||
|
useCattellQuestionsQuery: vi.fn(), |
||||
|
useSubmitCattellAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-glasser", () => ({ |
||||
|
useGlasserQuestionsQuery: vi.fn(), |
||||
|
useSubmitGlasserAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
||||
|
useMarriageProfileQuery: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
||||
|
useFormSchemaQuery: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({ |
||||
|
useHabcoinPaymentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("@/lib/schema-adapter", () => ({ |
||||
|
convertSchemaToFrontendItems: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
describe("QuestionDetailClient Validation", () => { |
||||
|
const mockCattellRefetch = vi.fn(); |
||||
|
const mockGlasserRefetch = vi.fn(); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
|
||||
|
(useMarriageProfileQuery as any).mockReturnValue({ |
||||
|
data: { age: 30, gender: "male" }, |
||||
|
isLoading: false, |
||||
|
}); |
||||
|
|
||||
|
(useFormSchemaQuery as any).mockReturnValue({ |
||||
|
data: {}, |
||||
|
isLoading: false, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
const setupTest = (slug: string, cattellData: any, glasserData: any) => { |
||||
|
// Mock schema adapter to return an item for the requested slug
|
||||
|
(convertSchemaToFrontendItems as any).mockReturnValue([ |
||||
|
{ |
||||
|
slug: slug, |
||||
|
title: "Test", |
||||
|
questions: [] |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
(useCattellQuestionsQuery as any).mockReturnValue({ |
||||
|
data: cattellData, |
||||
|
isLoading: false, |
||||
|
isError: false, |
||||
|
refetch: mockCattellRefetch, |
||||
|
}); |
||||
|
|
||||
|
(useGlasserQuestionsQuery as any).mockReturnValue({ |
||||
|
data: glasserData, |
||||
|
isLoading: false, |
||||
|
isError: false, |
||||
|
refetch: mockGlasserRefetch, |
||||
|
}); |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { |
||||
|
retry: false, |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionDetailClient |
||||
|
closeLabel="Close" |
||||
|
continueLabel="Continue" |
||||
|
description="Desc" |
||||
|
informationLabel="Info" |
||||
|
itemSlug={slug} |
||||
|
questionsListHref="/questions-list" |
||||
|
title="Test" |
||||
|
/> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Intro screen might render first; if Start is available, click it to mount questions flow
|
||||
|
const startButton = screen.queryByText("Start"); |
||||
|
if (startButton) { |
||||
|
fireEvent.click(startButton); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
it("should render correctly when Cattell API data is completely valid", () => { |
||||
|
setupTest("personality_test", { |
||||
|
questions: [ |
||||
|
{ |
||||
|
question_number: 1, |
||||
|
text: "Valid Question Cattell", |
||||
|
options: [ |
||||
|
{ id: "opt_a", label: "Opt1", value: "A" }, |
||||
|
{ id: "opt_b", label: "Opt2", value: "B" }, |
||||
|
{ id: "opt_c", label: "Opt3", value: "C" }, |
||||
|
], |
||||
|
} |
||||
|
] |
||||
|
}, null); |
||||
|
|
||||
|
// Retry UI should NOT be present
|
||||
|
expect(screen.queryByText("Retry")).toBeNull(); |
||||
|
// Question text should be visible
|
||||
|
expect(screen.getByText("Valid Question Cattell")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("should render Retry UI when Cattell API data is empty", () => { |
||||
|
setupTest("personality_test", { questions: [] }, null); |
||||
|
|
||||
|
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
||||
|
expect(screen.getAllByText("Retry")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => { |
||||
|
setupTest("personality_test", { |
||||
|
questions: [ |
||||
|
{ |
||||
|
question_number: 1, |
||||
|
text: "Invalid Question", |
||||
|
options: [{ label: "Opt1", value: "A" }], // Invalid schema
|
||||
|
} |
||||
|
] |
||||
|
}, null); |
||||
|
|
||||
|
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
||||
|
const retryBtn = screen.getAllByText("Retry")[0]; |
||||
|
|
||||
|
fireEvent.click(retryBtn); |
||||
|
await waitFor(() => { |
||||
|
expect(mockCattellRefetch).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should render correctly when Glasser API data is completely valid", () => { |
||||
|
setupTest("glasser_5_needs_test", null, { |
||||
|
questions: [ |
||||
|
{ |
||||
|
question_number: 1, |
||||
|
text: "Valid Question Glasser", |
||||
|
factor_code: "SUR", |
||||
|
options: [ |
||||
|
{ id: "o1", label: "O1", value: 1 }, |
||||
|
{ id: "o2", label: "O2", value: 2 }, |
||||
|
{ id: "o3", label: "O3", value: 3 }, |
||||
|
{ id: "o4", label: "O4", value: 4 }, |
||||
|
{ id: "o5", label: "O5", value: 5 }, |
||||
|
], |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(screen.queryByText("Retry")).toBeNull(); |
||||
|
expect(screen.getByText("Valid Question Glasser")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("should render Retry UI when Glasser options are invalid (schema failure) and trigger refetch on Retry", async () => { |
||||
|
setupTest("glasser_5_needs_test", null, { |
||||
|
questions: [ |
||||
|
{ |
||||
|
question_number: 1, |
||||
|
text: "Invalid Question Glasser", |
||||
|
factor_code: "SUR", |
||||
|
options: [ |
||||
|
{ label: "O1", value: 1 }, |
||||
|
{ label: "O2", value: 2 }, |
||||
|
{ label: "O3", value: 3 }, |
||||
|
{ label: "O4", value: 4 }, |
||||
|
], |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
expect(screen.getAllByText("No questions found for this test.")).toBeDefined(); |
||||
|
const retryBtn = screen.getAllByText("Retry")[0]; |
||||
|
|
||||
|
fireEvent.click(retryBtn); |
||||
|
await waitFor(() => { |
||||
|
expect(mockGlasserRefetch).toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should render profile questions using ID-based data flow", () => { |
||||
|
(convertSchemaToFrontendItems as any).mockReturnValue([ |
||||
|
{ |
||||
|
slug: "profile_test", |
||||
|
title: "Profile Form", |
||||
|
questions: [ |
||||
|
{ |
||||
|
id: "q_123", |
||||
|
title: "Dynamic ID Question", |
||||
|
type: "text", |
||||
|
order: 1, |
||||
|
required: true, |
||||
|
isVisible: true, |
||||
|
private: false, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: {}, |
||||
|
options: [] |
||||
|
}, |
||||
|
{ |
||||
|
id: "q_456", |
||||
|
title: "Another ID Question", |
||||
|
type: "radio", |
||||
|
order: 2, |
||||
|
required: false, |
||||
|
isVisible: true, |
||||
|
private: false, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: {}, |
||||
|
options: [ |
||||
|
{ id: "opt_1", label: "Yes", value: "yes", order: 1 }, |
||||
|
{ id: "opt_2", label: "No", value: "no", order: 2 } |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { queries: { retry: false } }, |
||||
|
}); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionDetailClient |
||||
|
closeLabel="Close" |
||||
|
continueLabel="Continue" |
||||
|
description="Desc" |
||||
|
informationLabel="Info" |
||||
|
itemSlug="profile_test" |
||||
|
questionsListHref="/questions-list" |
||||
|
title="Profile Test" |
||||
|
/> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Profile questions render directly, no start button
|
||||
|
expect(screen.getByText(/Dynamic ID/)).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -1,72 +0,0 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import { useQuestionAnswers } from "./question-answer-storage"; |
|
||||
import type { QuestionField } from "@/data/question-data"; |
|
||||
import QuestionRenderer from "./question-renderer"; |
|
||||
|
|
||||
type ConditionalQuestionsProps = { |
|
||||
parentQuestion: QuestionField; |
|
||||
parentQuestionIndex: number; |
|
||||
rules: { |
|
||||
values: string[]; |
|
||||
subQuestions: QuestionField[]; |
|
||||
}[]; |
|
||||
dobQuestion?: QuestionField; |
|
||||
dobQuestionIndex?: number; |
|
||||
}; |
|
||||
|
|
||||
export function ConditionalQuestions({ |
|
||||
parentQuestion, |
|
||||
parentQuestionIndex, |
|
||||
rules, |
|
||||
dobQuestion, |
|
||||
dobQuestionIndex, |
|
||||
}: ConditionalQuestionsProps) { |
|
||||
const { getAnswerValue } = useQuestionAnswers(); |
|
||||
const parentAnswer = getAnswerValue(parentQuestion, parentQuestionIndex); |
|
||||
|
|
||||
// Find if there is a matching rule for the current parent answer
|
|
||||
const matchedRule = rules.find((rule) => { |
|
||||
return rule.values.includes(String(parentAnswer)); |
|
||||
}); |
|
||||
|
|
||||
if (!matchedRule) { |
|
||||
return ( |
|
||||
<QuestionRenderer |
|
||||
question={parentQuestion} |
|
||||
questionIndex={parentQuestionIndex} |
|
||||
dobQuestion={dobQuestion} |
|
||||
dobQuestionIndex={dobQuestionIndex} |
|
||||
/> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex flex-col gap-5 w-full"> |
|
||||
<QuestionRenderer |
|
||||
question={parentQuestion} |
|
||||
questionIndex={parentQuestionIndex} |
|
||||
dobQuestion={dobQuestion} |
|
||||
dobQuestionIndex={dobQuestionIndex} |
|
||||
/> |
|
||||
<div className="flex flex-col gap-5 pl-4 border-l-2 border-[#F2465F]/20 mt-2 animate-in fade-in slide-in-from-top-4 duration-300"> |
|
||||
{matchedRule.subQuestions.map((subQuestion, index) => { |
|
||||
// Sub-question index is calculated uniquely to prevent conflicts
|
|
||||
const subIndex = parentQuestionIndex * 100 + index + 1; |
|
||||
return ( |
|
||||
<div key={subQuestion.title} className="w-full"> |
|
||||
<QuestionRenderer |
|
||||
question={subQuestion} |
|
||||
questionIndex={subIndex} |
|
||||
dobQuestion={dobQuestion} |
|
||||
dobQuestionIndex={dobQuestionIndex} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
})} |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export default ConditionalQuestions; |
|
||||
@ -1,54 +0,0 @@ |
|||||
import { type QuestionListItem } from "@/data/question-data"; |
|
||||
import type { MarriageGender } from "@/hooks/marriage/types"; |
|
||||
|
|
||||
export function getStoredAge(): number | null { |
|
||||
try { |
|
||||
if (typeof window === "undefined") return null; |
|
||||
const rawValue = window.localStorage.getItem( |
|
||||
"marriage:sections:personal_info:answers", |
|
||||
); |
|
||||
if (!rawValue) return null; |
|
||||
const storedAnswers = JSON.parse(rawValue); |
|
||||
const ageField = storedAnswers.fields?.find( |
|
||||
(f: Record<string, unknown>) => |
|
||||
f.type === "number" || |
|
||||
f.label === "Age" || |
|
||||
f.label === "سن" || |
|
||||
(typeof f.key === "string" && |
|
||||
(f.key.endsWith("_age") || f.key.endsWith("_sn"))), |
|
||||
); |
|
||||
if (ageField && ageField.value !== undefined && ageField.value !== null) { |
|
||||
const num = Number(ageField.value); |
|
||||
if (Number.isFinite(num)) return num; |
|
||||
} |
|
||||
const dobField = storedAnswers.fields?.find( |
|
||||
(f: Record<string, unknown>) => |
|
||||
f.type === "date" || |
|
||||
f.label === "Date of Birth" || |
|
||||
f.label === "تاریخ تولد" || |
|
||||
(typeof f.key === "string" && |
|
||||
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))), |
|
||||
); |
|
||||
if (dobField?.value) { |
|
||||
const dob = new Date(String(dobField.value)); |
|
||||
if (!Number.isNaN(dob.getTime())) { |
|
||||
const today = new Date(); |
|
||||
let age = today.getFullYear() - dob.getFullYear(); |
|
||||
const m = today.getMonth() - dob.getMonth(); |
|
||||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) { |
|
||||
age--; |
|
||||
} |
|
||||
return age >= 0 ? age : null; |
|
||||
} |
|
||||
} |
|
||||
} catch (_e) {} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
export function getLocalSectionProgress( |
|
||||
item: QuestionListItem, |
|
||||
profile: { gender?: MarriageGender | null } | null | undefined, |
|
||||
age: number | null, |
|
||||
): number | null { |
|
||||
return null; |
|
||||
} |
|
||||
@ -0,0 +1,260 @@ |
|||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; |
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; |
||||
|
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
||||
|
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
||||
|
useFormSchemaQuery: vi.fn(), |
||||
|
})); |
||||
|
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
||||
|
useMarriageProfileQuery: vi.fn(), |
||||
|
})); |
||||
|
vi.mock("@/hooks/marriage/use-section-data", () => ({ |
||||
|
useMarriageSectionDataQuery: vi.fn(), |
||||
|
useUpdateMarriageSectionDataMutation: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
// Dummy component to interact with the context
|
||||
|
function TestComponent({ slug }: { slug: string }) { |
||||
|
const { setAnswerValue, flushAnswers } = useQuestionAnswers(); |
||||
|
|
||||
|
return ( |
||||
|
<div> |
||||
|
<button |
||||
|
data-testid="set-radio" |
||||
|
onClick={() => |
||||
|
setAnswerValue( |
||||
|
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, |
||||
|
"opt1" |
||||
|
) |
||||
|
} |
||||
|
> |
||||
|
Set Radio |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
data-testid="set-checkbox" |
||||
|
onClick={() => |
||||
|
setAnswerValue( |
||||
|
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, |
||||
|
["opt2", "opt3"] |
||||
|
) |
||||
|
} |
||||
|
> |
||||
|
Set Checkbox |
||||
|
</button> |
||||
|
|
||||
|
<button data-testid="save" onClick={() => flushAnswers()}> |
||||
|
Save |
||||
|
</button> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
describe("Question Answer & Schema Integration", () => { |
||||
|
let capturedPayload: any = null; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
capturedPayload = null; |
||||
|
const updateMutateAsync = vi.fn(async (payload) => { |
||||
|
capturedPayload = payload; |
||||
|
return payload; |
||||
|
}); |
||||
|
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({ |
||||
|
mutateAsync: updateMutateAsync, |
||||
|
isPending: false, |
||||
|
}); |
||||
|
(useMarriageProfileQuery as any).mockReturnValue({ |
||||
|
data: { can_edit_profile: true }, |
||||
|
}); |
||||
|
(useMarriageSectionDataQuery as any).mockReturnValue({ |
||||
|
data: [], |
||||
|
isLoading: false, |
||||
|
}); |
||||
|
(useFormSchemaQuery as any).mockReturnValue({ |
||||
|
data: { is_completed: false }, |
||||
|
isLoading: false, |
||||
|
isFetching: false, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("should send option_id instead of label/value for radio", async () => { |
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider |
||||
|
slug="test_slug" |
||||
|
questions={[ |
||||
|
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, |
||||
|
]} |
||||
|
> |
||||
|
<TestComponent slug="test_slug" /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
fireEvent.click(screen.getByTestId("set-radio")); |
||||
|
fireEvent.click(screen.getByTestId("save")); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(capturedPayload).not.toBeNull(); |
||||
|
const field = capturedPayload.fields.find((f: any) => f.key === "q1"); |
||||
|
expect(field.option_id).toBe("opt1"); |
||||
|
expect(field.value).toBe("opt1"); // ui value is option_id
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should send array of option_ids for checkbox", async () => { |
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider |
||||
|
slug="test_slug" |
||||
|
questions={[ |
||||
|
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, |
||||
|
]} |
||||
|
> |
||||
|
<TestComponent slug="test_slug" /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
fireEvent.click(screen.getByTestId("set-checkbox")); |
||||
|
fireEvent.click(screen.getByTestId("save")); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(capturedPayload).not.toBeNull(); |
||||
|
const field = capturedPayload.fields.find((f: any) => f.key === "q2"); |
||||
|
expect(field.option_id).toEqual(["opt2", "opt3"]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should sort schema questions and options by order correctly", () => { |
||||
|
const mockSchema = { |
||||
|
sections: [ |
||||
|
{ |
||||
|
id: "sec1", |
||||
|
title: "Section", |
||||
|
order: 1, |
||||
|
icon: "user-circle", |
||||
|
is_required: true, |
||||
|
estimated_minutes: 5, |
||||
|
cards: [ |
||||
|
{ |
||||
|
id: "card1", |
||||
|
title: "Card", |
||||
|
order: 2, |
||||
|
questions: [ |
||||
|
{ id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] }, |
||||
|
{ id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [ |
||||
|
{ id: "opt1", value: "A", label: "Option A", order: 2 }, |
||||
|
{ id: "opt2", value: "B", label: "Option B", order: 1 } |
||||
|
] }, |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
id: "card2", |
||||
|
title: "Card 2", |
||||
|
order: 1, |
||||
|
questions: [ |
||||
|
{ id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
], |
||||
|
progress: { sections_progress: {} } |
||||
|
} as any; |
||||
|
|
||||
|
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
||||
|
const questions = frontendItems[0].questions; |
||||
|
|
||||
|
expect(questions[0].id).toBe("q3"); |
||||
|
expect(questions[1].id).toBe("q2"); |
||||
|
expect(questions[2].id).toBe("q1"); |
||||
|
|
||||
|
expect(questions[1].options[0].id).toBe("opt2"); |
||||
|
expect(questions[1].options[1].id).toBe("opt1"); |
||||
|
}); |
||||
|
|
||||
|
it("should hydrate radio/dropdown/checkbox with option_id instead of canonical value", async () => { |
||||
|
// Mock the backend sending canonical value but keeping option_id
|
||||
|
(useMarriageSectionDataQuery as any).mockReturnValue({ |
||||
|
data: { |
||||
|
slug: "test_slug", |
||||
|
data: [ |
||||
|
{ key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" }, |
||||
|
{ key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] }, |
||||
|
], |
||||
|
}, |
||||
|
isLoading: false, |
||||
|
}); |
||||
|
|
||||
|
let capturedValues: any = {}; |
||||
|
|
||||
|
function HydrationTestComponent() { |
||||
|
const { getAnswerValue } = useQuestionAnswers(); |
||||
|
capturedValues.radio = getAnswerValue({ id: "q_radio" } as any); |
||||
|
capturedValues.check = getAnswerValue({ id: "q_check" } as any); |
||||
|
return <div />; |
||||
|
} |
||||
|
|
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test_slug" questions={[]}> |
||||
|
<HydrationTestComponent /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Give it a moment to reconcile useEffect in QuestionAnswersProvider
|
||||
|
await waitFor(() => { |
||||
|
expect(capturedValues.radio).toBe("opt_radio"); |
||||
|
expect(capturedValues.check).toEqual(["opt_check1", "opt_check2"]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should respect bq.is_required over bq.required for required state", () => { |
||||
|
const mockSchema = { |
||||
|
sections: [ |
||||
|
{ |
||||
|
id: "sec1", |
||||
|
title: "Section", |
||||
|
order: 1, |
||||
|
icon: "user-circle", |
||||
|
is_required: true, |
||||
|
estimated_minutes: 5, |
||||
|
cards: [ |
||||
|
{ |
||||
|
id: "card1", |
||||
|
title: "Card", |
||||
|
order: 1, |
||||
|
questions: [ |
||||
|
{ id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] }, |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
], |
||||
|
progress: { sections_progress: {} } |
||||
|
} as any; |
||||
|
|
||||
|
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
||||
|
const question = frontendItems[0].questions[0]; |
||||
|
|
||||
|
expect(question.baseRequired).toBe(false); |
||||
|
expect(question.required).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,235 @@ |
|||||
|
import React from "react"; |
||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; |
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; |
||||
|
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; |
||||
|
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; |
||||
|
import { QuestionRadio } from "./question-radio"; |
||||
|
import { QuestionCheckbox } from "./question-checkbox"; |
||||
|
|
||||
|
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
||||
|
useFormSchemaQuery: vi.fn(), |
||||
|
})); |
||||
|
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
||||
|
useMarriageProfileQuery: vi.fn(), |
||||
|
})); |
||||
|
vi.mock("@/hooks/marriage/use-section-data", () => ({ |
||||
|
useMarriageSectionDataQuery: vi.fn(), |
||||
|
useUpdateMarriageSectionDataMutation: vi.fn(), |
||||
|
})); |
||||
|
|
||||
|
export const replaceMock = vi.fn(); |
||||
|
vi.mock("next/navigation", () => ({ |
||||
|
useRouter: () => ({ |
||||
|
push: vi.fn(), |
||||
|
replace: (...args: any[]) => replaceMock(...args), |
||||
|
back: vi.fn(), |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
const mockSchema = { |
||||
|
form_id: "profile", |
||||
|
version: 1, |
||||
|
answers: { |
||||
|
"q_radio": { value: "A", option_id: "opt_a" }, |
||||
|
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] } |
||||
|
}, |
||||
|
sections: [ |
||||
|
{ |
||||
|
id: "sec1", |
||||
|
title: "Section", |
||||
|
order: 1, |
||||
|
icon: "user-circle", |
||||
|
is_required: true, |
||||
|
estimated_minutes: 5, |
||||
|
cards: [ |
||||
|
{ |
||||
|
id: "card1", |
||||
|
title: "Card", |
||||
|
order: 2, // Messed up order
|
||||
|
questions: [ |
||||
|
{ |
||||
|
id: "q_child", title: "Child Q", type: "text", order: 20, |
||||
|
required: false, is_required: true, is_visible: false, |
||||
|
ui_config: {}, options: [] |
||||
|
}, |
||||
|
{ |
||||
|
id: "q_radio", title: "Radio Q", type: "radio", order: 5, |
||||
|
required: true, is_required: true, is_visible: true, |
||||
|
ui_config: {}, |
||||
|
options: [ |
||||
|
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 }, |
||||
|
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 } |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
id: "q_check", title: "Check Q", type: "checkbox", order: 10, |
||||
|
required: false, is_required: false, is_visible: true, |
||||
|
ui_config: {}, |
||||
|
options: [ |
||||
|
{ id: "opt_c", value: "C", label: "Option C", order: 2 }, |
||||
|
{ id: "opt_b", value: "B", label: "Option B", order: 1 } |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
{ |
||||
|
id: "card2", |
||||
|
title: "Card 2", |
||||
|
order: 1, // Card 2 should come first
|
||||
|
questions: [ |
||||
|
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, |
||||
|
] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
], |
||||
|
progress: { sections_progress: {} } |
||||
|
}; |
||||
|
|
||||
|
describe("Schema Question Flow Integration", () => { |
||||
|
let capturedPayload: any = null; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
capturedPayload = null; |
||||
|
const updateMutateAsync = vi.fn(async (payload) => { |
||||
|
capturedPayload = payload; |
||||
|
return payload; |
||||
|
}); |
||||
|
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({ |
||||
|
mutateAsync: updateMutateAsync, |
||||
|
isPending: false, |
||||
|
}); |
||||
|
(useMarriageProfileQuery as any).mockReturnValue({ |
||||
|
data: { can_edit_profile: true }, |
||||
|
}); |
||||
|
(useMarriageSectionDataQuery as any).mockReturnValue({ |
||||
|
data: { |
||||
|
slug: "sec1", |
||||
|
data: [ |
||||
|
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" }, |
||||
|
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] } |
||||
|
] |
||||
|
}, |
||||
|
isLoading: false, |
||||
|
}); |
||||
|
(useFormSchemaQuery as any).mockReturnValue({ |
||||
|
data: mockSchema, |
||||
|
isLoading: false, |
||||
|
isFetching: false, |
||||
|
error: null, |
||||
|
refetch: vi.fn(), |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => { |
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
// Use convertSchemaToFrontendItems directly (no mock)
|
||||
|
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); |
||||
|
|
||||
|
// Sort logic validation
|
||||
|
expect(frontendItems[0].questions[0].id).toBe("q_first"); |
||||
|
expect(frontendItems[0].questions[1].id).toBe("q_radio"); |
||||
|
expect(frontendItems[0].questions[2].id).toBe("q_check"); |
||||
|
expect(frontendItems[0].questions[3].id).toBe("q_child"); |
||||
|
|
||||
|
// Validate options sort
|
||||
|
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a"); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionDetailClient |
||||
|
itemSlug="sec1" |
||||
|
closeLabel="Close" |
||||
|
continueLabel="Continue" |
||||
|
description="Desc" |
||||
|
informationLabel="Info" |
||||
|
questionsListHref="/list" |
||||
|
title="Title" |
||||
|
/> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Wait for hydration and rendering
|
||||
|
await waitFor(() => { |
||||
|
// Radio hydration
|
||||
|
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement; |
||||
|
expect(radioInput.checked).toBe(true); |
||||
|
|
||||
|
// Checkbox hydration
|
||||
|
const checkB = screen.getByLabelText("Option B") as HTMLInputElement; |
||||
|
const checkC = screen.getByLabelText("Option C") as HTMLInputElement; |
||||
|
expect(checkB.checked).toBe(true); |
||||
|
expect(checkC.checked).toBe(true); |
||||
|
|
||||
|
// Child should not be rendered
|
||||
|
expect(screen.queryByText("Child Q")).toBeNull(); |
||||
|
|
||||
|
// q_child is required=false but is_required=true, if it was visible it should show *.
|
||||
|
// Let's modify schema dynamically to test child visibility and requirement
|
||||
|
}); |
||||
|
|
||||
|
// Interact to fire payload
|
||||
|
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement; |
||||
|
fireEvent.click(radioDummy); |
||||
|
|
||||
|
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
|
||||
|
const checkB = screen.getByLabelText("Option B") as HTMLInputElement; |
||||
|
fireEvent.click(checkB); // Should now be unchecked
|
||||
|
|
||||
|
const submitBtn = screen.getByText("Continue"); // Form submit button
|
||||
|
fireEvent.click(submitBtn); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(capturedPayload).not.toBeNull(); |
||||
|
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio"); |
||||
|
expect(radioPayload.option_id).toBe("opt_a_dummy"); |
||||
|
|
||||
|
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check"); |
||||
|
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should redirect and not render static questions on schema failure", () => { |
||||
|
replaceMock.mockClear(); |
||||
|
|
||||
|
(useFormSchemaQuery as any).mockReturnValue({ |
||||
|
data: undefined, |
||||
|
isLoading: false, |
||||
|
isFetching: false, |
||||
|
error: new Error("Network Error"), |
||||
|
refetch: vi.fn(), |
||||
|
}); |
||||
|
|
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionDetailClient |
||||
|
itemSlug="sec1" |
||||
|
closeLabel="Close" |
||||
|
continueLabel="Continue" |
||||
|
description="Desc" |
||||
|
informationLabel="Info" |
||||
|
questionsListHref="/list" |
||||
|
title="Title" |
||||
|
/> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Should redirect to questions list
|
||||
|
expect(replaceMock).toHaveBeenCalledWith("/list"); |
||||
|
// Static text should not exist
|
||||
|
expect(screen.queryByText("First Q")).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,114 @@ |
|||||
|
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; |
||||
|
import { describe, it, expect, vi, afterEach } from "vitest"; |
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
import { QuestionAnswersProvider } from "./question-answer-storage"; |
||||
|
import { QuestionBirthplace } from "./question-birthplace"; |
||||
|
import QuestionNumber from "./question-number"; |
||||
|
import QuestionText from "./question-text"; |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: vi.fn(() => ({ locale: "fa", dictionary: {} })), |
||||
|
})); |
||||
|
|
||||
|
describe("UI Config based behavior", () => { |
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); |
||||
|
|
||||
|
it("should trigger GeoIP only when ui_config.enable_geoip is true", () => { |
||||
|
// With totally random title but ui_config.enable_geoip = true
|
||||
|
const qWithGeo = { id: "q1", title: "Random Title Here", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: { enable_geoip: true } } as any; |
||||
|
|
||||
|
const { rerender } = render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}> |
||||
|
<QuestionBirthplace question={qWithGeo} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Auto button is present when GeoIP is active
|
||||
|
expect(screen.getByText("خودکار")).toBeDefined(); |
||||
|
|
||||
|
// With title "residence" but no ui_config
|
||||
|
const qWithoutGeo = { id: "q2", title: "residence test", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: {} } as any; |
||||
|
|
||||
|
rerender( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}> |
||||
|
<QuestionBirthplace question={qWithoutGeo} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
expect(screen.queryByText("خودکار")).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => { |
||||
|
// Title is random, but currency_enabled is true
|
||||
|
const qWithCurrency = { id: "q1", title: "Random Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: { currency_enabled: true } } as any; |
||||
|
|
||||
|
const { rerender } = render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}> |
||||
|
<QuestionNumber question={qWithCurrency} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
// Dropdown arrow should be rendered in currency mode (path is in SVG)
|
||||
|
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined(); |
||||
|
|
||||
|
// Title is "Monthly Income", but currency_enabled is false
|
||||
|
const qWithoutCurrency = { id: "q2", title: "Monthly Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: {} } as any; |
||||
|
|
||||
|
rerender( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}> |
||||
|
<QuestionNumber question={qWithoutCurrency} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("should render error message from validation.errorMessage or fallback to locale", async () => { |
||||
|
// Title is random, type is email, custom error message
|
||||
|
const qCustomError = { id: "q1", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: { errorMessage: "Custom Backend Error" } } as any; |
||||
|
|
||||
|
const { rerender } = render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qCustomError]}> |
||||
|
<QuestionText question={qCustomError} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
const input = screen.getByRole("textbox"); |
||||
|
fireEvent.change(input, { target: { value: 'invalid_email' } }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(screen.getByText("Custom Backend Error")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
const qFallbackError = { id: "q2", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: {} } as any; |
||||
|
|
||||
|
rerender( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}> |
||||
|
<QuestionText question={qFallbackError} /> |
||||
|
</QuestionAnswersProvider> |
||||
|
</QueryClientProvider> |
||||
|
); |
||||
|
|
||||
|
const input2 = screen.getByRole("textbox"); |
||||
|
fireEvent.change(input2, { target: { value: 'invalid_email_again' } }); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
1067
src/data/cattell-fallback.ts
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,183 +0,0 @@ |
|||||
export type GlasserFallbackQuestion = { |
|
||||
question_number: number; |
|
||||
factor_code: string; |
|
||||
text: string; |
|
||||
}; |
|
||||
|
|
||||
export const glasserFallbackQuestions: GlasserFallbackQuestion[] = [ |
|
||||
{ |
|
||||
question_number: 1, |
|
||||
factor_code: "S", |
|
||||
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 2, |
|
||||
factor_code: "S", |
|
||||
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 3, |
|
||||
factor_code: "S", |
|
||||
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 4, |
|
||||
factor_code: "S", |
|
||||
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 5, |
|
||||
factor_code: "S", |
|
||||
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 6, |
|
||||
factor_code: "S", |
|
||||
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 7, |
|
||||
factor_code: "S", |
|
||||
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 8, |
|
||||
factor_code: "L", |
|
||||
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 9, |
|
||||
factor_code: "L", |
|
||||
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 10, |
|
||||
factor_code: "L", |
|
||||
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 11, |
|
||||
factor_code: "L", |
|
||||
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 12, |
|
||||
factor_code: "L", |
|
||||
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 13, |
|
||||
factor_code: "L", |
|
||||
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 14, |
|
||||
factor_code: "L", |
|
||||
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 15, |
|
||||
factor_code: "P", |
|
||||
text: "تا چه اندازه تمایل دارید رهبری گروهها، هدایت پروژهها یا مدیریت امور را بر عهده بگیرید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 16, |
|
||||
factor_code: "P", |
|
||||
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 17, |
|
||||
factor_code: "P", |
|
||||
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان میدهید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 18, |
|
||||
factor_code: "P", |
|
||||
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روشهای شما انجام شوند؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 19, |
|
||||
factor_code: "P", |
|
||||
text: "در بحثها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 20, |
|
||||
factor_code: "P", |
|
||||
text: "وقتی احساس میکنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواستههایتان هستید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 21, |
|
||||
factor_code: "P", |
|
||||
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 22, |
|
||||
factor_code: "Fr", |
|
||||
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 23, |
|
||||
factor_code: "Fr", |
|
||||
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان میدهید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 24, |
|
||||
factor_code: "Fr", |
|
||||
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیمگیریها چقدر است؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 25, |
|
||||
factor_code: "Fr", |
|
||||
text: "تا چه اندازه رهایی از قید و بندها و برنامههای فشرده را ترجیح میدهید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 26, |
|
||||
factor_code: "Fr", |
|
||||
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 27, |
|
||||
factor_code: "Fr", |
|
||||
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 28, |
|
||||
factor_code: "Fr", |
|
||||
text: "چقدر برای آزادی اندیشه و ابراز دیدگاههای غیرمعمول اهمیت قائل هستید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 29, |
|
||||
factor_code: "Fu", |
|
||||
text: "تا چه اندازه اهل شوخطبعی، خنده، لطیفهگویی و ایجاد فضای شاد هستید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 30, |
|
||||
factor_code: "Fu", |
|
||||
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بیدغدغه و فعالیتهای مفرح اختصاص میدهید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 31, |
|
||||
factor_code: "Fu", |
|
||||
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت میبرید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 32, |
|
||||
factor_code: "Fu", |
|
||||
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 33, |
|
||||
factor_code: "Fu", |
|
||||
text: "تمایل شما به شرکت در دورهمیها، برنامههای تفریحی و سفر با دوستان چقدر است؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 34, |
|
||||
factor_code: "Fu", |
|
||||
text: "چقدر برای داشتن سرگرمیها (هابی) و فعالیتهای غیرکاری وقت میگذارید؟", |
|
||||
}, |
|
||||
{ |
|
||||
question_number: 35, |
|
||||
factor_code: "Fu", |
|
||||
text: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال میکنید؟", |
|
||||
}, |
|
||||
]; |
|
||||
@ -1,345 +0,0 @@ |
|||||
import enQuestions from "@/data/questions/en.json"; |
|
||||
import faQuestions from "@/data/questions/fa.json"; |
|
||||
import type { MarriageGender } from "@/hooks/marriage/types"; |
|
||||
import { defaultLocale, type Locale } from "@/translations/config"; |
|
||||
import { getDictionary } from "@/translations/dictionaries"; |
|
||||
|
|
||||
export const bookingTerms = [ |
|
||||
"You will be contacted by your consultant.", |
|
||||
"The call may start 10-15 minutes earlier or later than scheduled.", |
|
||||
"Make sure you are available and in a quiet place at least 10 minutes before the session.", |
|
||||
] as const; |
|
||||
|
|
||||
export type QuestionCardIcon = |
|
||||
| "profile" |
|
||||
| "education" |
|
||||
| "details" |
|
||||
| "checklist" |
|
||||
| "contact" |
|
||||
| "family_marital"; |
|
||||
|
|
||||
type QuestionExtras = { |
|
||||
placeHolder: string; |
|
||||
range: [number, number]; |
|
||||
options: string[]; |
|
||||
noSearch?: boolean; |
|
||||
}; |
|
||||
|
|
||||
type QuestionAudienceRule = { |
|
||||
genders?: MarriageGender[]; |
|
||||
maxAge?: number; |
|
||||
minAge?: number; |
|
||||
}; |
|
||||
|
|
||||
export type QuestionLogic = { |
|
||||
dependsOn: { |
|
||||
title: string; |
|
||||
values: string[]; |
|
||||
}; |
|
||||
}; |
|
||||
|
|
||||
export type QuestionField = { |
|
||||
title: string; |
|
||||
type: string; |
|
||||
required: boolean; |
|
||||
private?: boolean; |
|
||||
description: string; |
|
||||
tooltip: string; |
|
||||
extras: QuestionExtras; |
|
||||
audience?: QuestionAudienceRule; |
|
||||
requiredWhen?: QuestionAudienceRule; |
|
||||
logic?: QuestionLogic; |
|
||||
showGuardianNotice?: boolean; |
|
||||
originalSlug?: string; |
|
||||
originalIndex?: number; |
|
||||
englishTitle?: string; |
|
||||
}; |
|
||||
|
|
||||
export type QuestionListItem = { |
|
||||
slug: string; |
|
||||
title: string; |
|
||||
estimate: string; |
|
||||
progress: number; |
|
||||
icon: QuestionCardIcon; |
|
||||
required?: boolean; |
|
||||
note?: string; |
|
||||
showInfoBadge?: boolean; |
|
||||
summary: string; |
|
||||
checkpoints: readonly string[]; |
|
||||
tooltip: string; |
|
||||
questions: readonly QuestionField[]; |
|
||||
audience?: QuestionAudienceRule; |
|
||||
}; |
|
||||
|
|
||||
type RawQuestionListItem = { |
|
||||
title: string; |
|
||||
icon: string; |
|
||||
slug: string; |
|
||||
required?: boolean; |
|
||||
estimateTime: string; |
|
||||
tooltip: string; |
|
||||
progress: number; |
|
||||
description: string; |
|
||||
questions: QuestionField[]; |
|
||||
audience?: QuestionAudienceRule; |
|
||||
}; |
|
||||
|
|
||||
const iconMap: Record<string, QuestionCardIcon> = { |
|
||||
"user-circle": "profile", |
|
||||
school: "education", |
|
||||
"heart-handshake": "details", |
|
||||
"file-text": "contact", |
|
||||
"layout-grid": "checklist", |
|
||||
}; |
|
||||
|
|
||||
const questionsByLocale: Record<string, RawQuestionListItem[]> = { |
|
||||
en: enQuestions as RawQuestionListItem[], |
|
||||
fa: faQuestions as RawQuestionListItem[], |
|
||||
}; |
|
||||
|
|
||||
function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem { |
|
||||
return { |
|
||||
slug: item.slug, |
|
||||
title: item.title, |
|
||||
estimate: item.estimateTime, |
|
||||
progress: item.progress, |
|
||||
icon: iconMap[item.icon] ?? "details", |
|
||||
required: item.required, |
|
||||
showInfoBadge: Boolean(item.tooltip), |
|
||||
summary: item.description, |
|
||||
checkpoints: item.questions.map((question) => question.title), |
|
||||
tooltip: item.tooltip, |
|
||||
questions: item.questions, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
export function getQuestionListItems(locale: Locale = defaultLocale) { |
|
||||
const rawItems = |
|
||||
questionsByLocale[locale] ?? |
|
||||
questionsByLocale[defaultLocale] ?? |
|
||||
questionsByLocale.en ?? |
|
||||
[]; |
|
||||
|
|
||||
const enItems = questionsByLocale.en || []; |
|
||||
const mappedRawItems = rawItems.map((item) => { |
|
||||
const enItem = enItems.find((e) => e.slug === item.slug); |
|
||||
if (!enItem) return item; |
|
||||
return { |
|
||||
...item, |
|
||||
questions: item.questions.map((q, idx) => { |
|
||||
const enQ = enItem.questions[idx]; |
|
||||
return { |
|
||||
...q, |
|
||||
englishTitle: enQ ? enQ.title : q.title, |
|
||||
}; |
|
||||
}), |
|
||||
}; |
|
||||
}); |
|
||||
|
|
||||
const items = mappedRawItems.map(mapQuestionListItem); |
|
||||
|
|
||||
const fbIndex = items.findIndex((item) => item.slug === "family_background"); |
|
||||
const mhIndex = items.findIndex( |
|
||||
(item) => item.slug === "marital_history_children", |
|
||||
); |
|
||||
|
|
||||
if (fbIndex !== -1 && mhIndex !== -1) { |
|
||||
const fbItem = items[fbIndex]; |
|
||||
const mhItem = items[mhIndex]; |
|
||||
|
|
||||
const dict = getDictionary(locale); |
|
||||
const mergedTitle = |
|
||||
(dict as any)["Family Background, Marital Status, and Children"] || |
|
||||
"Family Background, Marital Status, and Children"; |
|
||||
const mergedEstimate = (dict as any)["20 minutes"] || "20 minutes"; |
|
||||
|
|
||||
const fbQuestions = fbItem.questions.map((q, idx) => ({ |
|
||||
...q, |
|
||||
originalSlug: "family_background", |
|
||||
originalIndex: idx, |
|
||||
})); |
|
||||
|
|
||||
const mhQuestions = mhItem.questions.map((q, idx) => ({ |
|
||||
...q, |
|
||||
originalSlug: "marital_history_children", |
|
||||
originalIndex: idx, |
|
||||
})); |
|
||||
|
|
||||
const finalQuestions = [...fbQuestions, ...mhQuestions]; |
|
||||
|
|
||||
const mergedItem: QuestionListItem = { |
|
||||
slug: "family_marital_history", |
|
||||
title: mergedTitle, |
|
||||
estimate: mergedEstimate, |
|
||||
progress: 0, |
|
||||
icon: "family_marital", |
|
||||
required: fbItem.required || mhItem.required, |
|
||||
showInfoBadge: fbItem.showInfoBadge || mhItem.showInfoBadge, |
|
||||
summary: `${fbItem.summary}\n\n${mhItem.summary}`, |
|
||||
checkpoints: [...fbItem.checkpoints, ...mhItem.checkpoints], |
|
||||
tooltip: fbItem.tooltip || mhItem.tooltip, |
|
||||
questions: finalQuestions, |
|
||||
}; |
|
||||
|
|
||||
const newItems = [...items]; |
|
||||
newItems[fbIndex] = mergedItem; |
|
||||
newItems.splice(mhIndex, 1); |
|
||||
|
|
||||
// Reorder items: bring future_spouse_criteria and identity_verification above the tests
|
|
||||
const personalityIndex = newItems.findIndex( |
|
||||
(item) => item.slug === "personality_test", |
|
||||
); |
|
||||
const spouseItem = newItems.find( |
|
||||
(item) => item.slug === "future_spouse_criteria", |
|
||||
); |
|
||||
const identityItem = newItems.find( |
|
||||
(item) => item.slug === "identity_verification", |
|
||||
); |
|
||||
|
|
||||
if (personalityIndex !== -1 && (spouseItem || identityItem)) { |
|
||||
const filtered = newItems.filter( |
|
||||
(item) => |
|
||||
item.slug !== "future_spouse_criteria" && |
|
||||
item.slug !== "identity_verification", |
|
||||
); |
|
||||
const insertAt = filtered.findIndex( |
|
||||
(item) => item.slug === "personality_test", |
|
||||
); |
|
||||
if (insertAt !== -1) { |
|
||||
const result = [...filtered]; |
|
||||
const itemsToInsert = []; |
|
||||
if (spouseItem) itemsToInsert.push(spouseItem); |
|
||||
if (identityItem) itemsToInsert.push(identityItem); |
|
||||
result.splice(insertAt, 0, ...itemsToInsert); |
|
||||
return result; |
|
||||
} |
|
||||
} |
|
||||
return newItems; |
|
||||
} |
|
||||
|
|
||||
// Also handle reordering if fbIndex/mhIndex are not found (fallback)
|
|
||||
const personalityIndex = items.findIndex( |
|
||||
(item) => item.slug === "personality_test", |
|
||||
); |
|
||||
const spouseItem = items.find( |
|
||||
(item) => item.slug === "future_spouse_criteria", |
|
||||
); |
|
||||
const identityItem = items.find( |
|
||||
(item) => item.slug === "identity_verification", |
|
||||
); |
|
||||
|
|
||||
if (personalityIndex !== -1 && (spouseItem || identityItem)) { |
|
||||
const filtered = items.filter( |
|
||||
(item) => |
|
||||
item.slug !== "future_spouse_criteria" && |
|
||||
item.slug !== "identity_verification", |
|
||||
); |
|
||||
const insertAt = filtered.findIndex( |
|
||||
(item) => item.slug === "personality_test", |
|
||||
); |
|
||||
if (insertAt !== -1) { |
|
||||
const result = [...filtered]; |
|
||||
const itemsToInsert = []; |
|
||||
if (spouseItem) itemsToInsert.push(spouseItem); |
|
||||
if (identityItem) itemsToInsert.push(identityItem); |
|
||||
result.splice(insertAt, 0, ...itemsToInsert); |
|
||||
return result; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return items; |
|
||||
} |
|
||||
|
|
||||
export function getQuestionListItemBySlug( |
|
||||
slug: string, |
|
||||
locale: Locale = defaultLocale, |
|
||||
) { |
|
||||
return getQuestionListItems(locale).find((item) => item.slug === slug); |
|
||||
} |
|
||||
|
|
||||
function matchesAudienceRule( |
|
||||
rule: QuestionAudienceRule | undefined, |
|
||||
profile: { |
|
||||
age?: number | null; |
|
||||
gender?: MarriageGender | null; |
|
||||
}, |
|
||||
) { |
|
||||
if (!rule) { |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
rule.genders?.length && |
|
||||
profile.gender && |
|
||||
!rule.genders.includes(profile.gender) |
|
||||
) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
typeof rule.minAge === "number" && |
|
||||
profile.age != null && |
|
||||
profile.age < rule.minAge |
|
||||
) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
typeof rule.maxAge === "number" && |
|
||||
profile.age != null && |
|
||||
profile.age > rule.maxAge |
|
||||
) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
export function isQuestionListItemVisibleForProfile( |
|
||||
item: QuestionListItem, |
|
||||
profile: { |
|
||||
age?: number | null; |
|
||||
gender?: MarriageGender | null; |
|
||||
}, |
|
||||
) { |
|
||||
return matchesAudienceRule(item.audience, profile); |
|
||||
} |
|
||||
|
|
||||
export function isQuestionVisibleForProfile( |
|
||||
question: QuestionField, |
|
||||
profile: { |
|
||||
age?: number | null; |
|
||||
gender?: MarriageGender | null; |
|
||||
}, |
|
||||
) { |
|
||||
return matchesAudienceRule(question.audience, profile); |
|
||||
} |
|
||||
|
|
||||
export function isQuestionRequiredForProfile( |
|
||||
question: QuestionField, |
|
||||
profile: { |
|
||||
age?: number | null; |
|
||||
gender?: MarriageGender | null; |
|
||||
}, |
|
||||
) { |
|
||||
return ( |
|
||||
question.required || |
|
||||
(Boolean(question.requiredWhen) && |
|
||||
matchesAudienceRule(question.requiredWhen, profile)) |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export function getRequiredQuestionsCount( |
|
||||
slug: string, |
|
||||
profile: { |
|
||||
age?: number | null; |
|
||||
gender?: MarriageGender | null; |
|
||||
}, |
|
||||
locale: Locale = defaultLocale, |
|
||||
) { |
|
||||
const item = getQuestionListItemBySlug(slug, locale); |
|
||||
if (!item) return 0; |
|
||||
|
|
||||
return item.questions.filter((q) => isQuestionRequiredForProfile(q, profile)) |
|
||||
.length; |
|
||||
} |
|
||||
1870
src/data/questions/en.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1870
src/data/questions/fa.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1,47 +0,0 @@ |
|||||
/** |
|
||||
* Maps backend section slugs to their corresponding frontend question list slugs. |
|
||||
* Some backend sections aggregate multiple frontend question categories under a |
|
||||
* single slug, so these two naming systems don't always match 1-to-1. |
|
||||
*/ |
|
||||
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = { |
|
||||
personal_identity: "personal_info", |
|
||||
contact_residence: "contact_residence_family_communication", |
|
||||
appearance_health: "appearance_health_activity", |
|
||||
education_career: "education_career_economic_status", |
|
||||
family_background: "family_marital_history", |
|
||||
marital_history: "family_marital_history", |
|
||||
beliefs_lifestyle: "beliefs_lifestyle_boundaries", |
|
||||
spouse_criteria: "future_spouse_criteria", |
|
||||
documents_verification: "identity_verification", |
|
||||
}; |
|
||||
|
|
||||
/** |
|
||||
* Reverse map: frontend slug → backend slug. |
|
||||
* Built automatically from BACKEND_TO_FRONTEND_SLUG_MAP. |
|
||||
*/ |
|
||||
export const FRONTEND_TO_BACKEND_SLUG_MAP: Record<string, string> = |
|
||||
Object.fromEntries( |
|
||||
Object.entries(BACKEND_TO_FRONTEND_SLUG_MAP).map(([backend, frontend]) => [ |
|
||||
frontend, |
|
||||
backend, |
|
||||
]), |
|
||||
); |
|
||||
|
|
||||
/** |
|
||||
* Resolve a frontend question-list slug to the backend section slug |
|
||||
* the API expects. Falls back to the original slug when no mapping exists. |
|
||||
*/ |
|
||||
export function toBackendSlug(frontendSlug: string): string { |
|
||||
if (frontendSlug === "marital_history_children") { |
|
||||
return "marital_history"; |
|
||||
} |
|
||||
return FRONTEND_TO_BACKEND_SLUG_MAP[frontendSlug] ?? frontendSlug; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Resolve a backend section slug to the frontend question-list slug |
|
||||
* used in the JSON data. Falls back to the original slug. |
|
||||
*/ |
|
||||
export function toFrontendSlug(backendSlug: string): string { |
|
||||
return BACKEND_TO_FRONTEND_SLUG_MAP[backendSlug] ?? backendSlug; |
|
||||
} |
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue