fix(admin_basic,basic): 상한 총 건수 목록의 페이지 번호 목록 복원

총 건수를 상한까지만 세는 목록(last_page: null)에서 Pagination 이 번호 목록을
통째로 비우고 현재 페이지 숫자 하나만 남겼다. 합의되고 CHANGELOG 에 약속된
동작은 "마지막 페이지 점프만 감춤" 이었으므로, 1..현재 페이지와 (다음이 있으면)
현재+1 까지는 그대로 그리고 그 너머만 계속됨 표시로 둔다. 활동 로그처럼
10,000건을 넘는 화면에서 앞 페이지로 직접 이동할 방법이 없던 것을 되돌린다.

두 템플릿 컴포넌트 회귀 테스트(작성 시 6/7 fail 확인)와 활동 로그 E2E 를 추가하고,
pagination.md 의 화면 규약 서술을 실제 동작으로 정정했다.
This commit is contained in:
HeuJung
2026-09-04 18:53:31 +09:00
parent 9d3b15e300
commit 3e41dc6075
14 changed files with 356 additions and 43 deletions
@@ -122,7 +122,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 206개 | `vitest.config.ts` |
| Vitest | 207개 | `vitest.config.ts` |
| Playwright | 9개 | `tests/Playwright` |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
@@ -26,6 +26,7 @@
### Fixed
- 기록이 아주 많아 총 건수를 정확히 세지 못한 목록(활동 로그 등)에서 페이지 번호가 전부 사라지고 현재 페이지 숫자 하나만 남던 문제를 수정했습니다. 이제 1 부터 현재 페이지까지의 번호와 다음 페이지 번호가 그대로 표시되어 앞 페이지로 바로 이동할 수 있으며, 감춰지는 것은 마지막 페이지로 뛰는 버튼뿐입니다.
- 설정 파일의 자산 항목이 실제 파일 위치와 달랐던 것을 정정했습니다. 존재하지 않는 글꼴·이미지 폴더 항목은 제거했습니다.
- 환경설정 > 일반의 「자산 URL 방식 자동 감지」가 감지 결과를 선택 항목과 저장 내용에 반영합니다. 종전에는 버튼을 눌러도 선택이 그대로였고 진행·결과 안내도 표시되지 않았습니다.
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@ export interface PaginationProps {
* 마지막 페이지 번호
*
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
*/
totalPages: number | null;
onPageChange: (page: number) => void;
@@ -14,8 +14,8 @@ export interface PaginationProps {
* 마지막 페이지 번호
*
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
*/
totalPages: number | null;
onPageChange: (page: number) => void;
@@ -91,8 +91,8 @@ export const Pagination: React.FC<PaginationProps> = ({
editorAttrs,
}) => {
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
// 끝까지 넘겨 보는 것은 그대로 가능하다.
// 계산 불가한 값은 마지막 페이지 번호 하나뿐이므로 그 점프만 감춘다. 1 부터 현재
// 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는 서버가 `hasMorePages` 로 알린다.
const isBounded = totalPages !== null && totalPages !== undefined;
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
@@ -112,7 +112,21 @@ export const Pagination: React.FC<PaginationProps> = ({
const pages: (number | string)[] = [];
if (!isBounded) {
// 마지막 페이지를 모르면 번호 목록을 만들 수 없다 (현재 페이지만 별도로 표시)
// 마지막 페이지를 모르면 아는 범위(1..현재, 다음이 있으면 현재+1)만 그린다.
// 현재+1 너머는 존재 여부를 모르므로 번호 대신 계속됨 표시만 둔다.
const halfVisible = Math.floor(maxVisiblePages / 2);
const startPage = currentPage <= maxVisiblePages ? 1 : currentPage - halfVisible;
if (startPage > 1) {
pages.push(1);
if (startPage > 2) pages.push('...');
}
for (let i = startPage; i <= currentPage; i++) {
pages.push(i);
}
if (hasMorePages) {
pages.push(currentPage + 1);
pages.push('...');
}
return pages;
}
@@ -160,7 +174,7 @@ export const Pagination: React.FC<PaginationProps> = ({
}
return pages;
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages, hasMorePages]);
const handlePageClick = (page: number) => {
if (page < 1 || page === currentPage) return;
@@ -210,13 +224,8 @@ export const Pagination: React.FC<PaginationProps> = ({
{prevText || <>&#8249;</>}
</Button>
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
{/* Page Numbers — 마지막 페이지를 모르면 아는 범위까지만 표시 */}
<Div className="pagination">
{!isBounded && (
<Span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
{currentPage}
</Span>
)}
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '../Pagination';
/**
* 마지막 페이지를 모르는 목록의 페이지 번호 목록 회귀
*
* 총 건수가 상한을 넘겨 `last_page` 가 `null` 이면 계산할 수 없는 값은 마지막 페이지
* 번호 하나뿐이다. 1 부터 현재 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는
* 서버가 `has_more_pages` 로 알린다. 그런데 종전 구현은 번호 목록을 통째로 비우고
* 현재 페이지 숫자 하나만 남겨, 운영자가 앞 페이지로 직접 뛰어갈 방법을 잃었다
* (활동 로그 화면 실측: "총 10000건 이상" 인데 페이저에 "1" 하나).
*
* 합의된 동작(CHANGELOG 7.0.10): 마지막 페이지로 바로 뛰는 버튼만 감춘다.
*/
describe('Pagination — last_page 를 모를 때의 페이지 번호 목록', () => {
// 접근성 이름은 「N페이지」 라벨이므로 번호 버튼은 표시 텍스트로 찾는다
const pageButton = (n: number) =>
screen.queryAllByRole('button').find((b) => b.textContent?.trim() === String(n)) ?? null;
it('1 부터 현재 페이지까지의 번호가 그대로 그려진다', () => {
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
for (let n = 1; n <= 5; n++) {
expect(pageButton(n)).toBeInTheDocument();
}
expect(pageButton(5)).toHaveAttribute('aria-current', 'page');
});
it('다음 페이지가 있으면 그 번호 하나와 계속됨 표시가 붙고, 그 너머는 그리지 않는다', () => {
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
expect(pageButton(6)).toBeInTheDocument();
expect(pageButton(7)).not.toBeInTheDocument();
expect(screen.getByText('...')).toBeInTheDocument();
});
it('다음 페이지가 없으면 현재 페이지가 끝이다 — 계속됨 표시도 없다', () => {
render(
<Pagination currentPage={3} totalPages={null} hasMorePages={false} onPageChange={vi.fn()} />
);
expect(pageButton(3)).toBeInTheDocument();
expect(pageButton(4)).not.toBeInTheDocument();
expect(screen.queryByText('...')).not.toBeInTheDocument();
});
it('첫 페이지에서도 번호가 버튼으로 그려진다', () => {
render(
<Pagination currentPage={1} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
expect(pageButton(1)).toBeInTheDocument();
expect(pageButton(2)).toBeInTheDocument();
});
it('깊은 페이지에서는 앞쪽을 접고 현재 페이지 주변만 남긴다', () => {
render(
<Pagination
currentPage={12}
totalPages={null}
hasMorePages={true}
maxVisiblePages={5}
onPageChange={vi.fn()}
/>
);
expect(pageButton(1)).toBeInTheDocument();
expect(pageButton(2)).not.toBeInTheDocument();
expect(pageButton(10)).toBeInTheDocument();
expect(pageButton(11)).toBeInTheDocument();
expect(pageButton(12)).toBeInTheDocument();
expect(pageButton(13)).toBeInTheDocument();
expect(pageButton(14)).not.toBeInTheDocument();
expect(screen.getAllByText('...')).toHaveLength(2);
});
it('앞 페이지 번호와 다음 페이지 번호를 클릭하면 그 페이지로 이동한다', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={onPageChange} />
);
fireEvent.click(pageButton(3)!);
expect(onPageChange).toHaveBeenCalledWith(3);
fireEvent.click(pageButton(6)!);
expect(onPageChange).toHaveBeenCalledWith(6);
});
it('마지막 페이지 점프 버튼만 감춰진다', () => {
render(
<Pagination
currentPage={5}
totalPages={null}
hasMorePages={true}
showFirstLast={true}
onPageChange={vi.fn()}
/>
);
expect(screen.queryByLabelText('마지막 페이지')).not.toBeInTheDocument();
expect(screen.getByLabelText('이전 페이지')).toBeInTheDocument();
expect(screen.getByLabelText('다음 페이지')).toBeInTheDocument();
});
});
+1 -1
View File
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 142개 | `vitest.config.ts` |
| Vitest | 143개 | `vitest.config.ts` |
| Playwright | 8개 | `tests/Playwright` |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
@@ -17,6 +17,7 @@
### Fixed
- 총 건수를 정확히 세지 못한 목록(대량 검색 결과, 게시글이 아주 많은 게시판 등)에서 페이지 번호가 전부 사라지고 현재 페이지 숫자 하나만 남던 문제를 수정했습니다. 이제 1 부터 현재 페이지까지의 번호와 다음 페이지 번호가 그대로 표시되며, 감춰지는 것은 마지막 페이지로 뛰는 버튼뿐입니다.
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@ export interface PaginationProps {
* 마지막 페이지 번호
*
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
*/
totalPages: number | null;
onPageChange: (page: number) => void;
@@ -14,8 +14,8 @@ export interface PaginationProps {
* 마지막 페이지 번호
*
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
*/
totalPages: number | null;
onPageChange: (page: number) => void;
@@ -93,8 +93,8 @@ export const Pagination: React.FC<PaginationProps> = ({
editorAttrs,
}) => {
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
// 끝까지 넘겨 보는 것은 그대로 가능하다.
// 계산 불가한 값은 마지막 페이지 번호 하나뿐이므로 그 점프만 감춘다. 1 부터 현재
// 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는 서버가 `hasMorePages` 로 알린다.
const isBounded = totalPages !== null && totalPages !== undefined;
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
@@ -114,7 +114,21 @@ export const Pagination: React.FC<PaginationProps> = ({
const pages: (number | string)[] = [];
if (!isBounded) {
// 마지막 페이지를 모르면 번호 목록을 만들 수 없다 (현재 페이지만 별도로 표시)
// 마지막 페이지를 모르면 아는 범위(1..현재, 다음이 있으면 현재+1)만 그린다.
// 현재+1 너머는 존재 여부를 모르므로 번호 대신 계속됨 표시만 둔다.
const halfVisible = Math.floor(maxVisiblePages / 2);
const startPage = currentPage <= maxVisiblePages ? 1 : currentPage - halfVisible;
if (startPage > 1) {
pages.push(1);
if (startPage > 2) pages.push('...');
}
for (let i = startPage; i <= currentPage; i++) {
pages.push(i);
}
if (hasMorePages) {
pages.push(currentPage + 1);
pages.push('...');
}
return pages;
}
@@ -162,7 +176,7 @@ export const Pagination: React.FC<PaginationProps> = ({
}
return pages;
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages, hasMorePages]);
const handlePageClick = (page: number) => {
if (page < 1 || page === currentPage) return;
@@ -212,13 +226,8 @@ export const Pagination: React.FC<PaginationProps> = ({
{prevText || <>&#8249;</>}
</Button>
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
{/* Page Numbers — 마지막 페이지를 모르면 아는 범위까지만 표시 */}
<Div className="flex items-center gap-1">
{!isBounded && (
<Span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
{currentPage}
</Span>
)}
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '../Pagination';
/**
* 마지막 페이지를 모르는 목록의 페이지 번호 목록 회귀
*
* 총 건수가 상한을 넘겨 `last_page` 가 `null` 이면 계산할 수 없는 값은 마지막 페이지
* 번호 하나뿐이다. 1 부터 현재 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는
* 서버가 `has_more_pages` 로 알린다. 그런데 종전 구현은 번호 목록을 통째로 비우고
* 현재 페이지 숫자 하나만 남겨, 운영자가 앞 페이지로 직접 뛰어갈 방법을 잃었다
* (활동 로그 화면 실측: "총 10000건 이상" 인데 페이저에 "1" 하나).
*
* 합의된 동작(CHANGELOG 7.0.10): 마지막 페이지로 바로 뛰는 버튼만 감춘다.
*/
describe('Pagination — last_page 를 모를 때의 페이지 번호 목록', () => {
// 접근성 이름은 「N페이지」 라벨이므로 번호 버튼은 표시 텍스트로 찾는다
const pageButton = (n: number) =>
screen.queryAllByRole('button').find((b) => b.textContent?.trim() === String(n)) ?? null;
it('1 부터 현재 페이지까지의 번호가 그대로 그려진다', () => {
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
for (let n = 1; n <= 5; n++) {
expect(pageButton(n)).toBeInTheDocument();
}
expect(pageButton(5)).toHaveAttribute('aria-current', 'page');
});
it('다음 페이지가 있으면 그 번호 하나와 계속됨 표시가 붙고, 그 너머는 그리지 않는다', () => {
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
expect(pageButton(6)).toBeInTheDocument();
expect(pageButton(7)).not.toBeInTheDocument();
expect(screen.getByText('...')).toBeInTheDocument();
});
it('다음 페이지가 없으면 현재 페이지가 끝이다 — 계속됨 표시도 없다', () => {
render(
<Pagination currentPage={3} totalPages={null} hasMorePages={false} onPageChange={vi.fn()} />
);
expect(pageButton(3)).toBeInTheDocument();
expect(pageButton(4)).not.toBeInTheDocument();
expect(screen.queryByText('...')).not.toBeInTheDocument();
});
it('첫 페이지에서도 번호가 버튼으로 그려진다', () => {
render(
<Pagination currentPage={1} totalPages={null} hasMorePages={true} onPageChange={vi.fn()} />
);
expect(pageButton(1)).toBeInTheDocument();
expect(pageButton(2)).toBeInTheDocument();
});
it('깊은 페이지에서는 앞쪽을 접고 현재 페이지 주변만 남긴다', () => {
render(
<Pagination
currentPage={12}
totalPages={null}
hasMorePages={true}
maxVisiblePages={5}
onPageChange={vi.fn()}
/>
);
expect(pageButton(1)).toBeInTheDocument();
expect(pageButton(2)).not.toBeInTheDocument();
expect(pageButton(10)).toBeInTheDocument();
expect(pageButton(11)).toBeInTheDocument();
expect(pageButton(12)).toBeInTheDocument();
expect(pageButton(13)).toBeInTheDocument();
expect(pageButton(14)).not.toBeInTheDocument();
expect(screen.getAllByText('...')).toHaveLength(2);
});
it('앞 페이지 번호와 다음 페이지 번호를 클릭하면 그 페이지로 이동한다', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={5} totalPages={null} hasMorePages={true} onPageChange={onPageChange} />
);
fireEvent.click(pageButton(3)!);
expect(onPageChange).toHaveBeenCalledWith(3);
fireEvent.click(pageButton(6)!);
expect(onPageChange).toHaveBeenCalledWith(6);
});
it('마지막 페이지 점프 버튼만 감춰진다', () => {
render(
<Pagination
currentPage={5}
totalPages={null}
hasMorePages={true}
showFirstLast={true}
onPageChange={vi.fn()}
/>
);
expect(screen.queryByLabelText('마지막 페이지')).not.toBeInTheDocument();
expect(screen.getByLabelText('이전 페이지')).toBeInTheDocument();
expect(screen.getByLabelText('다음 페이지')).toBeInTheDocument();
});
});