chore(ci): repair pagination validation fixture #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Apply bookmark library pagination | |
| on: | |
| push: | |
| branches: | |
| - feat/ambient-homepage-v1 | |
| permissions: | |
| contents: write | |
| jobs: | |
| apply-and-validate: | |
| if: github.actor != 'github-actions[bot]' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout feature branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: feat/ambient-homepage-v1 | |
| fetch-depth: 0 | |
| - name: Apply pagination implementation | |
| shell: bash | |
| run: | | |
| python <<'PY' | |
| from pathlib import Path | |
| from textwrap import dedent | |
| def replace_once(source: str, old: str, new: str, label: str) -> str: | |
| count = source.count(old) | |
| if count != 1: | |
| raise SystemExit(f'{label}: expected 1 match, found {count}') | |
| return source.replace(old, new, 1) | |
| page = Path('src/pages/BookmarkLibrary.tsx') | |
| source = page.read_text(encoding='utf-8') | |
| source = replace_once( | |
| source, | |
| " useDeferredValue,\n useMemo,\n useState,\n", | |
| " useDeferredValue,\n useEffect,\n useMemo,\n useRef,\n useState,\n", | |
| 'react imports', | |
| ) | |
| source = replace_once( | |
| source, | |
| " ArrowUpDown,\n Clock3,\n", | |
| " ArrowUpDown,\n ChevronLeft,\n ChevronRight,\n Clock3,\n", | |
| 'chevron imports', | |
| ) | |
| source = replace_once( | |
| source, | |
| " List,\n Lock,\n", | |
| " List,\n Lock,\n MoreHorizontal,\n", | |
| 'ellipsis import', | |
| ) | |
| constants = dedent(''' | |
| const LIBRARY_VIEW_KEY = 'nowen-library-view-v1' | |
| const LIBRARY_SORT_KEY = 'nowen-library-sort-v1' | |
| const LIBRARY_PAGE_SIZE_KEY = 'nowen-library-page-size-v1' | |
| const LIBRARY_PAGE_SIZE_OPTIONS = [20, 40, 80] as const | |
| type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end' | |
| function readStoredPageSize() { | |
| if (typeof window === 'undefined') return LIBRARY_PAGE_SIZE_OPTIONS[0] | |
| try { | |
| const value = Number(window.localStorage.getItem(LIBRARY_PAGE_SIZE_KEY)) | |
| return LIBRARY_PAGE_SIZE_OPTIONS.includes(value as (typeof LIBRARY_PAGE_SIZE_OPTIONS)[number]) | |
| ? value | |
| : LIBRARY_PAGE_SIZE_OPTIONS[0] | |
| } catch { | |
| return LIBRARY_PAGE_SIZE_OPTIONS[0] | |
| } | |
| } | |
| function buildPaginationItems(currentPage: number, totalPages: number): PaginationItem[] { | |
| if (totalPages <= 7) { | |
| return Array.from({ length: totalPages }, (_, index) => index + 1) | |
| } | |
| const page = Math.min(Math.max(currentPage, 1), totalPages) | |
| const items: PaginationItem[] = [1] | |
| let start = Math.max(2, page - 1) | |
| let end = Math.min(totalPages - 1, page + 1) | |
| if (page <= 4) { | |
| start = 2 | |
| end = 5 | |
| } else if (page >= totalPages - 3) { | |
| start = totalPages - 4 | |
| end = totalPages - 1 | |
| } | |
| if (start > 2) items.push('ellipsis-start') | |
| for (let value = start; value <= end; value += 1) items.push(value) | |
| if (end < totalPages - 1) items.push('ellipsis-end') | |
| items.push(totalPages) | |
| return items | |
| } | |
| ''').lstrip() | |
| source = replace_once( | |
| source, | |
| "const LIBRARY_VIEW_KEY = 'nowen-library-view-v1'\nconst LIBRARY_SORT_KEY = 'nowen-library-sort-v1'\n", | |
| constants, | |
| 'library constants', | |
| ) | |
| state_anchor = ( | |
| " const [sortMode, setSortMode] = useState<SortMode>(() =>\n" | |
| " readStoredValue(LIBRARY_SORT_KEY, ['custom', 'title', 'updated'] as const, 'custom'),\n" | |
| " )\n" | |
| ) | |
| state_insert = state_anchor + ( | |
| " const [pageSize, setPageSize] = useState(readStoredPageSize)\n" | |
| " const [currentPage, setCurrentPage] = useState(1)\n" | |
| " const contentRef = useRef<HTMLElement>(null)\n" | |
| ) | |
| source = replace_once(source, state_anchor, state_insert, 'pagination state') | |
| pagination_logic = dedent(''' | |
| const totalPages = Math.max(1, Math.ceil(filteredBookmarks.length / pageSize)) | |
| const safeCurrentPage = Math.min(currentPage, totalPages) | |
| const pageStart = (safeCurrentPage - 1) * pageSize | |
| const paginatedBookmarks = useMemo( | |
| () => filteredBookmarks.slice(pageStart, pageStart + pageSize), | |
| [filteredBookmarks, pageSize, pageStart], | |
| ) | |
| const paginationItems = useMemo( | |
| () => buildPaginationItems(safeCurrentPage, totalPages), | |
| [safeCurrentPage, totalPages], | |
| ) | |
| const showPagination = filteredBookmarks.length > LIBRARY_PAGE_SIZE_OPTIONS[0] | |
| const visibleRangeStart = filteredBookmarks.length === 0 ? 0 : pageStart + 1 | |
| const visibleRangeEnd = Math.min(pageStart + pageSize, filteredBookmarks.length) | |
| useEffect(() => { | |
| setCurrentPage(1) | |
| }, [activeCollection, activeTag, deferredQuery, sortMode, view]) | |
| useEffect(() => { | |
| setCurrentPage((previousPage) => Math.min(previousPage, totalPages)) | |
| }, [totalPages]) | |
| ''') | |
| source = replace_once( | |
| source, | |
| " const activeCollectionLabel = useMemo(() => {\n", | |
| pagination_logic + " const activeCollectionLabel = useMemo(() => {\n", | |
| 'pagination calculations', | |
| ) | |
| clear_filters = ( | |
| " const clearFilters = useCallback(() => {\n" | |
| " setQuery('')\n" | |
| " if (activeTag) {\n" | |
| " onSelectTag(null)\n" | |
| " } else if (activeCollection !== 'all') {\n" | |
| " onSelectCollection('all')\n" | |
| " }\n" | |
| " }, [activeCollection, activeTag, onSelectCollection, onSelectTag])\n\n" | |
| ) | |
| pagination_handlers = dedent(''' | |
| const scrollToResults = useCallback(() => { | |
| const scroll = () => contentRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }) | |
| if (typeof window.requestAnimationFrame === 'function') { | |
| window.requestAnimationFrame(scroll) | |
| } else { | |
| scroll() | |
| } | |
| }, []) | |
| const changePage = useCallback((nextPage: number) => { | |
| const clampedPage = Math.min(Math.max(nextPage, 1), totalPages) | |
| if (clampedPage === safeCurrentPage) return | |
| setCurrentPage(clampedPage) | |
| scrollToResults() | |
| }, [safeCurrentPage, scrollToResults, totalPages]) | |
| const changePageSize = useCallback((nextPageSize: number) => { | |
| setPageSize(nextPageSize) | |
| setCurrentPage(1) | |
| writeStoredValue(LIBRARY_PAGE_SIZE_KEY, String(nextPageSize)) | |
| scrollToResults() | |
| }, [scrollToResults]) | |
| ''') | |
| source = replace_once(source, clear_filters, clear_filters + pagination_handlers, 'pagination handlers') | |
| source = replace_once( | |
| source, | |
| '<main className="bookmark-library__content">', | |
| '<main ref={contentRef} className="bookmark-library__content">', | |
| 'content ref', | |
| ) | |
| source = replace_once( | |
| source, | |
| ' {filteredBookmarks.map((bookmark) => (\n', | |
| ' {paginatedBookmarks.map((bookmark) => (\n', | |
| 'paginated result map', | |
| ) | |
| pagination_markup = dedent(''' | |
| {showPagination && ( | |
| <footer className="bookmark-library__pagination"> | |
| <div className="bookmark-library__pagination-meta"> | |
| <span> | |
| {t('admin.bookmark.pagination_info', { | |
| start: visibleRangeStart, | |
| end: visibleRangeEnd, | |
| total: filteredBookmarks.length, | |
| })} | |
| </span> | |
| <label> | |
| <span>{t('admin.bookmark.page_size_label', '每页')}</span> | |
| <select | |
| value={pageSize} | |
| onChange={(event) => changePageSize(Number(event.target.value))} | |
| aria-label={t('library.pageSize', '每页显示数量')} | |
| > | |
| {LIBRARY_PAGE_SIZE_OPTIONS.map((option) => ( | |
| <option key={option} value={option}>{option}</option> | |
| ))} | |
| </select> | |
| </label> | |
| </div> | |
| <nav className="bookmark-library__pagination-controls" aria-label={t('library.pagination', '书签分页')}> | |
| <button | |
| type="button" | |
| onClick={() => changePage(safeCurrentPage - 1)} | |
| disabled={safeCurrentPage === 1} | |
| aria-label={t('library.previousPage', '上一页')} | |
| > | |
| <ChevronLeft className="h-4 w-4" /> | |
| </button> | |
| <div className="bookmark-library__pagination-pages"> | |
| {paginationItems.map((item) => typeof item === 'number' ? ( | |
| <button | |
| key={item} | |
| type="button" | |
| className={item === safeCurrentPage ? 'is-active' : undefined} | |
| aria-current={item === safeCurrentPage ? 'page' : undefined} | |
| aria-label={`${t('library.page', '第')} ${item} ${t('library.pageSuffix', '页')}`} | |
| onClick={() => changePage(item)} | |
| > | |
| {item} | |
| </button> | |
| ) : ( | |
| <span key={item} aria-hidden="true"><MoreHorizontal className="h-4 w-4" /></span> | |
| ))} | |
| </div> | |
| <span className="bookmark-library__pagination-status"> | |
| {safeCurrentPage} / {totalPages} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => changePage(safeCurrentPage + 1)} | |
| disabled={safeCurrentPage === totalPages} | |
| aria-label={t('library.nextPage', '下一页')} | |
| > | |
| <ChevronRight className="h-4 w-4" /> | |
| </button> | |
| </nav> | |
| </footer> | |
| )} | |
| ''') | |
| source = replace_once( | |
| source, | |
| " )}\n </main>\n", | |
| " )}" + pagination_markup + " </main>\n", | |
| 'pagination footer', | |
| ) | |
| page.write_text(source, encoding='utf-8') | |
| css = Path('src/styles/bookmark-library.css') | |
| css_source = css.read_text(encoding='utf-8') | |
| css_block = dedent(''' | |
| .bookmark-library__pagination { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| margin-top: 1.15rem; | |
| padding: 0.9rem 0.95rem; | |
| border: 1px solid color-mix(in srgb, var(--color-glass-border) 88%, transparent); | |
| border-radius: 1rem; | |
| background: color-mix(in srgb, var(--color-bg-tertiary) 62%, transparent); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16); | |
| } | |
| .bookmark-library__pagination-meta, | |
| .bookmark-library__pagination-meta label, | |
| .bookmark-library__pagination-controls, | |
| .bookmark-library__pagination-pages { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .bookmark-library__pagination-meta { | |
| gap: 0.9rem; | |
| color: var(--color-text-muted); | |
| font-size: 0.68rem; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .bookmark-library__pagination-meta label { | |
| gap: 0.4rem; | |
| } | |
| .bookmark-library__pagination-meta select { | |
| min-width: 4.1rem; | |
| height: 2.1rem; | |
| border: 1px solid var(--color-glass-border); | |
| border-radius: 0.7rem; | |
| outline: none; | |
| background: var(--color-bg-secondary); | |
| color: var(--color-text-secondary); | |
| font-size: 0.68rem; | |
| padding: 0 0.55rem; | |
| } | |
| .bookmark-library__pagination-controls { | |
| gap: 0.35rem; | |
| } | |
| .bookmark-library__pagination-controls > button, | |
| .bookmark-library__pagination-pages button, | |
| .bookmark-library__pagination-pages > span { | |
| display: inline-grid; | |
| width: 2.15rem; | |
| height: 2.15rem; | |
| place-items: center; | |
| border-radius: 0.7rem; | |
| } | |
| .bookmark-library__pagination-controls > button, | |
| .bookmark-library__pagination-pages button { | |
| border: 1px solid var(--color-glass-border); | |
| background: color-mix(in srgb, var(--color-glass) 74%, transparent); | |
| color: var(--color-text-secondary); | |
| font-size: 0.68rem; | |
| font-weight: 650; | |
| font-variant-numeric: tabular-nums; | |
| transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, color 160ms ease; | |
| } | |
| .bookmark-library__pagination-controls > button:hover:not(:disabled), | |
| .bookmark-library__pagination-pages button:hover { | |
| transform: translateY(-1px); | |
| border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-glass-border)); | |
| color: var(--color-primary); | |
| } | |
| .bookmark-library__pagination-pages button.is-active { | |
| border-color: color-mix(in srgb, var(--color-primary) 64%, transparent); | |
| background: var(--color-primary); | |
| color: white; | |
| box-shadow: 0 8px 22px color-mix(in srgb, var(--color-primary) 24%, transparent); | |
| } | |
| .bookmark-library__pagination-controls > button:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.34; | |
| } | |
| .bookmark-library__pagination-pages { | |
| gap: 0.3rem; | |
| } | |
| .bookmark-library__pagination-pages > span { | |
| color: var(--color-text-muted); | |
| } | |
| .bookmark-library__pagination-status { | |
| display: none; | |
| min-width: 3.4rem; | |
| color: var(--color-text-secondary); | |
| font-size: 0.7rem; | |
| font-weight: 650; | |
| text-align: center; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| @media (max-width: 639px) { | |
| .bookmark-library__pagination { | |
| align-items: stretch; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| padding: 0.8rem; | |
| } | |
| .bookmark-library__pagination-meta { | |
| justify-content: space-between; | |
| } | |
| .bookmark-library__pagination-controls { | |
| justify-content: space-between; | |
| } | |
| .bookmark-library__pagination-pages { | |
| display: none; | |
| } | |
| .bookmark-library__pagination-status { | |
| display: block; | |
| } | |
| .bookmark-library__pagination-controls > button { | |
| width: 2.5rem; | |
| } | |
| } | |
| ''') | |
| css_source = replace_once( | |
| css_source, | |
| '.bookmark-library__empty {\n', | |
| css_block + '.bookmark-library__empty {\n', | |
| 'pagination styles', | |
| ) | |
| css.write_text(css_source, encoding='utf-8') | |
| test = Path('src/pages/__tests__/BookmarkLibrary.test.tsx') | |
| test_source = test.read_text(encoding='utf-8') | |
| test_anchor = ( | |
| " it('persists the preferred list view', async () => {\n" | |
| " const { getByTitle } = render(<BookmarkLibrary {...baseProps} />)\n" | |
| " fireEvent.click(getByTitle('列表视图'))\n\n" | |
| " await waitFor(() => {\n" | |
| " expect(window.localStorage.getItem('nowen-library-view-v1')).toBe('list')\n" | |
| " })\n" | |
| " })\n" | |
| ) | |
| test_case = dedent(''' | |
| it('paginates large result sets and keeps page-size controls available', async () => { | |
| const manyBookmarks: Bookmark[] = Array.from({ length: 25 }, (_, index) => ({ | |
| id: `bookmark-${index + 1}`, | |
| url: `https://example.com/${index + 1}`, | |
| title: `Bookmark ${String(index + 1).padStart(2, '0')}`, | |
| orderIndex: index, | |
| createdAt: 1, | |
| updatedAt: index + 1, | |
| })) | |
| const { getByRole, queryByRole } = render( | |
| <BookmarkLibrary {...baseProps} bookmarks={manyBookmarks} categories={[]} />, | |
| ) | |
| expect(getByRole('heading', { name: 'Bookmark 01' })).toBeTruthy() | |
| expect(getByRole('heading', { name: 'Bookmark 20' })).toBeTruthy() | |
| expect(queryByRole('heading', { name: 'Bookmark 21' })).toBeNull() | |
| fireEvent.click(getByRole('button', { name: '下一页' })) | |
| await waitFor(() => { | |
| expect(getByRole('heading', { name: 'Bookmark 21' })).toBeTruthy() | |
| expect(queryByRole('heading', { name: 'Bookmark 01' })).toBeNull() | |
| }) | |
| fireEvent.change(getByRole('combobox', { name: '每页显示数量' }), { | |
| target: { value: '40' }, | |
| }) | |
| await waitFor(() => { | |
| expect(getByRole('heading', { name: 'Bookmark 01' })).toBeTruthy() | |
| expect(getByRole('heading', { name: 'Bookmark 25' })).toBeTruthy() | |
| expect(window.localStorage.getItem('nowen-library-page-size-v1')).toBe('40') | |
| }) | |
| }) | |
| ''') | |
| test_source = replace_once(test_source, test_anchor, test_anchor + test_case, 'pagination test') | |
| test.write_text(test_source, encoding='utf-8') | |
| PY | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Run bookmark library tests | |
| run: npm run test:run -- src/pages/__tests__/BookmarkLibrary.test.tsx | |
| - name: Build frontend | |
| run: npm run build | |
| - name: Commit pagination implementation | |
| shell: bash | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add src/pages/BookmarkLibrary.tsx src/styles/bookmark-library.css src/pages/__tests__/BookmarkLibrary.test.tsx | |
| git diff --cached --check | |
| git commit -m "feat(library): add responsive bookmark pagination" | |
| git push origin HEAD:feat/ambient-homepage-v1 |