Browse Source

refactor(ui): add refs and state for positioning in TokenSwitcher

Dev
mortezaei 1 week ago
parent
commit
bf02149635
  1. 18
      src/app/globals.css
  2. 137
      src/app/questions-list/page.tsx
  3. 49
      src/components/Componentes/question-answer-storage.tsx
  4. 257
      src/components/Componentes/question-answer.test.tsx
  5. 1
      src/components/Componentes/schema-question-flow.integration.test.tsx
  6. 185
      src/components/Componentes/token-switcher.tsx
  7. 28
      src/hooks/marriage/types.ts
  8. 99
      src/hooks/marriage/use-section-data.test.ts
  9. 167
      src/hooks/marriage/use-section-data.ts

18
src/app/globals.css

@ -145,22 +145,22 @@
} }
html { html {
width: 100%;
height: 100%; height: 100%;
overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
overscroll-behavior-y: none;
} }
body { body {
min-height: 100%;
width: 100%;
height: 100%;
margin: 0; margin: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden;
color: var(--foreground); color: var(--foreground);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overscroll-behavior: none; overscroll-behavior: none;
overscroll-behavior-y: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
} }
html:lang(ar) body, html:lang(ar) body,
@ -186,17 +186,21 @@ html:lang(ar) body,
.app-shell { .app-shell {
width: 100%; width: 100%;
min-height: 100%;
height: 100%;
height: 100dvh;
padding-inline: 17px; padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px); padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
background-color: var(--background); background-color: var(--background);
background-image: var(--default-page-background-image); background-image: var(--default-page-background-image);
background-position: top; background-position: top;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
overscroll-behavior: none; overscroll-behavior: none;
overscroll-behavior-y: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
} }
html[data-web-bootstrap="pending"] .app-shell { html[data-web-bootstrap="pending"] .app-shell {

137
src/app/questions-list/page.tsx

@ -1,41 +1,46 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button"; import NavigationButton from "@/components/Componentes/navigation-button";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
import ErrorToast from "@/components/Componentes/error-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import { import {
getFormSection, getFormSection,
useFormOverviewQuery, useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema"; } from "@/hooks/marriage/use-form-schema";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
applyProfilePatchResultToCache,
updateMarriageSectionData,
} from "@/hooks/marriage/use-section-data";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
import { import {
clearMatchStartGrace, clearMatchStartGrace,
markMatchStarted, markMatchStarted,
} from "@/lib/match-start-grace"; } from "@/lib/match-start-grace";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
export default function QuestionsListPage() { export default function QuestionsListPage() {
useCloseServiceOnBack(); useCloseServiceOnBack();
@ -58,7 +63,8 @@ export default function QuestionsListPage() {
const isProfileRedirecting = const isProfileRedirecting =
profileTargetPath !== null && profileTargetPath !== null &&
profileTargetPath !== "/questions-list" && profileTargetPath !== "/questions-list" &&
(!hasCompletedMarriageProfileBasics(profile) || profile?.can_edit_profile === false);
(!hasCompletedMarriageProfileBasics(profile) ||
profile?.can_edit_profile === false);
useEffect(() => { useEffect(() => {
if (isProfileRedirecting && profileTargetPath) { if (isProfileRedirecting && profileTargetPath) {
@ -153,15 +159,22 @@ export default function QuestionsListPage() {
setDisplayedRequiredSections(completedRequiredSections); setDisplayedRequiredSections(completedRequiredSections);
return; return;
} }
const direction = completedRequiredSections > displayedRequiredSections ? 1 : -1;
const distance = Math.abs(completedRequiredSections - displayedRequiredSections);
const interval = window.setInterval(() => {
setDisplayedRequiredSections((current) => {
const next = current + direction;
if (next === completedRequiredSections) window.clearInterval(interval);
return next;
});
}, Math.max(90, Math.floor(500 / distance)));
const direction =
completedRequiredSections > displayedRequiredSections ? 1 : -1;
const distance = Math.abs(
completedRequiredSections - displayedRequiredSections,
);
const interval = window.setInterval(
() => {
setDisplayedRequiredSections((current) => {
const next = current + direction;
if (next === completedRequiredSections)
window.clearInterval(interval);
return next;
});
},
Math.max(90, Math.floor(500 / distance)),
);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [completedRequiredSections, displayedRequiredSections]); }, [completedRequiredSections, displayedRequiredSections]);
@ -186,7 +199,11 @@ export default function QuestionsListPage() {
const progress = sectionProgressBySlug.get(item.slug) ?? 0; const progress = sectionProgressBySlug.get(item.slug) ?? 0;
return progress >= 100; return progress >= 100;
}); });
}, [hasValidRequiredContract, requiredQuestionListItems, sectionProgressBySlug]);
}, [
hasValidRequiredContract,
requiredQuestionListItems,
sectionProgressBySlug,
]);
const profileStatus = profile?.status; const profileStatus = profile?.status;
const isProfileSuspended = profileStatus === "suspended"; const isProfileSuspended = profileStatus === "suspended";
@ -200,12 +217,24 @@ export default function QuestionsListPage() {
const syncPendingAnswers = useCallback(async () => { const syncPendingAnswers = useCallback(async () => {
if (!overview) return; if (!overview) return;
const { updateMarriageSectionData } = await import(
"@/hooks/marriage/use-section-data"
);
let currentVersion = overview.version;
const pendingSections: Array<{
storageKey: string;
storedValue: {
current_step: number;
fields: MarriageField[];
pending_keys: string[];
pending_sync: boolean;
};
fields: MarriageField[];
slug: string;
}> = [];
for (const item of questionListItems) { for (const item of questionListItems) {
if (
item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test"
) {
continue;
}
const storageKey = getQuestionAnswersStorageKey(item.slug); const storageKey = getQuestionAnswersStorageKey(item.slug);
const rawValue = window.localStorage.getItem(storageKey); const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue; if (!rawValue) continue;
@ -218,22 +247,40 @@ export default function QuestionsListPage() {
? storedValue.pending_keys ? storedValue.pending_keys
: storedValue.fields.map((field: { key: string }) => field.key), : storedValue.fields.map((field: { key: string }) => field.key),
); );
for (const field of storedValue.fields) {
if (!pendingKeys.has(field.key)) continue;
const result = await updateMarriageSectionData(item.slug, {
version: currentVersion,
current_step: storedValue.current_step,
fields: [field],
});
currentVersion = result.version;
pendingKeys.delete(field.key);
storedValue.pending_keys = [...pendingKeys];
const pendingFields = storedValue.fields.filter(
(field: { key: string }) => pendingKeys.has(field.key),
);
if (pendingFields.length === 0) continue;
pendingSections.push({
storageKey,
storedValue,
fields: pendingFields,
slug: item.slug,
});
}
if (pendingSections.length === 0) return;
const result = await updateMarriageSectionData(pendingSections[0].slug, {
version: overview.version,
current_step: pendingSections[0].storedValue.current_step,
fields: pendingSections.flatMap((section) => section.fields),
});
applyProfilePatchResultToCache(queryClient, locale, result);
const cleared = new Set(result.cleared_answer_ids ?? []);
for (const { storageKey, storedValue } of pendingSections) {
storedValue.fields = storedValue.fields.filter(
(field: { key: string }) => !cleared.has(field.key),
);
storedValue.pending_sync = false;
storedValue.pending_keys = [];
if (storedValue.fields.length === 0) {
window.localStorage.removeItem(storageKey);
} else {
window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
} }
storedValue.pending_sync = false;
window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
} }
}, [overview, questionListItems]);
}, [locale, overview, queryClient, questionListItems]);
const prefetchSection = useCallback( const prefetchSection = useCallback(
(item: QuestionListItem) => { (item: QuestionListItem) => {

49
src/components/Componentes/question-answer-storage.tsx

@ -11,7 +11,6 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { QuestionField } from "@/lib/schema-adapter";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { import type {
@ -22,10 +21,12 @@ import type {
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { import {
applyProfilePatchResultToCache,
useMarriageSectionDataQuery, useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation, useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter";
const STORAGE_VERSION = 2; const STORAGE_VERSION = 2;
@ -121,7 +122,7 @@ function createQuestionField(
question: QuestionField, question: QuestionField,
value: MarriageFieldValue, value: MarriageFieldValue,
): MarriageField { ): MarriageField {
let option_id = undefined;
let option_id: string | string[] | undefined;
if (question.options && Array.isArray(question.options)) { if (question.options && Array.isArray(question.options)) {
if (question.type === "checkbox" && Array.isArray(value)) { if (question.type === "checkbox" && Array.isArray(value)) {
@ -484,14 +485,12 @@ export function QuestionAnswersProvider({
questionsRef.current, questionsRef.current,
backendFieldsRef.current, backendFieldsRef.current,
); );
const nextDirtyKey = fullPayload.fields.find((field) =>
const pendingFields = fullPayload.fields.filter((field) =>
dirtyKeysRef.current.has(field.key), dirtyKeysRef.current.has(field.key),
)?.key;
);
const payload = { const payload = {
...fullPayload, ...fullPayload,
fields: nextDirtyKey
? fullPayload.fields.filter((field) => field.key === nextDirtyKey)
: [],
fields: pendingFields,
version: schemaVersionRef.current, version: schemaVersionRef.current,
}; };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
@ -504,6 +503,30 @@ export function QuestionAnswersProvider({
if (result?.version) { if (result?.version) {
schemaVersionRef.current = result.version; schemaVersionRef.current = result.version;
} }
const cleared = new Set(result?.cleared_answer_ids ?? []);
const nextAnswers = { ...answersRef.current };
const questionById = new Map(
questionsRef.current.map((question) => [question.id, question]),
);
Object.entries(result?.answers ?? {}).forEach(([key, answer]) => {
const question = questionById.get(key);
if (question) {
nextAnswers[key] = {
key,
label: question.title,
type: question.type,
value: answer.option_id ?? answer.value,
option_id: answer.option_id,
} as MarriageField;
}
});
cleared.forEach((key) => {
delete nextAnswers[key];
});
answersRef.current = nextAnswers;
setAnswers(nextAnswers);
applyProfilePatchResultToCache(queryClient, locale, result);
return undefined;
}); });
flushPromiseRef.current = request; flushPromiseRef.current = request;
@ -524,7 +547,9 @@ export function QuestionAnswersProvider({
return; return;
} }
if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey);
pendingFields.forEach((field) => {
dirtyKeysRef.current.delete(field.key);
});
if (dirtyKeysRef.current.size > 0) { if (dirtyKeysRef.current.size > 0) {
await flushAnswersRef.current(); await flushAnswersRef.current();
@ -543,7 +568,7 @@ export function QuestionAnswersProvider({
[], [],
); );
}, },
[mutateAsync, canEdit],
[mutateAsync, canEdit, locale, queryClient],
); );
const flushAnswersRef = useRef(flushAnswers); const flushAnswersRef = useRef(flushAnswers);
@ -572,7 +597,7 @@ export function QuestionAnswersProvider({
const pendingFields = fullPayload.fields.filter((field) => const pendingFields = fullPayload.fields.filter((field) =>
dirtyKeysRef.current.has(field.key), dirtyKeysRef.current.has(field.key),
); );
const payload = { ...fullPayload, fields: pendingFields.slice(0, 1) };
const payload = { ...fullPayload, fields: pendingFields };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
@ -614,7 +639,9 @@ export function QuestionAnswersProvider({
} }
hasPendingSyncRef.current = false; hasPendingSyncRef.current = false;
dirtyKeysRef.current.delete(payload.fields[0].key);
payload.fields.forEach((field) => {
dirtyKeysRef.current.delete(field.key);
});
const stillPending = dirtyKeysRef.current.size > 0; const stillPending = dirtyKeysRef.current.size > 0;
hasPendingSyncRef.current = stillPending; hasPendingSyncRef.current = stillPending;
setHasPendingSync(stillPending); setHasPendingSync(stillPending);

257
src/components/Componentes/question-answer.test.tsx

@ -1,11 +1,23 @@
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import {
useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import {
QuestionAnswersProvider,
useQuestionAnswers,
} from "./question-answer-storage";
vi.mock("@/hooks/marriage/use-form-schema", () => ({ vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(), useFormSchemaQuery: vi.fn(),
@ -14,6 +26,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(), useMarriageProfileQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-section-data", () => ({ vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(), useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(),
})); }));
@ -28,8 +41,22 @@ function TestComponent({ slug }: { slug: string }) {
data-testid="set-radio" data-testid="set-radio"
onClick={() => onClick={() =>
setAnswerValue( setAnswerValue(
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
"opt1"
{
id: "q1",
type: "radio",
title: "Q1",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", options: [], range: [0, 0] },
options: [
{ id: "opt1", value: "A", label: "Option A", order: 1 },
],
},
"opt1",
) )
} }
> >
@ -40,8 +67,23 @@ function TestComponent({ slug }: { slug: string }) {
data-testid="set-checkbox" data-testid="set-checkbox"
onClick={() => onClick={() =>
setAnswerValue( setAnswerValue(
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, baseRequired: 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"]
{
id: "q2",
type: "checkbox",
title: "Q2",
order: 2,
required: true,
baseRequired: 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"],
) )
} }
> >
@ -87,19 +129,35 @@ describe("Question Answer & Schema Integration", () => {
}); });
it("should send option_id instead of label/value for radio", async () => { it("should send option_id instead of label/value for radio", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider <QuestionAnswersProvider
slug="test_slug" slug="test_slug"
questions={[ questions={[
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
{
id: "q1",
type: "radio",
title: "Q1",
order: 1,
required: true,
baseRequired: 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" /> <TestComponent slug="test_slug" />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
fireEvent.click(screen.getByTestId("set-radio")); fireEvent.click(screen.getByTestId("set-radio"));
@ -114,19 +172,36 @@ describe("Question Answer & Schema Integration", () => {
}); });
it("should send array of option_ids for checkbox", async () => { it("should send array of option_ids for checkbox", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider <QuestionAnswersProvider
slug="test_slug" slug="test_slug"
questions={[ questions={[
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, baseRequired: 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 }] },
{
id: "q2",
type: "checkbox",
title: "Q2",
order: 2,
required: true,
baseRequired: 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" /> <TestComponent slug="test_slug" />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
fireEvent.click(screen.getByTestId("set-checkbox")); fireEvent.click(screen.getByTestId("set-checkbox"));
@ -139,6 +214,61 @@ describe("Question Answer & Schema Integration", () => {
}); });
}); });
it("batches all dirty answers into one mutation", 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,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", options: [], range: [0, 0] },
options: [
{ id: "opt1", value: "A", label: "Option A", order: 1 },
],
},
{
id: "q2",
type: "checkbox",
title: "Q2",
order: 2,
required: true,
baseRequired: 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-radio"));
fireEvent.click(screen.getByTestId("set-checkbox"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => expect(capturedPayload?.fields).toHaveLength(2));
});
it("should sort schema questions and options by order correctly", () => { it("should sort schema questions and options by order correctly", () => {
const mockSchema = { const mockSchema = {
sections: [ sections: [
@ -155,25 +285,52 @@ describe("Question Answer & Schema Integration", () => {
title: "Card", title: "Card",
order: 2, order: 2,
questions: [ 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: "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", id: "card2",
title: "Card 2", title: "Card 2",
order: 1, order: 1,
questions: [ questions: [
{ id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
{
id: "q3",
title: "Q3",
type: "text",
order: 1,
required: true,
is_visible: true,
ui_config: {},
options: [],
},
],
},
],
},
], ],
progress: { sections_progress: {} }
progress: { sections_progress: {} },
} as any; } as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
@ -193,14 +350,24 @@ describe("Question Answer & Schema Integration", () => {
data: { data: {
slug: "test_slug", slug: "test_slug",
data: [ 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"] },
{
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, isLoading: false,
}); });
let capturedValues: any = {};
const capturedValues: any = {};
function HydrationTestComponent() { function HydrationTestComponent() {
const { getAnswerValue } = useQuestionAnswers(); const { getAnswerValue } = useQuestionAnswers();
@ -209,14 +376,16 @@ describe("Question Answer & Schema Integration", () => {
return <div />; return <div />;
} }
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test_slug" questions={[]}> <QuestionAnswersProvider slug="test_slug" questions={[]}>
<HydrationTestComponent /> <HydrationTestComponent />
</QuestionAnswersProvider> </QuestionAnswersProvider>
</QueryClientProvider>
</QueryClientProvider>,
); );
// Give it a moment to reconcile useEffect in QuestionAnswersProvider // Give it a moment to reconcile useEffect in QuestionAnswersProvider
@ -242,13 +411,23 @@ describe("Question Answer & Schema Integration", () => {
title: "Card", title: "Card",
order: 1, order: 1,
questions: [ questions: [
{ id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
{
id: "q1",
title: "Q1",
type: "text",
order: 1,
required: false,
is_required: true,
is_visible: true,
ui_config: {},
options: [],
},
],
},
],
},
], ],
progress: { sections_progress: {} }
progress: { sections_progress: {} },
} as any; } as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");

1
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -36,6 +36,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(), useMarriageProfileQuery: vi.fn(),
})); }));
vi.mock("@/hooks/marriage/use-section-data", () => ({ vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(), useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(),
})); }));

185
src/components/Componentes/token-switcher.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md";
import { MdOutlineSwitchAccount, MdDragIndicator } from "react-icons/md";
import { IoClose, IoTrashOutline, IoKeyOutline } from "react-icons/io5"; import { IoClose, IoTrashOutline, IoKeyOutline } from "react-icons/io5";
import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
@ -36,9 +36,16 @@ export function TokenSwitcher({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>(""); const [currentToken, setCurrentToken] = useState<string>("");
const [customTokenInput, setCustomTokenInput] = useState<string>(""); const [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const containerRef = useRef<HTMLButtonElement>(null);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dragStart = useRef<{
x: number;
y: number;
startX: number;
startY: number;
hasMoved: boolean;
}>({ x: 0, y: 0, startX: 12, startY: 12, hasMoved: false });
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@ -256,42 +263,152 @@ export function TokenSwitcher({
}, },
]; ];
// Pointer drag handlers
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isFloating || isOpen) return;
e.currentTarget.setPointerCapture(e.pointerId);
setIsDragging(true);
const startX = position?.x ?? 12;
const startY = position?.y ?? 12;
dragStart.current = {
x: e.clientX,
y: e.clientY,
startX,
startY,
hasMoved: false,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || !isFloating) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
if (Math.hypot(dx, dy) > 4) {
dragStart.current.hasMoved = true;
}
const width = containerRef.current?.offsetWidth || 140;
const height = containerRef.current?.offsetHeight || 40;
const newX = Math.max(
10,
Math.min(window.innerWidth - width - 10, dragStart.current.startX + dx)
);
const newY = Math.max(
10,
Math.min(window.innerHeight - height - 10, dragStart.current.startY + dy)
);
setPosition({ x: newX, y: newY });
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || !isFloating) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch (err) {}
setIsDragging(false);
if (dragStart.current.hasMoved) {
if (position) {
try {
localStorage.setItem(STORAGE_POS_KEY, JSON.stringify(position));
} catch (e) {}
}
}
};
const handleBadgeClick = (e: React.MouseEvent) => {
if (dragStart.current.hasMoved) {
e.preventDefault();
e.stopPropagation();
return;
}
setIsOpen(true);
};
const currentRoleLabel = isMale
? "آقا 👨"
: isMale2
? "آقا ۲ 👨"
: isFemale
? "خانم 👩"
: isGuestToken
? (guestAccounts.find((g) => g.token === currentToken)?.title ?? "مهمان 👤")
: isNoToken
? "بدون توکن 👤"
: "سفارشی 🔑";
if (!mounted && isFloating) {
return null;
}
const badgeContent = (
<div
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={() => setIsDragging(false)}
onClick={handleBadgeClick}
style={
isFloating
? {
position: "fixed",
left: position ? `${position.x}px` : "12px",
top: position ? `${position.y}px` : "12px",
zIndex: 99999,
touchAction: "none",
userSelect: "none",
}
: undefined
}
className={[
isFloating
? "fixed flex items-center gap-1.5 px-3 py-2 rounded-[15px] backdrop-blur-md shadow-md border select-none transition-shadow cursor-pointer"
: "inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 shadow-md border backdrop-blur-md cursor-pointer",
variant === "transparent"
? "bg-black/60 text-white border-white/20 hover:bg-black/70"
: "bg-white/95 text-slate-800 border-slate-200/80 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-700/80",
isDragging
? "cursor-grabbing shadow-2xl ring-2 ring-rose-500/50 scale-[1.02]"
: "cursor-grab active:scale-95",
className,
]
.filter(Boolean)
.join(" ")}
title="تغییر توکن کاربر (تست) - برای جابجایی بکشید / برای انتخاب کلیک کنید"
>
{/* Drag handle */}
{isFloating && (
<div
className="flex items-center justify-center text-slate-400 dark:text-slate-500 shrink-0"
title="برای جابجایی درگ کنید"
>
<MdDragIndicator className="size-4" />
</div>
)}
{/* Role icon & selected account label */}
<MdOutlineSwitchAccount className="size-5 text-rose-500 shrink-0" />
<span className="text-xs font-bold whitespace-nowrap">{currentRoleLabel}</span>
</div>
);
return ( return (
<> <>
<button
ref={containerRef}
type="button"
onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن (آقا / خانم)"
className={[
"inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 text-xs font-bold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md",
variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50"
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100",
className,
]
.filter(Boolean)
.join(" ")}
>
<MdOutlineSwitchAccount className="size-5 text-rose-500" />
<span>
{isMale
? "آقا 👨"
: isMale2
? "آقا ۲ 👨"
: isFemale
? "خانم 👩"
: isNoToken
? "بدون توکن 👤"
: "سفارشی 🔑"}
</span>
</button>
{badgeContent}
{isOpen && ( {isOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/60 p-4 backdrop-blur-xs animate-in fade-in duration-200"
onClick={() => setIsOpen(false)}
>
<div <div
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col"
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-2xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Modal Header */} {/* Modal Header */}

28
src/hooks/marriage/types.ts

@ -157,6 +157,34 @@ export type UpdateMarriageSectionDataPayload = {
fields: MarriageField[]; fields: MarriageField[];
}; };
export type MarriageQuestionState = {
is_visible: boolean;
is_required: boolean;
};
export type ProfileAnswersPatchResult = {
version: number;
answers: Record<
string,
{ value: MarriageFieldValue; option_id?: string | string[] }
>;
cleared_answer_ids?: string[];
question_states?: Record<string, MarriageQuestionState>;
affected_sections: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
progress: {
current_step: number;
total_steps: number;
completion_percent: number;
sections_progress: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
};
};
export type StartMarriageMatchResponse = { export type StartMarriageMatchResponse = {
detail: string; detail: string;
}; };

99
src/hooks/marriage/use-section-data.test.ts

@ -0,0 +1,99 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import { marriageQueryKeys } from "./query-keys";
import type { ProfileAnswersPatchResult } from "./types";
import { applyProfilePatchResultToCache } from "./use-section-data";
describe("applyProfilePatchResultToCache", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
window.localStorage.clear();
});
it("applies cleared answers, question states, and cross-section progress", () => {
const sectionAKey = marriageQueryKeys.formSection(
"profile",
"section-a",
"en",
);
const sectionBKey = marriageQueryKeys.formSection(
"profile",
"section-b",
"en",
);
queryClient.setQueryData(sectionAKey, {
answers: { parent: { value: "yes" } },
section: { cards: [{ questions: [{ id: "parent", is_visible: true }] }] },
});
queryClient.setQueryData(sectionBKey, {
answers: { child: { value: "old" } },
section: {
cards: [
{
questions: [{ id: "child", is_visible: true, is_required: true }],
},
],
},
});
queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), {
sections: [
{ id: "section-a", progress: {} },
{ id: "section-b", progress: {} },
],
});
window.localStorage.setItem(
"marriage:sections:section-b:answers:v2",
JSON.stringify({
fields: [{ key: "child", value: "old" }],
pending_keys: ["child"],
pending_sync: true,
}),
);
const result: ProfileAnswersPatchResult = {
version: 38,
answers: { parent: { value: "no" } },
cleared_answer_ids: ["child"],
question_states: {
child: { is_visible: false, is_required: false },
},
affected_sections: {
"section-a": {
current_step: 1,
total_steps: 1,
completion_percent: 100,
},
"section-b": {
current_step: 0,
total_steps: 0,
completion_percent: 100,
},
},
progress: {
current_step: 1,
total_steps: 1,
completion_percent: 100,
sections_progress: {},
},
};
applyProfilePatchResultToCache(queryClient, "en", result);
const sectionB = queryClient.getQueryData<any>(sectionBKey);
expect(sectionB.answers.child).toBeUndefined();
expect(sectionB.section.cards[0].questions[0]).toMatchObject({
is_visible: false,
is_required: false,
});
expect(sectionB.section_progress.current_step).toBe(0);
expect(
window.localStorage.getItem("marriage:sections:section-b:answers:v2"),
).toBeNull();
const overview = queryClient.getQueryData<any>(
marriageQueryKeys.formOverview("profile", "en"),
);
expect(overview.sections[1].progress.total_steps).toBe(0);
});
});

167
src/hooks/marriage/use-section-data.ts

@ -1,40 +1,37 @@
"use client"; "use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
type QueryClient,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { http } from "@/lib/http"; import { http } from "@/lib/http";
import type { MutationOptions } from "./options"; import type { MutationOptions } from "./options";
import { marriageQueryKeys } from "./query-keys"; import { marriageQueryKeys } from "./query-keys";
import type { import type {
MarriageSectionData, MarriageSectionData,
ProfileAnswersPatchResult,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "./types"; } from "./types";
import {
type FormSchemaResponse,
useFormSectionQuery,
} from "./use-form-schema";
import { useFormSectionQuery } from "./use-form-schema";
export async function updateMarriageSectionData( export async function updateMarriageSectionData(
slug: string, slug: string,
payload: UpdateMarriageSectionDataPayload, payload: UpdateMarriageSectionDataPayload,
) {
): Promise<ProfileAnswersPatchResult & { sectionData: MarriageSectionData }> {
const answersPayload = payload.fields.map((f) => ({ const answersPayload = payload.fields.map((f) => ({
question_id: f.key, question_id: f.key,
value: f.value, value: f.value,
option_id: (f as any).option_id || undefined,
option_id: f.option_id || undefined,
})); }));
const { data } = await http.patch<{
version: number;
answers: FormSchemaResponse["answers"];
affected_sections: Record<
string,
{ current_step: number; total_steps: number; completion_percent: number }
>;
progress: FormSchemaResponse["progress"];
}>(`/api/marriage/forms/profile/answers/`, {
version: payload.version,
answers: answersPayload,
});
const { data } = await http.patch<ProfileAnswersPatchResult>(
`/api/marriage/forms/profile/answers/`,
{
version: payload.version,
answers: answersPayload,
},
);
const prog = data.affected_sections[slug] || const prog = data.affected_sections[slug] ||
data.progress.sections_progress[slug] || { data.progress.sections_progress[slug] || {
@ -47,6 +44,9 @@ export async function updateMarriageSectionData(
version: data.version, version: data.version,
progress: data.progress, progress: data.progress,
answers: data.answers, answers: data.answers,
cleared_answer_ids: data.cleared_answer_ids,
question_states: data.question_states,
affected_sections: data.affected_sections,
sectionData: { sectionData: {
slug, slug,
data: payload.fields, data: payload.fields,
@ -58,6 +58,105 @@ export async function updateMarriageSectionData(
}; };
} }
export function applyProfilePatchResultToCache(
queryClient: QueryClient,
locale: string,
data: ProfileAnswersPatchResult,
) {
const cleared = new Set(data.cleared_answer_ids ?? []);
if (typeof window !== "undefined" && cleared.size > 0) {
for (let index = 0; index < window.localStorage.length; index += 1) {
const storageKey = window.localStorage.key(index);
if (!storageKey?.startsWith("marriage:sections:")) continue;
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) continue;
try {
const stored = JSON.parse(rawValue);
if (!Array.isArray(stored.fields)) continue;
const fields = stored.fields.filter(
(field: { key?: string }) => !field.key || !cleared.has(field.key),
);
if (fields.length === stored.fields.length) continue;
if (fields.length === 0) {
window.localStorage.removeItem(storageKey);
index -= 1;
continue;
}
stored.fields = fields;
stored.pending_keys = Array.isArray(stored.pending_keys)
? stored.pending_keys.filter((key: string) => !cleared.has(key))
: [];
stored.pending_sync = stored.pending_keys.length > 0;
window.localStorage.setItem(storageKey, JSON.stringify(stored));
} catch {
// Leave malformed drafts untouched; normal hydration handles them.
}
}
}
const patchSection = (current: any, slug: string) => {
if (!current) return current;
const sectionProgress =
data.affected_sections[slug] ?? data.progress.sections_progress[slug];
const answers = { ...(current.answers ?? {}) };
Object.entries(data.answers ?? {}).forEach(([key, answer]) => {
answers[key] = answer;
});
cleared.forEach((key) => {
delete answers[key];
});
const questionStates = data.question_states ?? {};
const section = current.section
? {
...current.section,
cards: current.section.cards?.map((card: any) => ({
...card,
questions: card.questions?.map((question: any) =>
questionStates[question.id]
? { ...question, ...questionStates[question.id] }
: question,
),
})),
}
: current.section;
return {
...current,
version: data.version,
answers,
section,
progress: data.progress,
...(sectionProgress ? { section_progress: sectionProgress } : {}),
};
};
queryClient
.getQueriesData({
queryKey: marriageQueryKeys
.formSection("profile", "", locale)
.slice(0, 3),
})
.forEach(([queryKey, current]) => {
const slug = String(queryKey[3] ?? "");
queryClient.setQueryData(queryKey, patchSection(current, slug));
});
queryClient.setQueryData(
marriageQueryKeys.formOverview("profile", locale),
(current: any) =>
current
? {
...current,
version: data.version,
progress: data.progress,
sections: current.sections.map((section: any) => {
const progress =
data.affected_sections[section.id] ??
data.progress.sections_progress[section.id];
return progress ? { ...section, progress } : section;
}),
}
: current,
);
}
export function useMarriageSectionDataQuery( export function useMarriageSectionDataQuery(
slug: string | null | undefined, slug: string | null | undefined,
locale: string, locale: string,
@ -116,35 +215,7 @@ export function useUpdateMarriageSectionDataMutation(
...options, ...options,
mutationFn: (payload) => updateMarriageSectionData(slug, payload), mutationFn: (payload) => updateMarriageSectionData(slug, payload),
onSuccess: async (data, variables, onMutateResult, context) => { onSuccess: async (data, variables, onMutateResult, context) => {
queryClient.setQueryData(
marriageQueryKeys.formSection("profile", slug, locale),
(current: any) =>
current
? {
...current,
version: data.version,
progress: data.progress,
section_progress: data.sectionData,
answers: { ...current.answers, ...data.answers },
}
: current,
);
queryClient.setQueryData(
marriageQueryKeys.formOverview("profile", locale),
(current: any) =>
current
? {
...current,
version: data.version,
progress: data.progress,
sections: current.sections.map((section: any) =>
section.id === slug
? { ...section, progress: data.sectionData }
: section,
),
}
: current,
);
applyProfilePatchResultToCache(queryClient, locale, data);
await options?.onSuccess?.(data, variables, onMutateResult, context); await options?.onSuccess?.(data, variables, onMutateResult, context);
}, },
}); });

Loading…
Cancel
Save