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
-
237src/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
-
164src/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
-
33src/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 { 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; |
|||
@ -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