diff --git a/schema/schema.graphql b/schema/schema.graphql index 8fb0baa..e2e6c8a 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@77976b55ccf2d2dd32305b66ebf001a5375098cc 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@7bece88a93b583c4c2d27ba42438cea7f01c9a0d 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- diff --git a/src/shared/lib/list-return.ts b/src/shared/lib/list-return.ts index c8d03f4..534cf53 100644 --- a/src/shared/lib/list-return.ts +++ b/src/shared/lib/list-return.ts @@ -7,20 +7,43 @@ const MAX_PATHS = 50; const normalize = (pathname: string) => pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname; -export function rememberSearch(pathname: string, search: Record) { +/** CursorPager가 지나온 커서. 목록으로 돌아와 다시 마운트돼도 '이전'과 구간 표시를 되살린다 */ +export interface CursorTrail { + /** 커서를 뺀 목록 조건 */ + filterKey: string; + /** 지나온 커서 순서. undefined는 첫 페이지 */ + cursors: (string | undefined)[]; +} +const lastTrail = new Map(); + +function remember(map: Map, pathname: string, value: T) { const key = normalize(pathname); - lastSearch.delete(key); - lastSearch.set(key, search); + map.delete(key); + map.set(key, value); // 상세 경로는 ID마다 쌓이므로 오래된 것부터 버린다 - if (lastSearch.size > MAX_PATHS) lastSearch.delete(lastSearch.keys().next().value!); + if (map.size > MAX_PATHS) map.delete(map.keys().next().value!); +} + +export function rememberSearch(pathname: string, search: Record) { + remember(lastSearch, pathname, search); } export function rememberedSearch(pathname: string): Record | undefined { return lastSearch.get(normalize(pathname)); } +export function rememberTrail(pathname: string, trail: CursorTrail) { + remember(lastTrail, pathname, trail); +} + +export function rememberedTrail(pathname: string): CursorTrail | undefined { + return lastTrail.get(normalize(pathname)); +} + +/** 세션을 잃으면 기억한 검색 파라미터·커서를 모두 비운다 */ export function forgetSearches() { lastSearch.clear(); + lastTrail.clear(); } /** 라우터 이동이 끝날 때마다 위치를 기록한다. 반환값은 구독 해제 함수. */ diff --git a/src/shared/ui/cursor-pager.spec.tsx b/src/shared/ui/cursor-pager.spec.tsx index dcec04c..2052725 100644 --- a/src/shared/ui/cursor-pager.spec.tsx +++ b/src/shared/ui/cursor-pager.spec.tsx @@ -1,7 +1,17 @@ +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useState } from 'react'; +import { forgetSearches } from '@/shared/lib/list-return'; + import { CursorPager } from './cursor-pager'; const TOTAL = 45; @@ -44,6 +54,23 @@ function Harness({ initial = {} }: { initial?: Search }) { const button = (name: string) => screen.queryByRole('button', { name }); +/** 목록 화면처럼 라우터 아래 경로에 붙여 렌더한다. 검색 파라미터는 Harness가 들고 있다 */ +function renderAt(pathname: string, initial: Search = {}) { + const root = createRootRoute({ component: Outlet }); + const routes = ['/stores', '/orders'].map((path) => + createRoute({ + getParentRoute: () => root, + path, + component: () => , + }), + ); + const router = createRouter({ + routeTree: root.addChildren(routes), + history: createMemoryHistory({ initialEntries: [pathname] }), + }); + return render(); +} + describe('CursorPager', () => { it('다음으로 가며 지나온 커서를 쌓고, 이전으로 한 페이지씩 돌아온다', async () => { render(); @@ -130,6 +157,46 @@ describe('CursorPager', () => { expect(screen.getByText('21–30 / 전체 45건')).toBeInTheDocument(); }); + describe('목록으로 돌아와 다시 마운트될 때', () => { + afterEach(() => forgetSearches()); + + /** 첫 페이지부터 두 번 다음으로 가 41–45 구간에서 화면을 떠난다 */ + async function leaveAtThirdPage() { + const { unmount } = renderAt('/stores'); + await userEvent.click(await screen.findByRole('button', { name: '다음' })); + await userEvent.click(button('다음')!); + expect(screen.getByText('41–45 / 전체 45건')).toBeInTheDocument(); + unmount(); + } + + it('같은 경로·목록 조건이고 그 커서를 지나왔으면 이전과 구간을 되살린다', async () => { + await leaveAtThirdPage(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('41–45 / 전체 45건')).toBeInTheDocument(); + await userEvent.click(button('이전')!); + expect(screen.getByText('21–40 / 전체 45건')).toBeInTheDocument(); + }); + + it.each<[string, string, Search]>([ + ['경로가 다르면', '/orders', { cursor: 'c40' }], + ['목록 조건이 다르면', '/stores', { cursor: 'c40', q: '케이크' }], + ['지나오지 않은 커서면', '/stores', { cursor: 'c30' }], + ])('%s 되살리지 않는다', async (_, pathname, initial) => { + await leaveAtThirdPage(); + renderAt(pathname, initial); + expect(await screen.findByRole('button', { name: '처음' })).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + + it('세션을 비우면(forgetSearches) 되살리지 않는다', async () => { + await leaveAtThirdPage(); + forgetSearches(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('전체 45건 중 5건 표시')).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + }); + it('결과가 없으면 0건으로 보인다', () => { render( ({ filterKey, cursors: [cursor] }); + // 라우터 밖(단독 렌더)이면 경로가 없어 기억하지 않는다. 경로는 목록 화면마다 고정이라 마운트 때 한 번 읽는다 + const router: AnyRouter | null = useRouter({ warn: false }); + const [pathname] = useState(() => router?.state.location.pathname); + // 기억한 것이 다른 조건이거나 지금 커서를 지나오지 않았으면 아래 보정이 새로 쌓는다 + const [trail, setTrail] = useState(() => { + const saved = pathname === undefined ? undefined : rememberedTrail(pathname); + return saved ?? { filterKey, cursors: [cursor] }; + }); + useEffect(() => { + if (pathname !== undefined) rememberTrail(pathname, trail); + }, [pathname, trail]); let current = trail; if (trail.filterKey !== filterKey || !trail.cursors.includes(cursor)) {