Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion schema/schema.graphql
Original file line number Diff line number Diff line change
@@ -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 ----
Expand Down
31 changes: 27 additions & 4 deletions src/shared/lib/list-return.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
/** CursorPager가 지나온 커서. 목록으로 돌아와 다시 마운트돼도 '이전'과 구간 표시를 되살린다 */
export interface CursorTrail {
/** 커서를 뺀 목록 조건 */
filterKey: string;
/** 지나온 커서 순서. undefined는 첫 페이지 */
cursors: (string | undefined)[];
}
const lastTrail = new Map<string, CursorTrail>();

function remember<T>(map: Map<string, T>, 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<string, unknown>) {
remember(lastSearch, pathname, search);
}

export function rememberedSearch(pathname: string): Record<string, unknown> | 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();
}

/** 라우터 이동이 끝날 때마다 위치를 기록한다. 반환값은 구독 해제 함수. */
Expand Down
67 changes: 67 additions & 0 deletions src/shared/ui/cursor-pager.spec.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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: () => <Harness initial={initial} />,
}),
);
const router = createRouter({
routeTree: root.addChildren(routes),
history: createMemoryHistory({ initialEntries: [pathname] }),
});
return render(<RouterProvider router={router} />);
}

describe('CursorPager', () => {
it('다음으로 가며 지나온 커서를 쌓고, 이전으로 한 페이지씩 돌아온다', async () => {
render(<Harness />);
Expand Down Expand Up @@ -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(
<CursorPager
Expand Down
23 changes: 15 additions & 8 deletions src/shared/ui/cursor-pager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { type AnyRouter, useRouter } from '@tanstack/react-router';
import { ChevronLeftIcon, ChevronRightIcon, RotateCcwIcon } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';

import { formatCount } from '@/shared/lib/format';
import { type CursorTrail, rememberTrail, rememberedTrail } from '@/shared/lib/list-return';
import { DEFAULT_LIMIT } from '@/shared/lib/list-search';
import { Button } from '@/shared/ui/button';

Expand Down Expand Up @@ -31,21 +33,26 @@ function filterKeyOf(search: ListSearch): string {
);
}

interface Trail {
filterKey: string;
/** 이 화면에서 지나온 커서 순서. undefined는 첫 페이지 */
cursors: (string | undefined)[];
}

/**
* 키셋 커서 페이지네이션. 서버는 다음 커서만 주므로 지나온 커서를 여기서 쌓아 '이전'을 만든다.
* 브라우저 뒤로가기로 커서가 바뀌어도 쌓인 순서에서 위치를 찾는다. 목록 조건이 바뀌면 처음부터 다시 쌓는다.
* 쌓은 커서는 경로별로 기억해, 상세에서 목록으로 돌아와 다시 마운트돼도 이어 쓴다.
* 주소로 중간 페이지에 바로 들어오면 앞 페이지를 알 수 없어 '처음'만 둔다.
*/
export function CursorPager({ page, search, onCursorChange, isFetching = false }: Props) {
const cursor = search.cursor;
const filterKey = filterKeyOf(search);
const [trail, setTrail] = useState<Trail>({ filterKey, cursors: [cursor] });
// 라우터 밖(단독 렌더)이면 경로가 없어 기억하지 않는다. 경로는 목록 화면마다 고정이라 마운트 때 한 번 읽는다
const router: AnyRouter | null = useRouter({ warn: false });
const [pathname] = useState(() => router?.state.location.pathname);
// 기억한 것이 다른 조건이거나 지금 커서를 지나오지 않았으면 아래 보정이 새로 쌓는다
const [trail, setTrail] = useState<CursorTrail>(() => {
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)) {
Expand Down
Loading