Browse Source
feat: implement questions list overlay with pre-mounting and hook-based state management
master
feat: implement questions list overlay with pre-mounting and hook-based state management
master
9 changed files with 663 additions and 20 deletions
-
181src/app/finding-match/finding-match-client.test.tsx
-
53src/app/finding-match/finding-match-client.tsx
-
38src/app/questions-list/questions-list-client.tsx
-
4src/app/questions-list/sections-request.tsx
-
90src/components/Componentes/page-header.test.tsx
-
175src/components/Componentes/questions-list-overlay.test.tsx
-
91src/components/Componentes/questions-list-overlay.tsx
-
18src/components/Componentes/section-overlay-host.test.tsx
-
33src/components/Componentes/section-overlay-host.tsx
@ -0,0 +1,181 @@ |
|||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { I18nProvider } from "@/translations/provider"; |
|||
import FindingMatchClient from "./finding-match-client"; |
|||
|
|||
const mockReplace = vi.fn(); |
|||
const mockPush = vi.fn(); |
|||
const mockBack = vi.fn(); |
|||
|
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: () => ({ |
|||
push: mockPush, |
|||
replace: mockReplace, |
|||
back: mockBack, |
|||
prefetch: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
let mockProfileData: any = { |
|||
id: 1, |
|||
gender: "male", |
|||
status: "search_in_progress", |
|||
can_edit_profile: true, |
|||
unseen_rejection: null, |
|||
}; |
|||
|
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: () => ({ |
|||
data: mockProfileData, |
|||
isLoading: false, |
|||
isFetched: true, |
|||
refetch: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
|||
useFormOverviewQuery: () => ({ |
|||
data: { |
|||
sections: [ |
|||
{ |
|||
slug: "personal_identity", |
|||
title: "Personal Identity", |
|||
required: true, |
|||
estimated_time_min: 3, |
|||
is_completed: false, |
|||
progress: { |
|||
current_step: 0, |
|||
completion_percent: 0, |
|||
is_completed: false, |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
isLoading: false, |
|||
isFetched: true, |
|||
isError: false, |
|||
refetch: vi.fn(), |
|||
}), |
|||
getFormSection: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-cattell", () => ({ |
|||
useCattellResultQuery: () => ({ data: null }), |
|||
getCattellQuestions: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-glasser", () => ({ |
|||
useGlasserResultQuery: () => ({ data: null }), |
|||
getGlasserQuestions: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-match-start", () => ({ |
|||
useStartMarriageMatchMutation: () => ({ |
|||
mutate: vi.fn(), |
|||
isPending: false, |
|||
isError: false, |
|||
reset: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-rejection-seen", () => ({ |
|||
useRejectionSeenMutation: () => ({ |
|||
mutate: vi.fn(), |
|||
isPending: false, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-marriage-advisors", () => ({ |
|||
useMarriageAdvisorsQuery: () => ({ |
|||
data: { results: [] }, |
|||
isLoading: false, |
|||
isError: false, |
|||
refetch: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
function createWrapper() { |
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { retry: false }, |
|||
}, |
|||
}); |
|||
|
|||
return function Wrapper({ children }: { children: React.ReactNode }) { |
|||
return ( |
|||
<QueryClientProvider client={queryClient}> |
|||
<I18nProvider locale="en">{children}</I18nProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
}; |
|||
} |
|||
|
|||
describe("FindingMatchClient", () => { |
|||
beforeEach(() => { |
|||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); |
|||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { |
|||
return setTimeout(() => cb(Date.now()), 0); |
|||
}); |
|||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { |
|||
clearTimeout(id); |
|||
}); |
|||
document.body.className = ""; |
|||
mockProfileData = { |
|||
id: 1, |
|||
gender: "male", |
|||
status: "search_in_progress", |
|||
can_edit_profile: true, |
|||
unseen_rejection: null, |
|||
}; |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
vi.restoreAllMocks(); |
|||
vi.useRealTimers(); |
|||
document.body.className = ""; |
|||
}); |
|||
|
|||
it("renders search in progress and Edit Profile button", () => { |
|||
const wrapper = createWrapper(); |
|||
render(<FindingMatchClient />, { wrapper }); |
|||
|
|||
expect(screen.getByText("SEARCH IN PROGRESS")).toBeInTheDocument(); |
|||
expect(screen.getByText("Edit Profile")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("opens questions list overlay when Edit Profile button is clicked", () => { |
|||
const wrapper = createWrapper(); |
|||
const { container } = render(<FindingMatchClient />, { wrapper }); |
|||
|
|||
// Initial render - overlay is pre-mounted offscreen
|
|||
const editBtn = screen.getByRole("button", { name: /edit profile/i }); |
|||
expect(editBtn).toBeInTheDocument(); |
|||
|
|||
fireEvent.click(editBtn); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
const overlay = container.querySelector('aside[data-slot="section-overlay"][data-state="open"]'); |
|||
expect(overlay).toBeInTheDocument(); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
|||
}); |
|||
|
|||
it("renders locked state when can_edit_profile is false", () => { |
|||
mockProfileData = { |
|||
id: 1, |
|||
gender: "male", |
|||
status: "search_in_progress", |
|||
can_edit_profile: false, |
|||
}; |
|||
|
|||
const wrapper = createWrapper(); |
|||
render(<FindingMatchClient />, { wrapper }); |
|||
|
|||
expect(screen.getByText("Profile is locked")).toBeInTheDocument(); |
|||
expect(screen.queryByRole("button", { name: /edit profile/i })).not.toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,90 @@ |
|||
import { afterEach, describe, expect, it, vi } from "vitest"; |
|||
import { cleanup, render, screen } from "@testing-library/react"; |
|||
import { PageHeader } from "./page-header"; |
|||
import { NavigationButton } from "./navigation-button"; |
|||
import { I18nProvider } from "@/translations/provider"; |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: () => ({ |
|||
push: vi.fn(), |
|||
replace: vi.fn(), |
|||
back: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: () => ({ |
|||
data: undefined, |
|||
isLoading: false, |
|||
isFetched: true, |
|||
}), |
|||
})); |
|||
|
|||
function renderWithI18n(ui: React.ReactNode) { |
|||
return render( |
|||
<I18nProvider locale="fa" dictionary={{ "Habib Marriage": "ازدواج حبیب", "Subscription Status": "وضعیت اشتراک", "Subscription": "اشتراک", "More": "بیشتر" }}> |
|||
{ui} |
|||
</I18nProvider> |
|||
); |
|||
} |
|||
|
|||
describe("PageHeader & NavigationButton Subscription Gender Restriction", () => { |
|||
it("renders subscription button for male profile", () => { |
|||
const maleProfile = { |
|||
id: 101, |
|||
gender: "male", |
|||
status: "waiting", |
|||
}; |
|||
|
|||
const { queryByRole } = renderWithI18n(<PageHeader profile={maleProfile} />); |
|||
|
|||
const subButton = queryByRole("button", { |
|||
name: /وضعیت اشتراک|اشتراک|subscription/i, |
|||
}); |
|||
expect(subButton).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("does NOT render subscription button for female profile", () => { |
|||
const femaleProfile = { |
|||
id: 243, |
|||
gender: "female", |
|||
status: "waiting", |
|||
}; |
|||
|
|||
const { queryByRole } = renderWithI18n(<PageHeader profile={femaleProfile} />); |
|||
|
|||
const subButton = queryByRole("button", { |
|||
name: /وضعیت اشتراک|اشتراک|subscription/i, |
|||
}); |
|||
expect(subButton).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("NavigationButton icon='subscription' returns null for female user", () => { |
|||
const femaleProfile = { |
|||
id: 243, |
|||
gender: "female", |
|||
}; |
|||
|
|||
const { container } = renderWithI18n( |
|||
<NavigationButton icon="subscription" profile={femaleProfile} />, |
|||
); |
|||
expect(container).toBeEmptyDOMElement(); |
|||
}); |
|||
|
|||
it("NavigationButton icon='subscription' renders button for male user", () => { |
|||
const maleProfile = { |
|||
id: 101, |
|||
gender: "male", |
|||
}; |
|||
|
|||
const { getByRole } = renderWithI18n( |
|||
<NavigationButton icon="subscription" profile={maleProfile} />, |
|||
); |
|||
const subButton = getByRole("button"); |
|||
expect(subButton).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,175 @@ |
|||
import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
|||
import { I18nProvider } from "@/translations/provider"; |
|||
import { |
|||
QuestionsListOverlay, |
|||
useQuestionsListOverlay, |
|||
} from "./questions-list-overlay"; |
|||
|
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: () => ({ |
|||
push: vi.fn(), |
|||
replace: vi.fn(), |
|||
back: vi.fn(), |
|||
prefetch: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: () => ({ |
|||
data: { |
|||
id: 1, |
|||
gender: "male", |
|||
status: "search_in_progress", |
|||
can_edit_profile: true, |
|||
}, |
|||
isLoading: false, |
|||
isFetched: true, |
|||
refetch: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-form-schema", () => ({ |
|||
useFormOverviewQuery: () => ({ |
|||
data: { |
|||
sections: [ |
|||
{ |
|||
slug: "personal_identity", |
|||
title: "Personal Identity", |
|||
required: true, |
|||
estimated_time_min: 3, |
|||
is_completed: false, |
|||
progress: { |
|||
current_step: 0, |
|||
completion_percent: 0, |
|||
is_completed: false, |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
isLoading: false, |
|||
isFetched: true, |
|||
isError: false, |
|||
refetch: vi.fn(), |
|||
}), |
|||
getFormSection: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-cattell", () => ({ |
|||
useCattellResultQuery: () => ({ data: null }), |
|||
getCattellQuestions: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-glasser", () => ({ |
|||
useGlasserResultQuery: () => ({ data: null }), |
|||
getGlasserQuestions: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-match-start", () => ({ |
|||
useStartMarriageMatchMutation: () => ({ |
|||
mutate: vi.fn(), |
|||
isPending: false, |
|||
isError: false, |
|||
reset: vi.fn(), |
|||
}), |
|||
})); |
|||
|
|||
function createWrapper() { |
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { retry: false }, |
|||
}, |
|||
}); |
|||
|
|||
return function Wrapper({ children }: { children: React.ReactNode }) { |
|||
return ( |
|||
<QueryClientProvider client={queryClient}> |
|||
<I18nProvider locale="fa">{children}</I18nProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
}; |
|||
} |
|||
|
|||
describe("QuestionsListOverlay & useQuestionsListOverlay", () => { |
|||
beforeEach(() => { |
|||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); |
|||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { |
|||
return setTimeout(() => cb(Date.now()), 0); |
|||
}); |
|||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { |
|||
clearTimeout(id); |
|||
}); |
|||
document.body.className = ""; |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
vi.restoreAllMocks(); |
|||
vi.useRealTimers(); |
|||
document.body.className = ""; |
|||
}); |
|||
|
|||
it("hook manages open and close state with history sync", () => { |
|||
const { result } = renderHook(() => useQuestionsListOverlay()); |
|||
|
|||
expect(result.current.isQuestionsOpen).toBe(false); |
|||
|
|||
act(() => { |
|||
result.current.openQuestions(); |
|||
}); |
|||
|
|||
expect(result.current.isQuestionsOpen).toBe(true); |
|||
|
|||
act(() => { |
|||
result.current.closeQuestions(); |
|||
}); |
|||
|
|||
expect(result.current.isQuestionsOpen).toBe(false); |
|||
}); |
|||
|
|||
it("pre-mounts in background when open is false and premount is true", () => { |
|||
const handleClose = vi.fn(); |
|||
const handleReady = vi.fn(); |
|||
const wrapper = createWrapper(); |
|||
|
|||
const { container } = render( |
|||
<QuestionsListOverlay |
|||
open={false} |
|||
onClose={handleClose} |
|||
onReady={handleReady} |
|||
premount={true} |
|||
/>, |
|||
{ wrapper }, |
|||
); |
|||
|
|||
const overlay = container.querySelector('aside[data-slot="section-overlay"]'); |
|||
expect(overlay).toBeInTheDocument(); |
|||
expect(overlay).toHaveAttribute("data-state", "closed"); |
|||
expect(overlay).toHaveAttribute("aria-hidden", "true"); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(false); |
|||
expect(handleReady).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("slides in smoothly when open is true", () => { |
|||
const handleClose = vi.fn(); |
|||
const wrapper = createWrapper(); |
|||
|
|||
render( |
|||
<QuestionsListOverlay |
|||
open={true} |
|||
onClose={handleClose} |
|||
/>, |
|||
{ wrapper }, |
|||
); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
const overlay = screen.getByRole("dialog"); |
|||
expect(overlay).toBeInTheDocument(); |
|||
expect(overlay).toHaveAttribute("data-state", "open"); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,91 @@ |
|||
"use client"; |
|||
|
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import QuestionsListClient from "@/app/questions-list/questions-list-client"; |
|||
import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; |
|||
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; |
|||
|
|||
export type QuestionsListOverlayProps = { |
|||
open: boolean; |
|||
onClose: () => void; |
|||
onReady?: () => void; |
|||
premount?: boolean; |
|||
}; |
|||
|
|||
/** |
|||
* Hook to manage Questions List ("Edit Profile") slide-in overlay state. |
|||
* Syncs with browser history (?edit_profile=open) and intercepts hardware back in Flutter. |
|||
*/ |
|||
export function useQuestionsListOverlay() { |
|||
const [isQuestionsOpen, setIsQuestionsOpen] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
const readFromUrl = () => { |
|||
if (typeof window === "undefined") return; |
|||
const params = new URLSearchParams(window.location.search); |
|||
setIsQuestionsOpen( |
|||
params.get("edit_profile") === "open" || |
|||
params.get("questions") === "open", |
|||
); |
|||
}; |
|||
|
|||
readFromUrl(); |
|||
window.addEventListener("popstate", readFromUrl); |
|||
return () => window.removeEventListener("popstate", readFromUrl); |
|||
}, []); |
|||
|
|||
const openQuestions = useCallback(() => { |
|||
setIsQuestionsOpen(true); |
|||
if (typeof window !== "undefined") { |
|||
const url = new URL(window.location.href); |
|||
url.searchParams.set("edit_profile", "open"); |
|||
window.history.replaceState({ edit_profile: "open" }, "", url.toString()); |
|||
} |
|||
}, []); |
|||
|
|||
const closeQuestions = useCallback(() => { |
|||
setIsQuestionsOpen(false); |
|||
if (typeof window !== "undefined") { |
|||
const url = new URL(window.location.href); |
|||
if ( |
|||
url.searchParams.get("edit_profile") === "open" || |
|||
url.searchParams.get("questions") === "open" |
|||
) { |
|||
url.searchParams.delete("edit_profile"); |
|||
url.searchParams.delete("questions"); |
|||
window.history.replaceState({}, "", url.toString()); |
|||
} |
|||
} |
|||
}, []); |
|||
|
|||
// Intercept hardware back in Flutter WebView when questions overlay is open
|
|||
useHardwareBackHandler(() => { |
|||
closeQuestions(); |
|||
return true; // handled: keep WebView screen open
|
|||
}, isQuestionsOpen); |
|||
|
|||
return { |
|||
isQuestionsOpen, |
|||
openQuestions, |
|||
closeQuestions, |
|||
}; |
|||
} |
|||
|
|||
export function QuestionsListOverlay({ |
|||
open, |
|||
onClose, |
|||
onReady, |
|||
premount = true, |
|||
}: QuestionsListOverlayProps) { |
|||
return ( |
|||
<SectionOverlayHost open={open} onClose={onClose} premount={premount}> |
|||
<QuestionsListClient |
|||
onClose={onClose} |
|||
isOverlay={true} |
|||
onReady={onReady} |
|||
/> |
|||
</SectionOverlayHost> |
|||
); |
|||
} |
|||
|
|||
export default QuestionsListOverlay; |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue