feat: implement phone input component with geo-based auto-detection, loading indicators, and supporting schema logic.
Dev
-
BINsimplified_profile_icons_02_to_11.zip
-
70src/app/questions-list/[slug]/question-detail-client.tsx
-
23src/app/questions-list/page.tsx
-
26src/app/questions-list/sections-request.tsx
-
41src/components/Componentes/loading-border-spinner.tsx
-
5src/components/Componentes/loading-icon-spinner.tsx
-
5src/components/Componentes/loading-select-spinner.tsx
-
17src/components/Componentes/question-answer-storage.tsx
-
23src/components/Componentes/question-card.tsx
-
214src/components/Componentes/question-phone.test.tsx
-
390src/components/Componentes/question-phone.tsx
-
70src/components/Componentes/question-sheet.tsx
-
138src/components/Componentes/question-slider.test.tsx
-
214src/components/Componentes/question-slider.tsx
-
134src/components/Componentes/schema-question-flow.integration.test.tsx
-
39src/components/Componentes/slider-slide-two.test.tsx
-
31src/components/Componentes/slider-slide-two.tsx
-
200src/components/Componentes/ui-icon.tsx
-
5src/data/languages.ts
-
4src/hooks/marriage/use-form-schema.ts
-
1src/hooks/marriage/use-marriage-config.ts
-
203src/lib/conditional-rules.test.ts
-
304src/lib/conditional-rules.ts
-
22src/lib/schema-adapter-overview.test.ts
-
113src/lib/schema-adapter.ts
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/02_contact_residence_family_communication.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/03_physical_appearance_health.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/04_education_career_economic_status.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/05_family_background.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/06_marital_status_marriage_history_children.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/07_beliefs_lifestyle_personal_boundaries.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/08_future_spouse_criteria_red_lines.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/09_identity_verification_documents.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/10_personality_test.png
-
BINtemp_extracted_icons/simplified_profile_icons_02_to_11/11_glasser_5_needs_test.png
@ -0,0 +1,214 @@ |
|||||
|
import { |
||||
|
act, |
||||
|
cleanup, |
||||
|
fireEvent, |
||||
|
render, |
||||
|
screen, |
||||
|
waitFor, |
||||
|
} from "@testing-library/react"; |
||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import type { QuestionField } from "@/lib/schema-adapter"; |
||||
|
import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone"; |
||||
|
|
||||
|
let answerMap: Record<string, unknown> = {}; |
||||
|
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { |
||||
|
answerMap[q.id] = val; |
||||
|
}); |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: () => ({ |
||||
|
locale: "en", |
||||
|
dictionary: { "Select country": "Select country" }, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("./question-answer-storage", () => ({ |
||||
|
useQuestionAnswers: () => ({ |
||||
|
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, |
||||
|
setAnswerValue: mockSetAnswerValue, |
||||
|
isLoading: false, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
const phoneQuestion1: QuestionField = { |
||||
|
id: "contact.personal_contact_number", |
||||
|
title: "Personal Contact Number", |
||||
|
type: "phone", |
||||
|
order: 1, |
||||
|
required: true, |
||||
|
baseRequired: true, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] }, |
||||
|
options: [], |
||||
|
}; |
||||
|
|
||||
|
const phoneQuestion2: QuestionField = { |
||||
|
id: "contact.representative_contact_number", |
||||
|
title: "Representative's Contact Number", |
||||
|
type: "phone", |
||||
|
order: 2, |
||||
|
required: false, |
||||
|
baseRequired: false, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] }, |
||||
|
options: [], |
||||
|
}; |
||||
|
|
||||
|
describe("QuestionPhone IP country detection and shimmer", () => { |
||||
|
beforeEach(() => { |
||||
|
answerMap = {}; |
||||
|
mockSetAnswerValue.mockClear(); |
||||
|
localStorage.clear(); |
||||
|
resetGeoPhoneStateForTesting(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("renders shimmer on country button while IP request is pending, then shows resolved country code", async () => { |
||||
|
let resolveIpFetch!: (value: unknown) => void; |
||||
|
const ipPromise = new Promise((resolve) => { |
||||
|
resolveIpFetch = resolve; |
||||
|
}); |
||||
|
|
||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(() => |
||||
|
ipPromise.then( |
||||
|
(data) => |
||||
|
({ |
||||
|
ok: true, |
||||
|
json: async () => data, |
||||
|
}) as unknown as Response, |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
const { container } = render(<QuestionPhone question={phoneQuestion1} />); |
||||
|
|
||||
|
// While IP is pending, shimmer should be present inside the country button
|
||||
|
const shimmerElements = container.querySelectorAll(".shimmer-bg"); |
||||
|
expect(shimmerElements.length).toBeGreaterThan(0); |
||||
|
// The input itself should NOT have shimmer
|
||||
|
const input = screen.getByRole("textbox"); |
||||
|
expect(input.classList.contains("shimmer-bg")).toBe(false); |
||||
|
|
||||
|
// Resolve IP fetch with Iran code
|
||||
|
await act(async () => { |
||||
|
resolveIpFetch({ country_calling_code: "+98" }); |
||||
|
}); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
// Shimmer elements should be gone
|
||||
|
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
||||
|
// Country code +98 should now be visible
|
||||
|
expect(screen.getByText("+98")).toBeDefined(); |
||||
|
expect(screen.getByText("🇮🇷")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("shows default country code when IP request fails", async () => { |
||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue( |
||||
|
new Error("Network failure"), |
||||
|
); |
||||
|
|
||||
|
const { container } = render( |
||||
|
<QuestionPhone question={phoneQuestion1} countryCode="+44" />, |
||||
|
); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
||||
|
expect(screen.getByText("+44")).toBeDefined(); |
||||
|
expect(screen.getByText("🇬🇧")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("fetches IP country code only once when multiple fields are rendered and updates both", async () => { |
||||
|
let resolveIpFetch!: (value: unknown) => void; |
||||
|
const ipPromise = new Promise((resolve) => { |
||||
|
resolveIpFetch = resolve; |
||||
|
}); |
||||
|
|
||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() => |
||||
|
ipPromise.then( |
||||
|
(data) => |
||||
|
({ |
||||
|
ok: true, |
||||
|
json: async () => data, |
||||
|
}) as unknown as Response, |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
render( |
||||
|
<> |
||||
|
<QuestionPhone question={phoneQuestion1} /> |
||||
|
<QuestionPhone question={phoneQuestion2} /> |
||||
|
</>, |
||||
|
); |
||||
|
|
||||
|
// Only 1 fetch call should be triggered for both components
|
||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
await act(async () => { |
||||
|
resolveIpFetch({ country_calling_code: "+98" }); |
||||
|
}); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
const irCodes = screen.getAllByText("+98"); |
||||
|
expect(irCodes.length).toBe(2); |
||||
|
const flags = screen.getAllByText("🇮🇷"); |
||||
|
expect(flags.length).toBe(2); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("does not show shimmer and uses saved profile value if already present", async () => { |
||||
|
answerMap[phoneQuestion1.id] = { |
||||
|
countryCode: "1", |
||||
|
phoneNumber: "2025550143", |
||||
|
}; |
||||
|
|
||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch"); |
||||
|
|
||||
|
const { container } = render(<QuestionPhone question={phoneQuestion1} />); |
||||
|
|
||||
|
// No shimmer because saved value is present
|
||||
|
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); |
||||
|
expect(screen.getByText("+1")).toBeDefined(); |
||||
|
expect(screen.getByDisplayValue("2025550143")).toBeDefined(); |
||||
|
expect(fetchSpy).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it("does not overwrite manual selection when user manually interacts", async () => { |
||||
|
let resolveIpFetch!: (value: unknown) => void; |
||||
|
const ipPromise = new Promise((resolve) => { |
||||
|
resolveIpFetch = resolve; |
||||
|
}); |
||||
|
|
||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(() => |
||||
|
ipPromise.then( |
||||
|
(data) => |
||||
|
({ |
||||
|
ok: true, |
||||
|
json: async () => data, |
||||
|
}) as unknown as Response, |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
render(<QuestionPhone question={phoneQuestion1} />); |
||||
|
|
||||
|
// User starts typing before IP request resolves
|
||||
|
const input = screen.getByRole("textbox"); |
||||
|
fireEvent.change(input, { target: { value: "123456" } }); |
||||
|
|
||||
|
// Resolve IP fetch with Iran code
|
||||
|
await act(async () => { |
||||
|
resolveIpFetch({ country_calling_code: "+98" }); |
||||
|
}); |
||||
|
|
||||
|
// Should retain the manual input
|
||||
|
expect(screen.getByDisplayValue("123456")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,138 @@ |
|||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
||||
|
import type { QuestionField } from "@/lib/schema-adapter"; |
||||
|
import { mapBackendQuestionToFrontend } from "@/lib/schema-adapter"; |
||||
|
import { QuestionSlider } from "./question-slider"; |
||||
|
|
||||
|
let answerValue: any = null; |
||||
|
const setAnswerValueMock = vi.fn(); |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: () => ({ |
||||
|
locale: "en", |
||||
|
dictionary: { From: "From", To: "To" }, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
vi.mock("./question-answer-storage", () => ({ |
||||
|
useQuestionAnswers: () => ({ |
||||
|
getAnswerValue: () => answerValue, |
||||
|
setAnswerValue: setAnswerValueMock, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
describe("QuestionSlider", () => { |
||||
|
beforeEach(() => { |
||||
|
answerValue = null; |
||||
|
setAnswerValueMock.mockClear(); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("extracts range correctly from backend validation schema", () => { |
||||
|
const backendQuestion = { |
||||
|
id: "appearance_health_activity.height_in_centimeters", |
||||
|
title: "Height in Centimeters", |
||||
|
type: "scale", |
||||
|
order: 1, |
||||
|
required: true, |
||||
|
is_required: true, |
||||
|
show_guardian_notice: false, |
||||
|
validation: { min: 100, max: 230 }, |
||||
|
ui_config: { placeholder_en: "175", private: true }, |
||||
|
logic: null, |
||||
|
is_visible: true, |
||||
|
options: [], |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
placeholder: "175", |
||||
|
}; |
||||
|
|
||||
|
const frontendQ = mapBackendQuestionToFrontend(backendQuestion as any, 0); |
||||
|
expect(frontendQ.extras.range).toEqual([100, 230]); |
||||
|
}); |
||||
|
|
||||
|
it("renders scale marks correctly across the range", () => { |
||||
|
const heightQuestion = { |
||||
|
id: "appearance_health_activity.height_in_centimeters", |
||||
|
title: "Height in Centimeters", |
||||
|
type: "scale", |
||||
|
order: 1, |
||||
|
required: true, |
||||
|
baseRequired: true, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "175", range: [100, 230], options: [] }, |
||||
|
options: [], |
||||
|
validation: { min: 100, max: 230 }, |
||||
|
} as QuestionField; |
||||
|
|
||||
|
answerValue = 178; |
||||
|
render(<QuestionSlider question={heightQuestion} />); |
||||
|
|
||||
|
const slider = screen.getByRole("slider") as HTMLInputElement; |
||||
|
expect(slider.min).toBe("100"); |
||||
|
expect(slider.max).toBe("230"); |
||||
|
expect(slider.value).toBe("178"); |
||||
|
|
||||
|
// The value badge shows 178
|
||||
|
expect(screen.getByText("178")).toBeDefined(); |
||||
|
|
||||
|
// Scale marks should be rendered with multiple ticks (100, 120, ..., 230)
|
||||
|
expect(screen.getByText("100")).toBeDefined(); |
||||
|
expect(screen.getByText("230")).toBeDefined(); |
||||
|
expect(screen.queryByText("0")).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it("updates answer value when slider changes", () => { |
||||
|
const weightQuestion = { |
||||
|
id: "appearance_health_activity.weight_in_kilograms", |
||||
|
title: "Weight in Kilograms", |
||||
|
type: "scale", |
||||
|
order: 2, |
||||
|
required: true, |
||||
|
baseRequired: true, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "70", range: [40, 180], options: [] }, |
||||
|
options: [], |
||||
|
validation: { min: 40, max: 180 }, |
||||
|
} as QuestionField; |
||||
|
|
||||
|
render(<QuestionSlider question={weightQuestion} />); |
||||
|
|
||||
|
const slider = screen.getByRole("slider"); |
||||
|
fireEvent.change(slider, { target: { value: "85" } }); |
||||
|
|
||||
|
expect(setAnswerValueMock).toHaveBeenCalledWith(weightQuestion, 85); |
||||
|
}); |
||||
|
|
||||
|
it("falls back gracefully when range is not in extras but in validation", () => { |
||||
|
const customQuestion = { |
||||
|
id: "custom_scale", |
||||
|
title: "Satisfaction", |
||||
|
type: "scale", |
||||
|
order: 3, |
||||
|
required: true, |
||||
|
baseRequired: true, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "5", range: [0, 0], options: [] }, |
||||
|
options: [], |
||||
|
validation: { min: 1, max: 10 }, |
||||
|
} as QuestionField; |
||||
|
|
||||
|
render(<QuestionSlider question={customQuestion} />); |
||||
|
|
||||
|
const slider = screen.getByRole("slider") as HTMLInputElement; |
||||
|
expect(slider.min).toBe("1"); |
||||
|
expect(slider.max).toBe("10"); |
||||
|
expect(screen.getByText("1")).toBeDefined(); |
||||
|
expect(screen.getByText("10")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; |
||||
|
import { describe, expect, it, afterEach, vi } from "vitest"; |
||||
|
import { SliderSlideTwo } from "./slider-slide-two"; |
||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
|
|
||||
|
vi.mock("@/hooks/marriage/use-marriage-config", () => ({ |
||||
|
useMarriageConfigQuery: () => ({ |
||||
|
data: { |
||||
|
intro_video_url: "https://example.com/video.mp4", |
||||
|
intro_video_thumbnail_url: "/assets/images/Frame 2095586523.png", |
||||
|
video_step_2_url: "https://example.com/video2.mp4", |
||||
|
video_step_2_thumbnail_url: "/assets/images/Frame 20953586523.png", |
||||
|
}, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
describe("SliderSlideTwo", () => { |
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("renders video card with portrait thumbnail and play button", () => { |
||||
|
const queryClient = new QueryClient(); |
||||
|
render( |
||||
|
<QueryClientProvider client={queryClient}> |
||||
|
<SliderSlideTwo index={1} /> |
||||
|
</QueryClientProvider>, |
||||
|
); |
||||
|
|
||||
|
const videoImg = screen.getByAltText("video"); |
||||
|
expect(videoImg).toBeDefined(); |
||||
|
expect(videoImg.getAttribute("src")).toContain("Frame%2020953586523.png"); |
||||
|
|
||||
|
const playBtn = screen.getByAltText("play"); |
||||
|
expect(playBtn).toBeDefined(); |
||||
|
|
||||
|
expect(screen.getByText("Dr. Hasti Masoudi")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,203 @@ |
|||||
|
import { describe, it, expect } from "vitest"; |
||||
|
import { |
||||
|
canonicalRule, |
||||
|
ruleMatches, |
||||
|
isQuestionVisible, |
||||
|
isQuestionRequired, |
||||
|
} from "./conditional-rules"; |
||||
|
import type { QuestionField } from "./schema-adapter"; |
||||
|
|
||||
|
describe("Conditional Rules Evaluator", () => { |
||||
|
const dummyQuestion: QuestionField = { |
||||
|
id: "child.q", |
||||
|
title: "Child Question", |
||||
|
type: "text", |
||||
|
order: 1, |
||||
|
required: false, |
||||
|
baseRequired: false, |
||||
|
isVisible: true, |
||||
|
description: "", |
||||
|
tooltip: "", |
||||
|
extras: { placeHolder: "", range: [0, 0], options: [] }, |
||||
|
options: [], |
||||
|
}; |
||||
|
|
||||
|
it("should evaluate single trigger option match correctly", () => { |
||||
|
const rule = { |
||||
|
parent_question_id: "appearance_health.physical_health_status", |
||||
|
trigger_option_ids: [ |
||||
|
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness", |
||||
|
"appearance_health.physical_health_status.i_have_a_physical_deformity_disability_or_limitation", |
||||
|
], |
||||
|
operator: "any_of", |
||||
|
}; |
||||
|
|
||||
|
// 1. When parent is not answered
|
||||
|
expect(ruleMatches(rule, {})).toBe(false); |
||||
|
|
||||
|
// 2. When parent is answered with non-matching option
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"appearance_health.physical_health_status": { |
||||
|
value: "i_am_in_perfect_health", |
||||
|
option_id: "appearance_health.physical_health_status.i_am_in_perfect_health", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(false); |
||||
|
|
||||
|
// 3. When parent is answered with matching option (full slug)
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"appearance_health.physical_health_status": { |
||||
|
value: "i_have_a_specific_or_chronic_illness", |
||||
|
option_id: |
||||
|
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(true); |
||||
|
|
||||
|
// 4. When parent value only contains the short value
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"appearance_health.physical_health_status": { |
||||
|
value: "i_have_a_physical_deformity_disability_or_limitation", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("should evaluate complex nested conditions (e.g. number of children rule)", () => { |
||||
|
// marital_history.number_of_children:
|
||||
|
// parent: children_and_guardianship_status (have_children_living_with_me, have_children_not_living_with_me)
|
||||
|
// conditions: current_marital_status in (divorced_after_living_together, widowed)
|
||||
|
const rule = { |
||||
|
parent_question_id: "marital_history.children_and_guardianship_status", |
||||
|
trigger_option_ids: [ |
||||
|
"marital_history.children_and_guardianship_status.have_children_living_with_me", |
||||
|
"marital_history.children_and_guardianship_status.have_children_not_living_with_me", |
||||
|
], |
||||
|
operator: "any_of", |
||||
|
conditions: [ |
||||
|
{ |
||||
|
parent_question_id: "marital_history.current_marital_status", |
||||
|
trigger_option_ids: [ |
||||
|
"marital_history.current_marital_status.divorced_after_living_together", |
||||
|
"marital_history.current_marital_status.widowed", |
||||
|
], |
||||
|
operator: "any_of", |
||||
|
}, |
||||
|
], |
||||
|
conditions_operator: "all_of", |
||||
|
root_operator: "all_of", |
||||
|
}; |
||||
|
|
||||
|
// Case 1: Single, no children -> false
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"marital_history.current_marital_status": { |
||||
|
value: "single_never_married", |
||||
|
}, |
||||
|
"marital_history.children_and_guardianship_status": { |
||||
|
value: "no_children", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(false); |
||||
|
|
||||
|
// Case 2: Divorced, but no children -> false
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"marital_history.current_marital_status": { |
||||
|
value: "divorced_after_living_together", |
||||
|
option_id: |
||||
|
"marital_history.current_marital_status.divorced_after_living_together", |
||||
|
}, |
||||
|
"marital_history.children_and_guardianship_status": { |
||||
|
value: "no_children", |
||||
|
option_id: |
||||
|
"marital_history.children_and_guardianship_status.no_children", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(false); |
||||
|
|
||||
|
// Case 3: Divorced AND has children -> true
|
||||
|
expect( |
||||
|
ruleMatches(rule, { |
||||
|
"marital_history.current_marital_status": { |
||||
|
value: "divorced_after_living_together", |
||||
|
option_id: |
||||
|
"marital_history.current_marital_status.divorced_after_living_together", |
||||
|
}, |
||||
|
"marital_history.children_and_guardianship_status": { |
||||
|
value: "have_children_living_with_me", |
||||
|
option_id: |
||||
|
"marital_history.children_and_guardianship_status.have_children_living_with_me", |
||||
|
}, |
||||
|
}), |
||||
|
).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("should evaluate audience rules by gender and age", () => { |
||||
|
const questionForMen: QuestionField = { |
||||
|
...dummyQuestion, |
||||
|
id: "education_career.ability_to_support_marriage_expenses", |
||||
|
audience: { |
||||
|
genders: ["male"], |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const questionForWomen: QuestionField = { |
||||
|
...dummyQuestion, |
||||
|
id: "beliefs_lifestyle.makeup_in_public", |
||||
|
audience: { |
||||
|
genders: ["female"], |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
// Male user
|
||||
|
expect(isQuestionVisible(questionForMen, {}, { gender: "male" })).toBe(true); |
||||
|
expect(isQuestionVisible(questionForWomen, {}, { gender: "male" })).toBe(false); |
||||
|
|
||||
|
// Female user
|
||||
|
expect(isQuestionVisible(questionForMen, {}, { gender: "female" })).toBe(false); |
||||
|
expect(isQuestionVisible(questionForWomen, {}, { gender: "female" })).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("should evaluate dynamic requirement via requiredWhen", () => { |
||||
|
const q: QuestionField = { |
||||
|
...dummyQuestion, |
||||
|
required: false, |
||||
|
baseRequired: false, |
||||
|
requiredWhen: { |
||||
|
parent_question_id: "family_background.parents_marital_status", |
||||
|
trigger_option_ids: [ |
||||
|
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
||||
|
], |
||||
|
operator: "any_of", |
||||
|
}, |
||||
|
conditionalRule: { |
||||
|
parent_question_id: "family_background.parents_marital_status", |
||||
|
trigger_option_ids: [ |
||||
|
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
||||
|
], |
||||
|
operator: "any_of", |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
// When condition not met -> not visible, not required
|
||||
|
expect(isQuestionVisible(q, {})).toBe(false); |
||||
|
expect(isQuestionRequired(q, {})).toBe(false); |
||||
|
|
||||
|
// When condition met -> visible AND required
|
||||
|
const matchingAnswers = { |
||||
|
"family_background.parents_marital_status": { |
||||
|
value: |
||||
|
"i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
||||
|
option_id: |
||||
|
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description", |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
expect(isQuestionVisible(q, matchingAnswers)).toBe(true); |
||||
|
expect(isQuestionRequired(q, matchingAnswers)).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,304 @@ |
|||||
|
import type { QuestionField } from "./schema-adapter"; |
||||
|
|
||||
|
export type CanonicalRule = { |
||||
|
parent_question_id?: string; |
||||
|
trigger_option_ids?: string[]; |
||||
|
operator?: "any_of" | "all_of" | "equals" | "exists"; |
||||
|
clear_answer_when_hidden?: boolean; |
||||
|
audience?: { |
||||
|
genders?: string[]; |
||||
|
minAge?: number; |
||||
|
maxAge?: number; |
||||
|
}; |
||||
|
conditions?: CanonicalRule[]; |
||||
|
conditions_operator?: "any_of" | "all_of"; |
||||
|
root_operator?: "any_of" | "all_of"; |
||||
|
dependsOn?: { |
||||
|
key?: string; |
||||
|
values?: string[]; |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
export type UserContext = { |
||||
|
gender?: string | null; |
||||
|
age?: number | null; |
||||
|
}; |
||||
|
|
||||
|
export function canonicalRule(rule: any): CanonicalRule | null { |
||||
|
if (!rule || typeof rule !== "object") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// If wrapped in dependsOn (legacy)
|
||||
|
if (rule.dependsOn && !rule.parent_question_id) { |
||||
|
return { |
||||
|
dependsOn: rule.dependsOn, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const operator = ["any_of", "all_of", "equals", "exists"].includes( |
||||
|
rule.operator, |
||||
|
) |
||||
|
? rule.operator |
||||
|
: "any_of"; |
||||
|
|
||||
|
let optionIds: string[] = []; |
||||
|
if (Array.isArray(rule.trigger_option_ids)) { |
||||
|
optionIds = rule.trigger_option_ids.map(String); |
||||
|
} else if (rule.trigger_option_ids !== undefined && rule.trigger_option_ids !== null) { |
||||
|
optionIds = [String(rule.trigger_option_ids)]; |
||||
|
} |
||||
|
|
||||
|
const result: CanonicalRule = { |
||||
|
parent_question_id: rule.parent_question_id || rule.parentQuestionId, |
||||
|
trigger_option_ids: optionIds, |
||||
|
operator, |
||||
|
clear_answer_when_hidden: Boolean(rule.clear_answer_when_hidden), |
||||
|
}; |
||||
|
|
||||
|
if (rule.audience && typeof rule.audience === "object") { |
||||
|
result.audience = rule.audience; |
||||
|
} |
||||
|
|
||||
|
if (Array.isArray(rule.conditions)) { |
||||
|
result.conditions = rule.conditions |
||||
|
.map(canonicalRule) |
||||
|
.filter((c: CanonicalRule | null): c is CanonicalRule => c !== null); |
||||
|
result.conditions_operator = |
||||
|
rule.conditions_operator === "any_of" ? "any_of" : "all_of"; |
||||
|
result.root_operator = |
||||
|
rule.root_operator === "any_of" ? "any_of" : "all_of"; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
export function isAnswerPresent(answer: any): boolean { |
||||
|
if (answer === undefined || answer === null) { |
||||
|
return false; |
||||
|
} |
||||
|
const val = typeof answer === "object" && "value" in answer ? answer.value : answer; |
||||
|
if (val === undefined || val === null) { |
||||
|
return false; |
||||
|
} |
||||
|
if (typeof val === "string") { |
||||
|
return val.trim().length > 0; |
||||
|
} |
||||
|
if (Array.isArray(val)) { |
||||
|
return val.length > 0; |
||||
|
} |
||||
|
if (typeof val === "object") { |
||||
|
return Object.keys(val).length > 0; |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
function getSelectedOptionTokens(answer: any): Set<string> { |
||||
|
const tokens = new Set<string>(); |
||||
|
if (!answer) return tokens; |
||||
|
|
||||
|
const rawOptionId = |
||||
|
typeof answer === "object" && "option_id" in answer |
||||
|
? answer.option_id |
||||
|
: undefined; |
||||
|
const rawValue = |
||||
|
typeof answer === "object" && "value" in answer ? answer.value : answer; |
||||
|
|
||||
|
const addToken = (item: any) => { |
||||
|
if (item === undefined || item === null) return; |
||||
|
const str = String(item).trim(); |
||||
|
if (!str) return; |
||||
|
tokens.add(str.toLowerCase()); |
||||
|
// If it has a dot prefix like "sec.q.opt", also add the suffix "opt"
|
||||
|
const lastDot = str.lastIndexOf("."); |
||||
|
if (lastDot !== -1 && lastDot < str.length - 1) { |
||||
|
tokens.add(str.slice(lastDot + 1).toLowerCase()); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
if (Array.isArray(rawOptionId)) { |
||||
|
rawOptionId.forEach(addToken); |
||||
|
} else if (rawOptionId !== undefined) { |
||||
|
addToken(rawOptionId); |
||||
|
} |
||||
|
|
||||
|
if (Array.isArray(rawValue)) { |
||||
|
rawValue.forEach(addToken); |
||||
|
} else if (rawValue !== undefined) { |
||||
|
addToken(rawValue); |
||||
|
} |
||||
|
|
||||
|
return tokens; |
||||
|
} |
||||
|
|
||||
|
export function matchesAudience( |
||||
|
audience: { genders?: string[]; minAge?: number; maxAge?: number } | undefined, |
||||
|
context?: UserContext, |
||||
|
): boolean { |
||||
|
if (!audience || typeof audience !== "object") { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (audience.genders && audience.genders.length > 0) { |
||||
|
if (context?.gender && !audience.genders.includes(context.gender)) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (audience.minAge !== undefined && context?.age !== undefined && context.age !== null) { |
||||
|
if (context.age < audience.minAge) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (audience.maxAge !== undefined && context?.age !== undefined && context.age !== null) { |
||||
|
if (context.age > audience.maxAge) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
export function ruleMatches( |
||||
|
rawRule: any, |
||||
|
answers: Record<string, any>, |
||||
|
context?: UserContext, |
||||
|
): boolean { |
||||
|
const rule = canonicalRule(rawRule); |
||||
|
if (!rule) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (rule.audience && !matchesAudience(rule.audience, context)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Handle legacy dependsOn
|
||||
|
if (rule.dependsOn && rule.dependsOn.key) { |
||||
|
const parentId = rule.dependsOn.key; |
||||
|
const answer = answers[parentId]; |
||||
|
if (!isAnswerPresent(answer)) { |
||||
|
return false; |
||||
|
} |
||||
|
const expectedValues = (rule.dependsOn.values || []).map((v) => |
||||
|
String(v).toLowerCase().trim(), |
||||
|
); |
||||
|
const actualTokens = getSelectedOptionTokens(answer); |
||||
|
const hasMatch = expectedValues.some((v) => actualTokens.has(v)); |
||||
|
return hasMatch; |
||||
|
} |
||||
|
|
||||
|
let mainMatches = true; |
||||
|
const parentId = rule.parent_question_id; |
||||
|
|
||||
|
if (parentId) { |
||||
|
const answer = answers[parentId]; |
||||
|
const operator = rule.operator || "any_of"; |
||||
|
|
||||
|
if (operator === "exists") { |
||||
|
mainMatches = isAnswerPresent(answer); |
||||
|
} else if (!isAnswerPresent(answer)) { |
||||
|
mainMatches = false; |
||||
|
} else { |
||||
|
const actualTokens = getSelectedOptionTokens(answer); |
||||
|
const expectedIds = (rule.trigger_option_ids || []).map((id) => |
||||
|
String(id).toLowerCase().trim(), |
||||
|
); |
||||
|
|
||||
|
const isTokenMatched = (expectedId: string) => { |
||||
|
if (actualTokens.has(expectedId)) return true; |
||||
|
const lastDot = expectedId.lastIndexOf("."); |
||||
|
if (lastDot !== -1 && lastDot < expectedId.length - 1) { |
||||
|
const suffix = expectedId.slice(lastDot + 1); |
||||
|
if (actualTokens.has(suffix)) return true; |
||||
|
} |
||||
|
return false; |
||||
|
}; |
||||
|
|
||||
|
if (operator === "all_of") { |
||||
|
mainMatches = expectedIds.length > 0 && expectedIds.every(isTokenMatched); |
||||
|
} else if (operator === "equals") { |
||||
|
mainMatches = |
||||
|
expectedIds.length > 0 && |
||||
|
expectedIds.every(isTokenMatched) && |
||||
|
actualTokens.size <= expectedIds.length * 2; |
||||
|
} else { |
||||
|
// default: "any_of"
|
||||
|
mainMatches = expectedIds.some(isTokenMatched); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (rule.conditions && rule.conditions.length > 0) { |
||||
|
const subMatches = rule.conditions.map((cond) => |
||||
|
ruleMatches(cond, answers, context), |
||||
|
); |
||||
|
const condOperator = rule.conditions_operator || "all_of"; |
||||
|
const conditionsResult = |
||||
|
condOperator === "any_of" |
||||
|
? subMatches.some(Boolean) |
||||
|
: subMatches.every(Boolean); |
||||
|
|
||||
|
if (!parentId) { |
||||
|
return conditionsResult; |
||||
|
} |
||||
|
|
||||
|
const rootOp = rule.root_operator || "all_of"; |
||||
|
return rootOp === "any_of" |
||||
|
? mainMatches || conditionsResult |
||||
|
: mainMatches && conditionsResult; |
||||
|
} |
||||
|
|
||||
|
return mainMatches; |
||||
|
} |
||||
|
|
||||
|
export function isQuestionVisible( |
||||
|
question: QuestionField, |
||||
|
answers: Record<string, any>, |
||||
|
context?: UserContext, |
||||
|
): boolean { |
||||
|
// 1. Audience check
|
||||
|
if (question.audience && !matchesAudience(question.audience, context)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 2. Canonical visibility / conditional rule
|
||||
|
const rule = |
||||
|
question.visibility || |
||||
|
question.conditionalRule || |
||||
|
question.logic; |
||||
|
|
||||
|
if (rule) { |
||||
|
return ruleMatches(rule, answers, context); |
||||
|
} |
||||
|
|
||||
|
if (question.isVisible !== undefined) { |
||||
|
return question.isVisible; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
export function isQuestionRequired( |
||||
|
question: QuestionField, |
||||
|
answers: Record<string, any>, |
||||
|
context?: UserContext, |
||||
|
): boolean { |
||||
|
if (!isQuestionVisible(question, answers, context)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (question.required || question.baseRequired) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (question.requiredWhen) { |
||||
|
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) { |
||||
|
return matchesAudience(question.requiredWhen, context); |
||||
|
} |
||||
|
return ruleMatches(question.requiredWhen, answers, context); |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
After Width: 1254 | Height: 1254 | Size: 987 KiB |
|
After Width: 1254 | Height: 1254 | Size: 952 KiB |
|
After Width: 1254 | Height: 1254 | Size: 980 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1.0 MiB |
|
After Width: 1254 | Height: 1254 | Size: 805 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1018 KiB |
|
After Width: 1254 | Height: 1254 | Size: 899 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1021 KiB |
|
After Width: 1254 | Height: 1254 | Size: 1021 KiB |
|
After Width: 1254 | Height: 1254 | Size: 794 KiB |