fix(admin_basic,basic): 상한 총 건수 목록의 페이지 번호 목록 복원
총 건수를 상한까지만 세는 목록(last_page: null)에서 Pagination 이 번호 목록을 통째로 비우고 현재 페이지 숫자 하나만 남겼다. 합의되고 CHANGELOG 에 약속된 동작은 "마지막 페이지 점프만 감춤" 이었으므로, 1..현재 페이지와 (다음이 있으면) 현재+1 까지는 그대로 그리고 그 너머만 계속됨 표시로 둔다. 활동 로그처럼 10,000건을 넘는 화면에서 앞 페이지로 직접 이동할 방법이 없던 것을 되돌린다. 두 템플릿 컴포넌트 회귀 테스트(작성 시 6/7 fail 확인)와 활동 로그 E2E 를 추가하고, pagination.md 의 화면 규약 서술을 실제 동작으로 정정했다.
This commit is contained in:
@@ -87,6 +87,7 @@ FULLTEXT(`MATCH ... AGAINST`) 검색에서 이 차이가 크게 드러난다.
|
|||||||
| 다음 페이지 이동 | **그대로 가능** — `per_page + 1` 조회로 정확히 판정 |
|
| 다음 페이지 이동 | **그대로 가능** — `per_page + 1` 조회로 정확히 판정 |
|
||||||
| 이전 페이지 이동 | 그대로 가능 |
|
| 이전 페이지 이동 | 그대로 가능 |
|
||||||
| 마지막 페이지 점프 | **감춤** — 총 건수를 알아야 계산되는 유일한 값 |
|
| 마지막 페이지 점프 | **감춤** — 총 건수를 알아야 계산되는 유일한 값 |
|
||||||
|
| 페이지 번호 목록 | 그대로 — 1 부터 현재 페이지까지와, 다음이 있으면 현재+1 을 그린다. 그 너머는 존재 여부를 모르므로 계속됨 표시(`...`)만 둔다 |
|
||||||
|
|
||||||
마지막 페이지 점프를 감추는 것은 기능 축소가 아니라 **계산 불가 사실의 정직한 표시**다.
|
마지막 페이지 점프를 감추는 것은 기능 축소가 아니라 **계산 불가 사실의 정직한 표시**다.
|
||||||
그 버튼은 대량 매칭에서 초대형 OFFSET 을 실행하므로, 눌러도 정상 응답이 오지 않는 경우가 많다.
|
그 버튼은 대량 매칭에서 초대형 OFFSET 을 실행하므로, 눌러도 정상 응답이 오지 않는 경우가 많다.
|
||||||
@@ -318,7 +319,7 @@ HookManager::addFilter('core.pagination.filter_result_cap', function (int $cap,
|
|||||||
|
|
||||||
| Prop | 의미 |
|
| Prop | 의미 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `totalPages` | 마지막 페이지. **`null` 이면 페이지 번호 목록과 마지막 페이지 점프가 사라진다** |
|
| `totalPages` | 마지막 페이지. **`null` 이면 마지막 페이지 점프만 사라진다** — 번호 목록은 1..현재(+다음이 있으면 현재+1)까지 그대로 그린다. 번호 목록까지 비우면 앞 페이지로 직접 뛰어갈 방법이 없어진다 |
|
||||||
| `hasMorePages` | 다음 페이지 존재 여부. 총 건수를 몰라도 정확하다 |
|
| `hasMorePages` | 다음 페이지 존재 여부. 총 건수를 몰라도 정확하다 |
|
||||||
| `showFirst` / `showLast` | 첫/마지막 버튼을 따로 제어 (미지정 시 `showFirstLast` 를 따름) |
|
| `showFirst` / `showLast` | 첫/마지막 버튼을 따로 제어 (미지정 시 `showFirstLast` 를 따름) |
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
|||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 0개 | — |
|
| PHPUnit | 0개 | — |
|
||||||
| Vitest | 206개 | `vitest.config.ts` |
|
| Vitest | 207개 | `vitest.config.ts` |
|
||||||
| Playwright | 9개 | `tests/Playwright` |
|
| Playwright | 9개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- 기록이 아주 많아 총 건수를 정확히 세지 못한 목록(활동 로그 등)에서 페이지 번호가 전부 사라지고 현재 페이지 숫자 하나만 남던 문제를 수정했습니다. 이제 1 부터 현재 페이지까지의 번호와 다음 페이지 번호가 그대로 표시되어 앞 페이지로 바로 이동할 수 있으며, 감춰지는 것은 마지막 페이지로 뛰는 버튼뿐입니다.
|
||||||
- 설정 파일의 자산 항목이 실제 파일 위치와 달랐던 것을 정정했습니다. 존재하지 않는 글꼴·이미지 폴더 항목은 제거했습니다.
|
- 설정 파일의 자산 항목이 실제 파일 위치와 달랐던 것을 정정했습니다. 존재하지 않는 글꼴·이미지 폴더 항목은 제거했습니다.
|
||||||
- 환경설정 > 일반의 「자산 URL 방식 자동 감지」가 감지 결과를 선택 항목과 저장 내용에 반영합니다. 종전에는 버튼을 눌러도 선택이 그대로였고 진행·결과 안내도 표시되지 않았습니다.
|
- 환경설정 > 일반의 「자산 URL 방식 자동 감지」가 감지 결과를 선택 항목과 저장 내용에 반영합니다. 종전에는 버튼을 눌러도 선택이 그대로였고 진행·결과 안내도 표시되지 않았습니다.
|
||||||
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@ export interface PaginationProps {
|
|||||||
* 마지막 페이지 번호
|
* 마지막 페이지 번호
|
||||||
*
|
*
|
||||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
|
||||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||||
*/
|
*/
|
||||||
totalPages: number | null;
|
totalPages: number | null;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export interface PaginationProps {
|
|||||||
* 마지막 페이지 번호
|
* 마지막 페이지 번호
|
||||||
*
|
*
|
||||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
|
||||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||||
*/
|
*/
|
||||||
totalPages: number | null;
|
totalPages: number | null;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
@@ -91,8 +91,8 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
editorAttrs,
|
editorAttrs,
|
||||||
}) => {
|
}) => {
|
||||||
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
|
// 계산 불가한 값은 마지막 페이지 번호 하나뿐이므로 그 점프만 감춘다. 1 부터 현재
|
||||||
// 끝까지 넘겨 보는 것은 그대로 가능하다.
|
// 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는 서버가 `hasMorePages` 로 알린다.
|
||||||
const isBounded = totalPages !== null && totalPages !== undefined;
|
const isBounded = totalPages !== null && totalPages !== undefined;
|
||||||
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
||||||
|
|
||||||
@@ -112,7 +112,21 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
const pages: (number | string)[] = [];
|
const pages: (number | string)[] = [];
|
||||||
|
|
||||||
if (!isBounded) {
|
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;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +174,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
|
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages, hasMorePages]);
|
||||||
|
|
||||||
const handlePageClick = (page: number) => {
|
const handlePageClick = (page: number) => {
|
||||||
if (page < 1 || page === currentPage) return;
|
if (page < 1 || page === currentPage) return;
|
||||||
@@ -210,13 +224,8 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
{prevText || <>‹</>}
|
{prevText || <>‹</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
|
{/* Page Numbers — 마지막 페이지를 모르면 아는 범위까지만 표시 */}
|
||||||
<Div className="pagination">
|
<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) => {
|
{pageNumbers.map((page, index) => {
|
||||||
if (page === '...') {
|
if (page === '...') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+110
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
|||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 0개 | — |
|
| PHPUnit | 0개 | — |
|
||||||
| Vitest | 142개 | `vitest.config.ts` |
|
| Vitest | 143개 | `vitest.config.ts` |
|
||||||
| Playwright | 8개 | `tests/Playwright` |
|
| Playwright | 8개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- 총 건수를 정확히 세지 못한 목록(대량 검색 결과, 게시글이 아주 많은 게시판 등)에서 페이지 번호가 전부 사라지고 현재 페이지 숫자 하나만 남던 문제를 수정했습니다. 이제 1 부터 현재 페이지까지의 번호와 다음 페이지 번호가 그대로 표시되며, 감춰지는 것은 마지막 페이지로 뛰는 버튼뿐입니다.
|
||||||
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
|
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
|
||||||
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
|
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
|
||||||
|
|||||||
+10
-10
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@ export interface PaginationProps {
|
|||||||
* 마지막 페이지 번호
|
* 마지막 페이지 번호
|
||||||
*
|
*
|
||||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
|
||||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||||
*/
|
*/
|
||||||
totalPages: number | null;
|
totalPages: number | null;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export interface PaginationProps {
|
|||||||
* 마지막 페이지 번호
|
* 마지막 페이지 번호
|
||||||
*
|
*
|
||||||
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
* 총 건수가 상한을 넘겨 정확히 세지 않은 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
* 그 경우 서버가 `null` 을 보내며, 페이지 번호 목록과 마지막 페이지 점프가 사라지고
|
* 그 경우 서버가 `null` 을 보내며, 마지막 페이지 점프만 사라진다. 1 부터 현재 페이지까지와
|
||||||
* 이전/다음 이동만 남는다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
* (`hasMorePages` 가 참이면) 다음 페이지 번호는 그대로 그린다. 0 이나 1 로 채우면 화면이 "1페이지뿐" 이라고 잘못 말하게 된다.
|
||||||
*/
|
*/
|
||||||
totalPages: number | null;
|
totalPages: number | null;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
@@ -93,8 +93,8 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
editorAttrs,
|
editorAttrs,
|
||||||
}) => {
|
}) => {
|
||||||
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
// 총 건수를 정확히 세지 못한 목록은 마지막 페이지를 계산할 수 없다.
|
||||||
// 이때는 페이지 번호 목록과 마지막 페이지 점프를 감추고 이전/다음 이동만 남긴다 —
|
// 계산 불가한 값은 마지막 페이지 번호 하나뿐이므로 그 점프만 감춘다. 1 부터 현재
|
||||||
// 끝까지 넘겨 보는 것은 그대로 가능하다.
|
// 페이지까지는 확실히 존재하고, 다음 페이지 존재 여부는 서버가 `hasMorePages` 로 알린다.
|
||||||
const isBounded = totalPages !== null && totalPages !== undefined;
|
const isBounded = totalPages !== null && totalPages !== undefined;
|
||||||
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
const resolvedTotalPages = isBounded ? (totalPages as number) : 0;
|
||||||
|
|
||||||
@@ -114,7 +114,21 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
const pages: (number | string)[] = [];
|
const pages: (number | string)[] = [];
|
||||||
|
|
||||||
if (!isBounded) {
|
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;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +176,7 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages]);
|
}, [currentPage, resolvedTotalPages, isBounded, maxVisiblePages, hasMorePages]);
|
||||||
|
|
||||||
const handlePageClick = (page: number) => {
|
const handlePageClick = (page: number) => {
|
||||||
if (page < 1 || page === currentPage) return;
|
if (page < 1 || page === currentPage) return;
|
||||||
@@ -212,13 +226,8 @@ export const Pagination: React.FC<PaginationProps> = ({
|
|||||||
{prevText || <>‹</>}
|
{prevText || <>‹</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Page Numbers — 마지막 페이지를 모르면 현재 페이지만 표시 */}
|
{/* Page Numbers — 마지막 페이지를 모르면 아는 범위까지만 표시 */}
|
||||||
<Div className="flex items-center gap-1">
|
<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) => {
|
{pageNumbers.map((page, index) => {
|
||||||
if (page === '...') {
|
if (page === '...') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+110
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* E2E: 총 건수를 정확히 세지 못한 목록의 페이지 번호 목록
|
||||||
|
*
|
||||||
|
* @scenario activity_log_unbounded_pager_numbers, activity_log_unbounded_pager_no_last
|
||||||
|
* @effects page_numbers_visible, last_page_jump_hidden
|
||||||
|
*
|
||||||
|
* 총 건수 집계 상한(관리자 > 환경설정 > 고급)을 활동 로그 건수보다 작게 낮춰
|
||||||
|
* `last_page: null` 응답을 실제 서버에서 만들고, 활동 로그 화면의 페이저가
|
||||||
|
* 1. 1 페이지 번호 버튼과 다음 페이지(2) 번호 버튼을 그리고
|
||||||
|
* 2. 마지막 페이지 점프 버튼은 그리지 않으며
|
||||||
|
* 3. 번호 버튼을 눌러 그 페이지로 이동하는지
|
||||||
|
* 를 확인한다. 종전에는 현재 페이지 숫자 하나만 남아 앞 페이지로 뛸 방법이 없었다.
|
||||||
|
* 테스트가 끝나면 상한을 원래 값으로 되돌린다.
|
||||||
|
*/
|
||||||
|
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||||
|
|
||||||
|
const SETTINGS_URL = '/api/admin/settings';
|
||||||
|
|
||||||
|
async function readResultCap(page: import('@playwright/test').Page, token: string): Promise<number> {
|
||||||
|
const res = await page.request.get(SETTINGS_URL, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
return Number(body?.data?.advanced?.pagination_result_cap ?? 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeResultCap(page: import('@playwright/test').Page, token: string, cap: number): Promise<void> {
|
||||||
|
const res = await page.request.post(SETTINGS_URL, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { advanced: { pagination_result_cap: cap } },
|
||||||
|
});
|
||||||
|
expect(res.ok(), `설정 저장 실패: ${res.status()}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// @scenario link=activity_log_unbounded_pager_numbers, permitted=na
|
||||||
|
// @effects page_numbers_visible, last_page_jump_hidden
|
||||||
|
test('#519 후속 - 총 건수를 모르는 활동 로그에서도 페이지 번호가 그려지고 마지막 페이지 버튼만 감춰진다', async ({ page }) => {
|
||||||
|
const token = issueToken('core.settings.update');
|
||||||
|
await authenticatePage(page, token);
|
||||||
|
|
||||||
|
const originalCap = await readResultCap(page, token);
|
||||||
|
await writeResultCap(page, token, 5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto('/admin/activity-logs?per_page=5');
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||||
|
|
||||||
|
const list = await page.request.get('/api/admin/activity-logs?per_page=5', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const listBody = await list.json();
|
||||||
|
const meta = listBody?.data?.pagination ?? listBody?.meta;
|
||||||
|
test.skip(!meta || meta.last_page !== null, '활동 로그가 상한(5건) 이하라 last_page 가 계산된다 — 이 시나리오의 전제가 성립하지 않는다');
|
||||||
|
|
||||||
|
// 페이저 루트는 Div(role=navigation) 이라 태그가 아니라 역할·이름으로 찾는다
|
||||||
|
const nav = page.getByRole('navigation', { name: '페이지네이션' }).first();
|
||||||
|
await expect(nav).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const pageButton = (n: number) => nav.locator('button', { hasText: new RegExp(`^${n}$`) });
|
||||||
|
await expect(pageButton(1)).toBeVisible();
|
||||||
|
await expect(pageButton(1)).toHaveAttribute('aria-current', 'page');
|
||||||
|
await expect(pageButton(2)).toBeVisible();
|
||||||
|
await expect(nav.locator('button[aria-label="마지막 페이지"]')).toHaveCount(0);
|
||||||
|
await expect(nav.locator('button[aria-label="다음 페이지"]')).toBeEnabled();
|
||||||
|
|
||||||
|
await pageButton(2).click();
|
||||||
|
await expect(pageButton(2)).toHaveAttribute('aria-current', 'page', { timeout: 15_000 });
|
||||||
|
await expect(pageButton(1)).toBeVisible();
|
||||||
|
await expect(pageButton(3)).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await writeResultCap(page, token, originalCap);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user