Browse Source
feat: implement multi-select logic with exclusive option handling and add accompanying tests and UI component integration.
staging
feat: implement multi-select logic with exclusive option handling and add accompanying tests and UI component integration.
staging
12 changed files with 1038 additions and 75 deletions
-
93src/app/sheet-lab/page.tsx
-
16src/components/Componentes/question-checkbox.tsx
-
17src/components/Componentes/question-dropdown.tsx
-
4src/components/Componentes/question-file.tsx
-
138src/components/Componentes/question-sheet.test.tsx
-
160src/components/Componentes/question-sheet.tsx
-
125src/components/Componentes/terms-sheet.test.tsx
-
330src/components/Componentes/terms-sheet.tsx
-
2src/hooks/marriage/use-form-schema.ts
-
132src/lib/multi-select-helper.test.ts
-
76src/lib/multi-select-helper.ts
-
2src/lib/schema-adapter.ts
@ -0,0 +1,93 @@ |
|||
"use client"; |
|||
|
|||
/** |
|||
* Standalone dev page for testing QuestionSheet sizing behavior in a browser. |
|||
* No login, no backend: profile query data is seeded into the QueryClient so |
|||
* QuestionAnswersProvider never needs the API. Hardcoded questions only. |
|||
*/ |
|||
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { useState } from "react"; |
|||
import QuestionSheet from "@/components/Componentes/question-sheet"; |
|||
import { QuestionAnswersProvider } from "@/components/Componentes/question-answer-storage"; |
|||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|||
import type { QuestionField } from "@/lib/schema-adapter"; |
|||
import { I18nProvider } from "@/translations/provider"; |
|||
|
|||
function makeOptions(count: number, prefix: string): QuestionField["options"] { |
|||
return Array.from({ length: count }, (_, index) => ({ |
|||
id: `${prefix}-${index + 1}`, |
|||
value: `${prefix}-${index + 1}`, |
|||
label: `${prefix} ${index + 1}`, |
|||
order: index + 1, |
|||
})); |
|||
} |
|||
|
|||
const fourOptionQuestion: QuestionField = { |
|||
id: "sheet_lab.four_options", |
|||
title: "Sheet Lab — 4 options (compact)", |
|||
type: "dropdown", |
|||
order: 1, |
|||
required: false, |
|||
baseRequired: false, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "Select (4)", range: [0, 1], options: [] }, |
|||
options: makeOptions(4, "Option"), |
|||
ui_config: {}, |
|||
}; |
|||
|
|||
const manyOptionQuestion: QuestionField = { |
|||
id: "sheet_lab.many_options", |
|||
title: "Sheet Lab — 67 options (draggable)", |
|||
type: "dropdown", |
|||
order: 2, |
|||
required: false, |
|||
baseRequired: false, |
|||
isVisible: true, |
|||
description: "", |
|||
tooltip: "", |
|||
extras: { placeHolder: "Select (67)", range: [0, 1], options: [] }, |
|||
options: makeOptions(67, "Item"), |
|||
ui_config: {}, |
|||
}; |
|||
|
|||
export default function SheetLabPage() { |
|||
const [queryClient] = useState(() => { |
|||
const qc = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { |
|||
refetchOnMount: false, |
|||
refetchOnWindowFocus: false, |
|||
refetchOnReconnect: false, |
|||
retry: false, |
|||
staleTime: Infinity, |
|||
}, |
|||
}, |
|||
}); |
|||
// Seed the profile cache so QuestionAnswersProvider never hits the API.
|
|||
qc.setQueryData(marriageQueryKeys.profile(), { |
|||
id: 1, |
|||
can_edit_profile: true, |
|||
}); |
|||
return qc; |
|||
}); |
|||
|
|||
return ( |
|||
<I18nProvider locale="en"> |
|||
<QueryClientProvider client={queryClient}> |
|||
<QuestionAnswersProvider |
|||
slug="sheet-lab" |
|||
questions={[fourOptionQuestion, manyOptionQuestion]} |
|||
> |
|||
<main className="mx-auto flex max-w-[480px] flex-col gap-8 p-4 pt-10"> |
|||
<h1 className="text-lg font-bold">Sheet Lab</h1> |
|||
<QuestionSheet question={fourOptionQuestion} /> |
|||
<QuestionSheet question={manyOptionQuestion} /> |
|||
</main> |
|||
</QuestionAnswersProvider> |
|||
</QueryClientProvider> |
|||
</I18nProvider> |
|||
); |
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import TermsSheet from "./terms-sheet"; |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ |
|||
locale: "fa", |
|||
dictionary: { |
|||
"terms & conditions": "قوانین و مقررات", |
|||
"Got it": "متوجه شدم", |
|||
"Close terms and conditions": "بستن قوانین و مقررات", |
|||
}, |
|||
}), |
|||
})); |
|||
|
|||
describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
it("does not render when isOpen is false", () => { |
|||
render(<TermsSheet isOpen={false} />); |
|||
expect(screen.queryByRole("dialog")).toBeNull(); |
|||
}); |
|||
|
|||
it("renders with initial 75% height when isOpen is true", () => { |
|||
render(<TermsSheet isOpen={true} />); |
|||
|
|||
const dialog = screen.getByRole("dialog"); |
|||
expect(dialog).toBeInTheDocument(); |
|||
|
|||
const section = dialog.querySelector("section")!; |
|||
expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]"); |
|||
expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument(); |
|||
expect(screen.getByText("متوجه شدم")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("grows sheet when pulling up at top of content", () => { |
|||
render(<TermsSheet isOpen={true} />); |
|||
|
|||
const dialog = screen.getByRole("dialog"); |
|||
const section = dialog.querySelector("section")!; |
|||
const content = screen.getByTestId("terms-sheet-content"); |
|||
|
|||
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
|||
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
|||
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
|||
|
|||
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
|||
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); |
|||
|
|||
expect(section.style.getPropertyValue("--sheet-size")).toBe( |
|||
(0.75 + 100 / window.innerHeight).toFixed(4), |
|||
); |
|||
}); |
|||
|
|||
it("grows sheet on wheel event at top of content", () => { |
|||
render(<TermsSheet isOpen={true} />); |
|||
|
|||
const dialog = screen.getByRole("dialog"); |
|||
const section = dialog.querySelector("section")!; |
|||
const content = screen.getByTestId("terms-sheet-content"); |
|||
|
|||
Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); |
|||
Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); |
|||
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
|||
|
|||
fireEvent.wheel(content, { deltaY: 120 }); |
|||
|
|||
expect(section.style.getPropertyValue("--sheet-size")).toBe( |
|||
(0.75 + 120 / window.innerHeight).toFixed(4), |
|||
); |
|||
}); |
|||
|
|||
it("shrinks to 60% floor and closes when pulled past it (shouldCloseOnMinExtent: true)", async () => { |
|||
const handleClose = vi.fn(); |
|||
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
|||
|
|||
const dialog = screen.getByRole("dialog"); |
|||
const section = dialog.querySelector("section")!; |
|||
const content = screen.getByTestId("terms-sheet-content"); |
|||
|
|||
Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); |
|||
|
|||
// Pull down at the top: sheet shrinks and clamps at 0.6 floor
|
|||
fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); |
|||
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 560 }] }); |
|||
expect(section.style.getPropertyValue("--sheet-size")).toBe("0.6000"); |
|||
|
|||
// Pull further past 0.6: triggers close
|
|||
fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 580 }] }); |
|||
|
|||
await waitFor(() => { |
|||
expect(handleClose).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it("calls onClose when Got it button is clicked", async () => { |
|||
const handleClose = vi.fn(); |
|||
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
|||
|
|||
const gotItButton = screen.getByRole("button", { name: "متوجه شدم" }); |
|||
fireEvent.click(gotItButton); |
|||
|
|||
await waitFor(() => { |
|||
expect(handleClose).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
it("calls onClose when close X button is clicked", async () => { |
|||
const handleClose = vi.fn(); |
|||
render(<TermsSheet isOpen={true} onClose={handleClose} />); |
|||
|
|||
const closeButton = screen.getByRole("button", { name: "بستن قوانین و مقررات" }); |
|||
fireEvent.click(closeButton); |
|||
|
|||
await waitFor(() => { |
|||
expect(handleClose).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,132 @@ |
|||
import { describe, it, expect } from "vitest"; |
|||
import { |
|||
isExclusiveOption, |
|||
resolveMultiOptionToggle, |
|||
type MultiSelectOption, |
|||
} from "./multi-select-helper"; |
|||
|
|||
describe("multi-select-helper", () => { |
|||
const options: MultiSelectOption[] = [ |
|||
{ id: "opt1", value: "football", label: "Football" }, |
|||
{ id: "opt2", value: "swimming", label: "Swimming" }, |
|||
{ id: "opt3", value: "running", label: "Running" }, |
|||
{ id: "opt_none", value: "none", label: "None of the above", is_exclusive: true }, |
|||
]; |
|||
|
|||
describe("isExclusiveOption", () => { |
|||
it("identifies explicit is_exclusive property", () => { |
|||
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true); |
|||
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false); |
|||
}); |
|||
|
|||
it("accepts the explicit flag and rejects everything else (SSOT: backend flag only)", () => { |
|||
expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true); |
|||
expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false); |
|||
expect(isExclusiveOption({ id: "custom" })).toBe(false); |
|||
}); |
|||
|
|||
it("returns false for missing/empty/string-only input (no flag = not exclusive)", () => { |
|||
expect(isExclusiveOption("")).toBe(false); |
|||
expect(isExclusiveOption("test.none")).toBe(false); |
|||
}); |
|||
|
|||
it("does NOT guess from canonical values or slug suffixes (SSOT: backend flag only)", () => { |
|||
expect(isExclusiveOption({ id: "test.none", value: "none" })).toBe(false); |
|||
expect(isExclusiveOption({ id: "test.no_pets", value: "no_pets" })).toBe(false); |
|||
expect( |
|||
isExclusiveOption( |
|||
"spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult", |
|||
), |
|||
).toBe(false); |
|||
expect( |
|||
isExclusiveOption({ |
|||
id: "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult", |
|||
value: "no_appearance_feature_alone_makes_difficult", |
|||
}), |
|||
).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe("resolveMultiOptionToggle", () => { |
|||
it("adds a regular option when none was selected", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: [], |
|||
optionId: "opt1", |
|||
options, |
|||
}); |
|||
expect(res).toEqual(["opt1"]); |
|||
}); |
|||
|
|||
it("adds multiple regular options sequentially", () => { |
|||
const res1 = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1"], |
|||
optionId: "opt2", |
|||
options, |
|||
}); |
|||
expect(res1).toEqual(["opt1", "opt2"]); |
|||
|
|||
const res2 = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1", "opt2"], |
|||
optionId: "opt3", |
|||
options, |
|||
}); |
|||
expect(res2).toEqual(["opt1", "opt2", "opt3"]); |
|||
}); |
|||
|
|||
it("deselects a regular option when toggled again", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1", "opt2"], |
|||
optionId: "opt1", |
|||
options, |
|||
}); |
|||
expect(res).toEqual(["opt2"]); |
|||
}); |
|||
|
|||
it("clears ALL regular options when an exclusive option is selected", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1", "opt2", "opt3"], |
|||
optionId: "opt_none", |
|||
options, |
|||
}); |
|||
expect(res).toEqual(["opt_none"]); |
|||
}); |
|||
|
|||
it("deselects the exclusive option when toggled again", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt_none"], |
|||
optionId: "opt_none", |
|||
options, |
|||
}); |
|||
expect(res).toEqual([]); |
|||
}); |
|||
|
|||
it("clears the exclusive option when a regular option is clicked", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt_none"], |
|||
optionId: "opt1", |
|||
options, |
|||
}); |
|||
expect(res).toEqual(["opt1"]); |
|||
}); |
|||
|
|||
it("enforces maxSelect for regular options without exclusive conflict", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1", "opt2"], |
|||
optionId: "opt3", |
|||
options, |
|||
maxSelect: 2, |
|||
}); |
|||
expect(res).toEqual(["opt1", "opt2"]); // Capped at 2
|
|||
}); |
|||
|
|||
it("allows selecting exclusive option even when regular options reach maxSelect", () => { |
|||
const res = resolveMultiOptionToggle({ |
|||
currentSelected: ["opt1", "opt2"], |
|||
optionId: "opt_none", |
|||
options, |
|||
maxSelect: 2, |
|||
}); |
|||
expect(res).toEqual(["opt_none"]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,76 @@ |
|||
export type MultiSelectOption = { |
|||
id: string; |
|||
value?: string | number; |
|||
label?: string; |
|||
is_exclusive?: boolean; |
|||
}; |
|||
|
|||
/** |
|||
* Determine if an option is mutually exclusive (مانعةالجمع) with the other |
|||
* options of its question. |
|||
* |
|||
* ⚠️ Single Source of Truth: exclusivity is configured in the live database |
|||
* (via the admin dashboard) and delivered by the backend as the |
|||
* `is_exclusive` flag on every option. No hardcoded slug/suffix heuristics. |
|||
*/ |
|||
export function isExclusiveOption(option: MultiSelectOption | string): boolean { |
|||
if (!option || typeof option !== "object") return false; |
|||
return option.is_exclusive === true; |
|||
} |
|||
|
|||
/** |
|||
* Resolves toggling an option in a multi-select context with automatic mutual exclusion (مانعةالجمع). |
|||
* |
|||
* Behavior: |
|||
* 1. If an exclusive option is selected: |
|||
* - All other selected options are automatically cleared (deselected). |
|||
* - Only the exclusive option remains selected. |
|||
* 2. If an exclusive option is deselected: |
|||
* - It is removed, leaving an empty selection. |
|||
* 3. If a regular (non-exclusive) option is selected while an exclusive option was active: |
|||
* - The exclusive option is automatically cleared (deselected). |
|||
* - The new regular option is selected. |
|||
* 4. Respects maxSelect limit for regular options. |
|||
*/ |
|||
export function resolveMultiOptionToggle({ |
|||
currentSelected, |
|||
optionId, |
|||
options = [], |
|||
maxSelect, |
|||
}: { |
|||
currentSelected: string[]; |
|||
optionId: string; |
|||
options?: MultiSelectOption[]; |
|||
maxSelect?: number; |
|||
}): string[] { |
|||
const targetOption = options.find((o) => o.id === optionId) || { id: optionId }; |
|||
const isExclusive = isExclusiveOption(targetOption); |
|||
const isAlreadySelected = currentSelected.includes(optionId); |
|||
|
|||
// Case 1: Toggling an exclusive option
|
|||
if (isExclusive) { |
|||
if (isAlreadySelected) { |
|||
return []; |
|||
} |
|||
return [optionId]; |
|||
} |
|||
|
|||
// Case 2: Toggling a regular option that is already selected -> remove it
|
|||
if (isAlreadySelected) { |
|||
return currentSelected.filter((id) => id !== optionId); |
|||
} |
|||
|
|||
// Case 3: Adding a regular option that was NOT selected:
|
|||
// First, filter out any exclusive option(s) from current selection
|
|||
const cleanSelected = currentSelected.filter((id) => { |
|||
const opt = options.find((o) => o.id === id) || { id }; |
|||
return !isExclusiveOption(opt); |
|||
}); |
|||
|
|||
// Check maxSelect limit
|
|||
if (maxSelect && cleanSelected.length >= maxSelect) { |
|||
return cleanSelected; |
|||
} |
|||
|
|||
return [...cleanSelected, optionId]; |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue