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
-
31src/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