diff --git a/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.test.tsx b/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.test.tsx new file mode 100644 index 0000000000..0b6be712fd --- /dev/null +++ b/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.test.tsx @@ -0,0 +1,302 @@ +import userEvent from '@testing-library/user-event'; + +import { render, screen, waitFor } from '@/test'; + +import { DataTablePaginationControls } from './DataTablePaginationControls'; + +describe('Component: DataTablePaginationControls', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user is on the first page, disables the first and previous buttons but not the next and last buttons', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByRole('button', { name: 'Go to first page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Go to previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Go to next page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Go to last page' })).toBeEnabled(); + }); + + it('given the user is on the last page, disables the next and last buttons but not the first and previous buttons', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByRole('button', { name: 'Go to first page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Go to previous page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Go to next page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Go to last page' })).toBeDisabled(); + }); + + it('given the user clicks the next page button, navigates forward and warms the cache for the next page', async () => { + // ARRANGE + const onPageChange = vi.fn(); + const onPrefetchPage = vi.fn(); + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + expect(onPageChange).toHaveBeenCalledWith(3); + expect(onPrefetchPage).toHaveBeenCalledWith(4); + }); + + it('given the user clicks the previous page button toward the first page, navigates back', async () => { + // ARRANGE + const onPageChange = vi.fn(); + const onPrefetchPage = vi.fn(); + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to previous page' })); + + // ASSERT + expect(onPageChange).toHaveBeenCalledWith(1); + + expect(onPrefetchPage).toHaveBeenCalledWith(1); + expect(onPrefetchPage).not.toHaveBeenCalledWith(0); + }); + + it('given the user clicks the first page button, navigates to the first page', async () => { + // ARRANGE + const onPageChange = vi.fn(); + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to first page' })); + + // ASSERT + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + it('given the user clicks the last page button, navigates to the last page', async () => { + // ARRANGE + const onPageChange = vi.fn(); + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to last page' })); + + // ASSERT + expect(onPageChange).toHaveBeenCalledWith(5); + }); + + it('given the user hovers over a pagination button, warms the cache', async () => { + // ARRANGE + const onPageChange = vi.fn(); + const onPrefetchPage = vi.fn(); + render( + , + ); + + // ACT + await userEvent.hover(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + expect(onPrefetchPage).toHaveBeenCalledWith(3); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('given the user types a valid page number, navigates there after a brief debounce', async () => { + // ARRANGE + const onPageChange = vi.fn(); + render( + , + ); + + // ACT + const inputEl = screen.getByRole('spinbutton', { name: 'current page number' }); + await userEvent.clear(inputEl); + await userEvent.type(inputEl, '3'); + + // ASSERT + await waitFor(() => { + expect(onPageChange).toHaveBeenCalledWith(3); + }); + }); + + it('given the user types an out of bounds page, does not navigate', async () => { + // ARRANGE + const onPageChange = vi.fn(); + render( + , + ); + + // ACT + const inputEl = screen.getByRole('spinbutton', { name: 'current page number' }); + await userEvent.clear(inputEl); + await userEvent.type(inputEl, '999'); + await new Promise((resolve) => setTimeout(resolve, 1200)); // wait for the debounce + + // ASSERT + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('given the table goes to a new page, the manual entry paginator field updates accordingly', () => { + // ARRANGE + const { rerender } = render( + , + ); + + // ACT + rerender( + , + ); + + // ASSERT + expect(screen.getByRole('spinbutton', { name: 'current page number' })).toHaveValue(4); + }); + + it('given the user changes the page, scrolls back to the top of the list', async () => { + // ARRANGE + const mockScrollTo = vi.fn(); + window.scrollTo = mockScrollTo; + + render( +
+
+ +
, + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + await waitFor(() => { + expect(mockScrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); + }); + + it('given the scroll target is not on the page, does not scroll', async () => { + // ARRANGE + const mockScrollTo = vi.fn(); + window.scrollTo = mockScrollTo; + + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + await new Promise((resolve) => setTimeout(resolve, 50)); // wait for the queued scroll event + + // ASSERT + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it('given there is only a single page, shows static text instead of a manual page field', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + expect(screen.getByText(/page 1 of 1/i)).toBeVisible(); + }); + + it('given no prefetch callback is provided, navigation still works', async () => { + // ARRANGE + const onPageChange = vi.fn(); + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + expect(onPageChange).toHaveBeenCalledWith(3); + }); +}); diff --git a/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.tsx b/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.tsx new file mode 100644 index 0000000000..2d690c60cb --- /dev/null +++ b/resources/js/common/components/DataTablePaginationControls/DataTablePaginationControls.tsx @@ -0,0 +1,124 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuChevronFirst, LuChevronLast, LuChevronLeft, LuChevronRight } from 'react-icons/lu'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { BasePagination, BasePaginationContent } from '@/common/components/+vendor/BasePagination'; +import { cn } from '@/common/utils/cn'; + +import { ManualPaginatorField } from './ManualPaginatorField'; + +interface DataTablePaginationControlsProps { + currentPage: number; + lastPage: number; + onPageChange: (pageNumber: number) => void; + + onPrefetchPage?: (pageNumber: number) => void; +} + +export const DataTablePaginationControls: FC = ({ + currentPage, + lastPage, + onPageChange, + onPrefetchPage, +}) => { + const { t } = useTranslation(); + + const goToPage = (pageNumber: number, prefetchDirection?: -1 | 1) => { + onPageChange(pageNumber); + + if (prefetchDirection) { + const adjacentPage = pageNumber + prefetchDirection; + if (adjacentPage >= 1 && adjacentPage <= lastPage) { + onPrefetchPage?.(adjacentPage); + } + } + + scrollToPaginationScrollTarget(); + }; + + const isOnFirstPage = currentPage === 1; + const isOnLastPage = currentPage === lastPage; + + const buttonClassNames = cn( + 'border-none hover:outline-1 hover:outline-neutral-300 hover:light:outline-neutral-200', + 'aria-disabled:pointer-events-none aria-disabled:opacity-50', + ); + + return ( + + + goToPage(1, 1)} + onMouseEnter={() => onPrefetchPage?.(1)} + disabled={isOnFirstPage} + aria-label={t('Go to first page')} + > + + + + goToPage(currentPage - 1, -1)} + onMouseEnter={() => onPrefetchPage?.(currentPage - 1)} + disabled={isOnFirstPage} + aria-label={t('Go to previous page')} + > + + + + + + goToPage(currentPage + 1, 1)} + onMouseEnter={() => onPrefetchPage?.(currentPage + 1)} + disabled={isOnLastPage} + aria-label={t('Go to next page')} + > + + + + goToPage(lastPage, -1)} + onMouseEnter={() => onPrefetchPage?.(lastPage)} + disabled={isOnLastPage} + aria-label={t('Go to last page')} + > + + + + + ); +}; + +/** + * We use a `setTimeout()` without any time here to deliberately + * push the scroll event to the end of the browser's event queue. + * If we don't do this, scroll events for navigating to the first + * and last page may not occur on some browsers. + */ +export function scrollToPaginationScrollTarget(): void { + setTimeout(() => { + const scrollTarget = document.getElementById('pagination-scroll-target'); + + if (!scrollTarget) { + return; + } + + window.scrollTo({ + top: scrollTarget.offsetTop, + behavior: 'smooth', + }); + }); +} diff --git a/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/ManualPaginatorField.tsx b/resources/js/common/components/DataTablePaginationControls/ManualPaginatorField.tsx similarity index 63% rename from resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/ManualPaginatorField.tsx rename to resources/js/common/components/DataTablePaginationControls/ManualPaginatorField.tsx index a3ef7072e3..fb94132dbc 100644 --- a/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/ManualPaginatorField.tsx +++ b/resources/js/common/components/DataTablePaginationControls/ManualPaginatorField.tsx @@ -1,5 +1,4 @@ -import type { Table } from '@tanstack/react-table'; -import type { ChangeEvent, ReactNode } from 'react'; +import type { ChangeEvent, FC } from 'react'; import { useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useDebounce } from 'react-use'; @@ -7,35 +6,31 @@ import { useDebounce } from 'react-use'; import { BaseInput } from '@/common/components/+vendor/BaseInput'; import { cn } from '@/common/utils/cn'; -interface ManualPaginatorFieldProps { - table: Table; - onPageChange: (newPageIndex: number) => void; +interface ManualPaginatorFieldProps { + currentPage: number; + onPageChange: (pageNumber: number) => void; + totalPages: number; } -export function ManualPaginatorField({ - table, +export const ManualPaginatorField: FC = ({ + currentPage, onPageChange, -}: ManualPaginatorFieldProps): ReactNode { + totalPages, +}) => { const { t } = useTranslation(); - const { pagination } = table.getState(); - - const currentPage = pagination.pageIndex + 1; - const totalPages = table.getPageCount(); - const [inputValue, setInputValue] = useState(String(currentPage)); - // Sync the input field with table state for when - // pagination changes externally (ie: the pagination buttons). useEffect(() => { setInputValue(String(currentPage)); }, [currentPage]); + // Prevent partial input from overzealously fetching. useDebounce( () => { - const newPage = Number(inputValue); - if (newPage >= 1 && newPage <= totalPages && newPage !== currentPage) { - onPageChange(newPage - 1); + const typedPage = Number(inputValue); + if (typedPage >= 1 && typedPage <= totalPages && typedPage !== currentPage) { + onPageChange(typedPage); } }, 800, @@ -54,7 +49,7 @@ export function ManualPaginatorField({ ) : ( ({ className={cn( 'h-8 max-w-20 pt-1.25 text-[13px] text-neutral-200 light:text-neutral-900', - // Hide the number spinner on desktop browsers -- it can obstruct the input field. + // Hide the number spinner on desktop browsers. It can obstruct the input field. 'appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', )} value={inputValue} - onChange={(e: ChangeEvent) => setInputValue(e.target.value)} + onChange={(event: ChangeEvent) => + setInputValue(event.target.value) + } aria-label={t('current page number')} /> ), @@ -77,4 +74,4 @@ export function ManualPaginatorField({ )}
); -} +}; diff --git a/resources/js/common/components/DataTablePaginationControls/index.ts b/resources/js/common/components/DataTablePaginationControls/index.ts new file mode 100644 index 0000000000..1f406770f8 --- /dev/null +++ b/resources/js/common/components/DataTablePaginationControls/index.ts @@ -0,0 +1 @@ +export * from './DataTablePaginationControls'; diff --git a/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/DataTablePagination.tsx b/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/DataTablePagination.tsx index 0438db1ee5..0553eb179e 100644 --- a/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/DataTablePagination.tsx +++ b/resources/js/features/game-list/components/GamesDataTableContainer/DataTablePagination/DataTablePagination.tsx @@ -1,15 +1,13 @@ import type { Table } from '@tanstack/react-table'; import type { ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { LuChevronFirst, LuChevronLast, LuChevronLeft, LuChevronRight } from 'react-icons/lu'; import type { RouteName } from 'ziggy-js'; -import { BaseButton } from '@/common/components/+vendor/BaseButton'; -import { BasePagination, BasePaginationContent } from '@/common/components/+vendor/BasePagination'; -import { cn } from '@/common/utils/cn'; +import { + DataTablePaginationControls, + scrollToPaginationScrollTarget, +} from '@/common/components/DataTablePaginationControls'; import { useDataTablePrefetchPagination } from '../../../hooks/useDataTablePrefetchPagination'; -import { ManualPaginatorField } from './ManualPaginatorField'; import { PageSizeSelect } from './PageSizeSelect'; interface DataTablePaginationProps { @@ -23,96 +21,24 @@ export function DataTablePagination({ tableApiRouteParams, tableApiRouteName = 'api.game.index', }: DataTablePaginationProps): ReactNode { - const { t } = useTranslation(); - const { pagination } = table.getState(); - // Given the user hovers over a pagination button, it is very likely they will - // wind up clicking the button. Queries are cheap, so prefetch the destination page. const { prefetchPagination } = useDataTablePrefetchPagination( table, tableApiRouteName, tableApiRouteParams, ); - const scrollToTopOfPage = () => { - /** - * We use a `setTimeout()` without any time here to deliberately - * push the scroll event to the end of the browser's event queue. - * If we don't do this, scroll events for navigating to the first - * and last page may not occur on some browsers. - */ - setTimeout(() => { - const scrollTarget = document.getElementById('pagination-scroll-target'); - - if (!scrollTarget) { - return; - } - - window.scrollTo({ - top: scrollTarget.offsetTop, - behavior: 'smooth', - }); - }); - }; - - /** - * Handles page change and optional prefetching of adjacent pages for a smoother user experience. - * - * @param {Array<'next' | 'previous'>} [prefetchDirections] - Optional. Specifies whether to prefetch - * adjacent pages. 'next' prefetches the following page, and 'previous' prefetches the previous page. - * This is helpful for loading pages before the user actually clicks the pagination button. - * - * @example - * // Navigate to page 3 and prefetch the next page: - * handlePageChange(3, ['next']); - * - * // Navigate to page 2 and prefetch both next and previous pages: - * handlePageChange(2, ['next', 'previous']); - */ - const handlePageChange = ( - newPageIndex: number, - prefetchDirections?: Array<'next' | 'previous'>, - ) => { - // Update the current page. - table.setPageIndex(newPageIndex); - - // Handle prefetching of adjacent pages. - const lastPageIndex = table.getPageCount() - 1; - const canPrefetchNext = prefetchDirections?.includes('next') && newPageIndex < lastPageIndex; - const canPrefetchPrevious = prefetchDirections?.includes('previous') && newPageIndex > 0; - - if (canPrefetchNext) { - prefetchPagination({ - newPageIndex: Math.min(newPageIndex + 1, lastPageIndex), - newPageSize: pagination.pageSize, - }); - } - if (canPrefetchPrevious) { - prefetchPagination({ - newPageIndex: Math.max(newPageIndex - 1, 0), - newPageSize: pagination.pageSize, - }); - } - - scrollToTopOfPage(); - }; - const handlePageSizeChange = (newPageSize: number) => { // If the user is changing the page size and they're not on the first page, // auto-scroll to the top. Otherwise, things can quickly get disorienting. if (pagination.pageIndex !== 0) { - scrollToTopOfPage(); + scrollToPaginationScrollTarget(); } table.setPagination({ pageIndex: 0, pageSize: newPageSize }); }; - const buttonClassNames = cn( - 'border-none hover:outline-1 hover:outline-neutral-300 hover:light:outline-neutral-200', - 'aria-disabled:pointer-events-none aria-disabled:opacity-50', - ); - return (
{/* TODO X of Y rows selected */} @@ -120,79 +46,24 @@ export function DataTablePagination({
{ prefetchPagination({ newPageIndex: 0, newPageSize: pageSize }); }} onChange={handlePageSizeChange} /> - - - handlePageChange(0, ['next'])} - onMouseEnter={() => - prefetchPagination({ newPageIndex: 0, newPageSize: pagination.pageSize }) - } - disabled={!table.getCanPreviousPage()} - aria-label={t('Go to first page')} - > - - - - handlePageChange(pagination.pageIndex - 1, ['previous'])} - onMouseEnter={() => - prefetchPagination({ - newPageIndex: pagination.pageIndex - 1, - newPageSize: pagination.pageSize, - }) - } - disabled={!table.getCanPreviousPage()} - aria-label={t('Go to previous page')} - > - - - - - - handlePageChange(pagination.pageIndex + 1, ['next'])} - onMouseEnter={() => - prefetchPagination({ - newPageIndex: pagination.pageIndex + 1, - newPageSize: pagination.pageSize, - }) - } - disabled={!table.getCanNextPage()} - aria-label={t('Go to next page')} - > - - - - handlePageChange(table.getPageCount() - 1, ['previous'])} - onMouseEnter={() => - prefetchPagination({ - newPageIndex: table.getPageCount() - 1, - newPageSize: pagination.pageSize, - }) - } - disabled={!table.getCanNextPage()} - aria-label={t('Go to last page')} - > - - - - + table.setPageIndex(pageNumber - 1)} + onPrefetchPage={(pageNumber) => + prefetchPagination({ + newPageIndex: pageNumber - 1, + newPageSize: pagination.pageSize, + }) + } + />
); diff --git a/resources/js/features/game-list/components/GamesDataTableContainer/GameListDataTable/GameListDataTable.tsx b/resources/js/features/game-list/components/GamesDataTableContainer/GameListDataTable/GameListDataTable.tsx index 4cdf5414eb..56f583a3a8 100644 --- a/resources/js/features/game-list/components/GamesDataTableContainer/GameListDataTable/GameListDataTable.tsx +++ b/resources/js/features/game-list/components/GamesDataTableContainer/GameListDataTable/GameListDataTable.tsx @@ -108,6 +108,7 @@ export function GameListDataTable({ table, isLoading = false }: GameListDataTabl { }), stateCounts: createTicketListStateCounts(), availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: {} }), }, }); @@ -56,6 +61,7 @@ describe('Component: TicketIndexRoot', () => { }), stateCounts: createTicketListStateCounts(), availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: {} }), }, }); @@ -89,7 +95,7 @@ describe('Component: TicketIndexRoot', () => { ); expect(screen.getByRole('link', { name: /scott/i })).toBeVisible(); - expect(screen.getByRole('img', { name: 'Quarantined' })).toBeVisible(); + expect(screen.getAllByRole('img', { name: 'Quarantined' })[0]).toBeVisible(); }); it('given the page has no tickets, shows the empty state instead of a table', () => { @@ -105,6 +111,7 @@ describe('Component: TicketIndexRoot', () => { }), stateCounts: createTicketListStateCounts(), availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: {} }), }, }); @@ -112,4 +119,161 @@ describe('Component: TicketIndexRoot', () => { expect(screen.getByText('No tickets match these filters.')).toBeVisible(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); }); + + it('given the user advances to the next page, fetches the next page from the API and syncs the URL', async () => { + // ARRANGE + const pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 2001 })], { + currentPage: 2, + lastPage: 3, + perPage: 50, + total: 150, + }), + }, + }); + + render(, { + pageProps: { + scope: 'all', + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), + stateCounts: createTicketListStateCounts(), + availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: {} }), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'page[number]': 2 }, + ]); + }); + + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'page[number]': 3 }, + ]); + }); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Ticket #2001' })).toBeVisible(); + }); + + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + expect.stringContaining('page%5Bnumber%5D=2'), + ); + }); + + it('given the user types a page number instead of clicking, the query fetches the page', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 3001 })], { + currentPage: 3, + lastPage: 3, + perPage: 50, + total: 150, + }), + }, + }); + + render(, { + pageProps: { + scope: 'all', + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), + stateCounts: createTicketListStateCounts(), + availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: {} }), + }, + }); + + // ACT + const inputEl = screen.getByRole('spinbutton', { name: 'current page number' }); + await userEvent.clear(inputEl); + await userEvent.type(inputEl, '3'); + + // ASSERT + await waitFor( + () => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'page[number]': 3 }, + ]); + }, + { timeout: 2000 }, + ); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Ticket #3001' })).toBeVisible(); + }); + }); + + it('given the URL has a filter and a sort, also sends those things to the API when the user paginates', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: { + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 2, + lastPage: 3, + perPage: 50, + total: 150, + }), + }, + }); + + render(, { + pageProps: { + scope: 'all', + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), + stateCounts: createTicketListStateCounts(), + availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + ziggy: createZiggyProps({ query: { filter: { status: 'resolved' }, sort: 'state' } }), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { + scope: 'all', + sort: 'state', + 'filter[status]': 'resolved', + 'page[number]': 2, + }, + ]); + }); + }); }); diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx index 7f88f39bfd..5bd40587b3 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx @@ -1,8 +1,11 @@ +import { HydrationBoundary } from '@tanstack/react-query'; import type { FC } from 'react'; +import { DataTablePaginationControls } from '@/common/components/DataTablePaginationControls'; import { usePageProps } from '@/common/hooks/usePageProps'; import { useTicketListColumnDefinitions } from '../../hooks/useTicketListColumnDefinitions'; +import { useTicketListTableRoot } from '../../hooks/useTicketListTableRoot'; import { TICKET_LIST_COLUMN_IDS } from '../../utils/ticketListColumnIds'; import { TicketListEmptyState } from '../TicketListEmptyState'; import { TicketListHeading } from '../TicketListHeading'; @@ -12,20 +15,42 @@ import { TicketListTable } from '../TicketListTable'; const columnVisibility = Object.fromEntries(TICKET_LIST_COLUMN_IDS.map((id) => [id, true])); export const TicketIndexRoot: FC = () => { - const { paginatedTickets } = usePageProps(); + const { paginatedTickets, scope } = usePageProps(); const columnDefinitions = useTicketListColumnDefinitions(); + const { hydrationState, ticketListTableProps } = useTicketListTableRoot({ + paginatedTickets, + scope, + }); + return ( -
+
- } - paginatedTickets={paginatedTickets} - /> + + } + isFetching={ticketListTableProps.isFetching} + paginatedTickets={ticketListTableProps.paginatedTickets} + paginatorNode={ +
+ +
+ } + /> +
); }; diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListMobileRow.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListMobileRow.tsx new file mode 100644 index 0000000000..2d095a2929 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListTable/TicketListMobileRow.tsx @@ -0,0 +1,59 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useDiffForHumans } from '@/common/utils/l10n/useDiffForHumans'; + +import { ticketListCellClassNames } from '../../utils/column-definitions/ticketListCellClassNames'; +import { TicketStateGlyph } from '../TicketStateGlyph'; + +interface TicketListMobileRowProps { + entry: App.Platform.Data.TicketListEntry; +} + +export const TicketListMobileRow: FC = ({ entry }) => { + const { t } = useTranslation(); + + const { diffForHumans } = useDiffForHumans(); + + return ( +
+ + + {entry.ticketableType === 'achievement' && entry.ticketableBadgeUrl ? ( + + ) : null} + + + {entry.ticketableType === 'leaderboard' + ? t('(LB) {{title}}', { title: entry.ticketableTitle }) + : entry.ticketableTitle} + + +
+ {entry.reporter ? ( + {entry.reporter.avatarUrl} + ) : null} + + + {diffForHumans(entry.createdAt, { style: 'narrow' })} + +
+
+ ); +}; diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx index 46b709695e..d71866add8 100644 --- a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx +++ b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx @@ -21,20 +21,26 @@ const noneVisible = Object.fromEntries(TICKET_LIST_COLUMN_IDS.map((id) => [id, f interface TestHarnessProps { columnVisibility?: Record; emptyStateNode?: React.ReactNode; + isFetching?: boolean; + lastPage?: number; + paginatorNode?: React.ReactNode; tickets?: App.Platform.Data.TicketListEntry[]; } const TestHarness: FC = ({ - columnVisibility = { ...noneVisible, id: true, ticketable: true, age: true }, emptyStateNode, + isFetching, + paginatorNode, + columnVisibility = { ...noneVisible, id: true, ticketable: true, age: true }, + lastPage = 1, tickets = [createTicketListEntry()], }) => { const columnDefinitions: ColumnDef[] = useTicketListColumnDefinitions(); const paginatedTickets = createPaginatedData(tickets, { + lastPage, currentPage: 1, - lastPage: 1, perPage: 50, total: tickets.length, }); @@ -45,6 +51,8 @@ const TestHarness: FC = ({ columnVisibility={columnVisibility} paginatedTickets={paginatedTickets} emptyStateNode={emptyStateNode} + isFetching={isFetching} + paginatorNode={paginatorNode} /> ); }; @@ -143,10 +151,10 @@ describe('Component: TicketListTable', () => { expect(linkEl).toHaveAttribute('href', expect.stringContaining('achievement.show')); expect(route).toHaveBeenCalledWith('achievement.show', { achievement: 777 }); - expect(screen.getByRole('presentation')).toHaveAttribute( - 'src', - 'https://example.com/badge.png', - ); + const badgeEls = screen + .getAllByRole('presentation') + .filter((el) => el.getAttribute('src') === 'https://example.com/badge.png'); + expect(badgeEls).toHaveLength(2); }); it('given a leaderboard ticket, prefixes the title as plain text', () => { @@ -161,7 +169,7 @@ describe('Component: TicketListTable', () => { render(); // ASSERT - expect(screen.getByText('(LB) Fastest lap')).toBeVisible(); + expect(screen.getAllByText('(LB) Fastest lap')[0]).toBeVisible(); expect(screen.queryByRole('link', { name: /Fastest lap/ })).not.toBeInTheDocument(); expect(screen.queryByRole('img', { name: 'Fastest lap' })).not.toBeInTheDocument(); }); @@ -274,6 +282,43 @@ describe('Component: TicketListTable', () => { 'Issue with', 'Age', ]); - expect(screen.getByRole('img', { name: 'Open' })).toBeVisible(); + expect(screen.getAllByRole('img', { name: 'Open' })).toHaveLength(2); + }); + + it('given a new page is being fetched, marks the table busy and dims it', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('table')).toHaveClass('opacity-50'); + }); + + it('given more than one page exists, renders the given paginator element', () => { + // ARRANGE + const { rerender } = render( + } />, + ); + + // ASSERT + expect(screen.getByTestId('paginator')).toBeVisible(); + + // it should be hidden for a single page + rerender(} />); + expect(screen.queryByTestId('paginator')).not.toBeInTheDocument(); + }); + + it('given a ticket whose reporter was deleted, the mobile row omits the avatar', () => { + // ARRANGE + const ticket = createTicketListEntry({ + ticketableType: 'achievement', + ticketableBadgeUrl: null, + reporter: null, + }); + + render(); + + // ASSERT + expect(screen.queryByRole('presentation')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx index ca77f9904a..5d52f3f158 100644 --- a/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx +++ b/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx @@ -8,6 +8,7 @@ import { cn } from '@/common/utils/cn'; import { TicketListEmptyState } from '../TicketListEmptyState'; import { TicketStateGlyph } from '../TicketStateGlyph'; +import { TicketListMobileRow } from './TicketListMobileRow'; type TicketListTablePage = Pick< App.Data.PaginatedData, @@ -20,6 +21,8 @@ interface TicketListTableProps { paginatedTickets: TicketListTablePage; emptyStateNode?: ReactNode; + isFetching?: boolean; + paginatorNode?: ReactNode; } const glyphSlotClassName = 'mx-[0.6em] flex w-4 flex-none items-center justify-center'; @@ -29,6 +32,8 @@ export const TicketListTable: FC = ({ columnVisibility, paginatedTickets, emptyStateNode, + paginatorNode, + isFetching = false, }) => { const { t } = useTranslation(); @@ -58,73 +63,85 @@ export const TicketListTable: FC = ({ } return ( -
-
+ + + {paginatedTickets.lastPage > 1 ? paginatorNode : null}
); }; diff --git a/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts b/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts new file mode 100644 index 0000000000..5f1910af6b --- /dev/null +++ b/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts @@ -0,0 +1,36 @@ +import { QueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +interface UsePreloadedTicketListQueryClientProps { + pageNumber: number; + paginatedTickets: App.Data.PaginatedData; + passthroughParams: Record; + scope: App.Platform.Enums.TicketListScope; +} + +export function usePreloadedTicketListQueryClient({ + pageNumber, + paginatedTickets, + passthroughParams, + scope, +}: UsePreloadedTicketListQueryClientProps) { + const [queryClient] = useState(() => new QueryClient()); + + /** + * It's very important to memoize the queryClient. + * If we don't, the whole queryClient will be reset on every single re-render. + * From the user's perspective, it'll appear that they can never page, filter, sort, etc. + */ + useMemo(() => { + // This seed must use the exact key and payload shape the paginated + // query reads, otherwise the client refetches data it already has. + queryClient.setQueryData(['ticket-list', scope, passthroughParams, pageNumber], { + paginatedTickets, + }); + + /* eslint-disable react-compiler/react-compiler -- exhaustive-deps is intentionally constrained */ + /* eslint-disable-next-line react-hooks/exhaustive-deps -- needed for ssr */ + }, [queryClient]); + + return { queryClientWithInitialData: queryClient }; +} diff --git a/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts new file mode 100644 index 0000000000..d8995a6f82 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { route } from 'ziggy-js'; + +const ONE_MINUTE = 1 * 60 * 1000; + +interface UseTicketListPaginatedQueryProps { + pageNumber: number; + passthroughParams: Record; + scope: App.Platform.Enums.TicketListScope; + + queryClient?: QueryClient; +} + +export function useTicketListPaginatedQuery({ + pageNumber, + passthroughParams, + scope, + queryClient, +}: UseTicketListPaginatedQueryProps) { + return useQuery( + { + queryKey: ['ticket-list', scope, passthroughParams, pageNumber], + + queryFn: async () => { + const response = await axios.get<{ + paginatedTickets: App.Data.PaginatedData; + }>( + route('api.ticket.index', { + scope, + ...passthroughParams, + 'page[number]': pageNumber, + }), + ); + + return response.data; + }, + + staleTime: ONE_MINUTE, + placeholderData: keepPreviousData, + + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + queryClient, + ); +} diff --git a/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts b/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts new file mode 100644 index 0000000000..70e9664746 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { route } from 'ziggy-js'; + +const ONE_MINUTE = 1 * 60 * 1000; + +interface UseTicketListPrefetchPaginationProps { + passthroughParams: Record; + queryClient: QueryClient; + scope: App.Platform.Enums.TicketListScope; +} + +/** + * Given the user hovers over a pagination button, it is very likely they will + * wind up clicking the button. Queries are cheap, so prefetch the destination page. + */ +export function useTicketListPrefetchPagination({ + passthroughParams, + queryClient, + scope, +}: UseTicketListPrefetchPaginationProps) { + const prefetchPage = (pageNumber: number) => { + queryClient.prefetchQuery({ + queryKey: ['ticket-list', scope, passthroughParams, pageNumber], + + queryFn: async () => { + const response = await axios.get<{ + paginatedTickets: App.Data.PaginatedData; + }>( + route('api.ticket.index', { + scope, + ...passthroughParams, + 'page[number]': pageNumber, + }), + ); + + return response.data; + }, + + staleTime: ONE_MINUTE, + }); + }; + + return { prefetchPage }; +} diff --git a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts new file mode 100644 index 0000000000..89ab19ec24 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts @@ -0,0 +1,58 @@ +import { dehydrate } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import { buildTicketListPassthroughParams } from '../utils/buildTicketListPassthroughParams'; +import { usePreloadedTicketListQueryClient } from './usePreloadedTicketListQueryClient'; +import { useTicketListPaginatedQuery } from './useTicketListPaginatedQuery'; +import { useTicketListPrefetchPagination } from './useTicketListPrefetchPagination'; +import { useTicketListTableSync } from './useTicketListTableSync'; + +interface UseTicketListTableRootOptions { + paginatedTickets: App.Data.PaginatedData; + scope: App.Platform.Enums.TicketListScope; +} + +export function useTicketListTableRoot({ paginatedTickets, scope }: UseTicketListTableRootOptions) { + const { + ziggy: { query }, + } = usePageProps(); + + // temporary safeguard + const [passthroughParams] = useState(() => buildTicketListPassthroughParams(query)); + + const [pageNumber, setPageNumber] = useState(paginatedTickets.currentPage); + + const { queryClientWithInitialData } = usePreloadedTicketListQueryClient({ + pageNumber, + paginatedTickets, + passthroughParams, + scope, + }); + + useTicketListTableSync(pageNumber); + + const ticketListQuery = useTicketListPaginatedQuery({ + pageNumber, + passthroughParams, + scope, + queryClient: queryClientWithInitialData, + }); + + const { prefetchPage } = useTicketListPrefetchPagination({ + passthroughParams, + scope, + queryClient: queryClientWithInitialData, + }); + + return { + hydrationState: dehydrate(queryClientWithInitialData), + ticketListTableProps: { + isFetching: ticketListQuery.isFetching, + paginatedTickets: ticketListQuery.data?.paginatedTickets ?? paginatedTickets, + prefetchPage, + setPageNumber, + }, + }; +} diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts new file mode 100644 index 0000000000..4c13b11aa7 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts @@ -0,0 +1,112 @@ +import { renderHook } from '@/test'; + +import { useTicketListTableSync } from './useTicketListTableSync'; + +function setWindowLocation(search: string) { + Object.defineProperty(window, 'location', { + writable: true, + value: { search, pathname: '/tickets2' }, + }); +} + +describe('Hook: useTicketListTableSync', () => { + let pushStateSpy: ReturnType; + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + setWindowLocation(''); + + pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: originalLocation, + }); + pushStateSpy.mockRestore(); + }); + + it('renders without crashing', () => { + // ARRANGE + const { result } = renderHook(() => useTicketListTableSync(1)); + + // ASSERT + expect(result).toBeDefined(); + }); + + it('given it is the first render cycle, does not update URL params', () => { + // ARRANGE + renderHook(() => useTicketListTableSync(1)); + + // ASSERT + expect(pushStateSpy).not.toHaveBeenCalled(); + }); + + it('given the user advances from page 1 to page 2, updates URL params accordingly', () => { + // ARRANGE + const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { + initialProps: 1, + }); + + // ACT + rerender(3); + + // ASSERT + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + `/tickets2?${encodeURIComponent('page[number]')}=3`, + ); + }); + + it('given the user goes from page 2 to page 1, updates URL params accordingly', () => { + // ARRANGE + setWindowLocation('?page[number]=3'); + + const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { + initialProps: 3, + }); + + // ACT + rerender(1); + + // ASSERT + expect(pushStateSpy).toHaveBeenCalledWith({ inertia: true }, '', '/tickets2'); + }); + + it('given the URL contains unrelated params, leaves them untouched', () => { + // ARRANGE + setWindowLocation('?filter[status]=resolved&sort=state'); + + const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { + initialProps: 1, + }); + + // ACT + rerender(2); + + // ASSERT + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + '/tickets2?filter%5Bstatus%5D=resolved&sort=state&page%5Bnumber%5D=2', + ); + }); + + it('given the current URL params already match the serialized state, does not push a new history entry', () => { + // ARRANGE + setWindowLocation('?page%5Bnumber%5D=3'); + + const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { + initialProps: 1, + }); + + // ACT + rerender(3); + + // ASSERT + expect(pushStateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.ts new file mode 100644 index 0000000000..72cbac5cd9 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.ts @@ -0,0 +1,31 @@ +import { useUpdateEffect } from 'react-use'; + +// TODO user's persistence cookie support + +/** + * This hook is designed to keep the URL query params and + * user's persistence cookie in sync with the table state. + */ +export function useTicketListTableSync(pageNumber: number) { + useUpdateEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + + if (pageNumber > 1) { + searchParams.set('page[number]', String(pageNumber)); + } else { + searchParams.delete('page[number]'); + } + + const newUrl = Array.from(searchParams).length + ? `${window.location.pathname}?${searchParams.toString()}` + : window.location.pathname; + + const currentUrl = `${window.location.pathname}${window.location.search}`; + + if (newUrl === currentUrl) { + return; + } + + window.history.pushState({ inertia: true }, '', newUrl); + }, [pageNumber]); +} diff --git a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts new file mode 100644 index 0000000000..f2ecb686da --- /dev/null +++ b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts @@ -0,0 +1,50 @@ +import { buildTicketListPassthroughParams } from './buildTicketListPassthroughParams'; + +describe('Util: buildTicketListPassthroughParams', () => { + it('is defined', () => { + // ASSERT + expect(buildTicketListPassthroughParams).toBeDefined(); + }); + + it('given the URL carries a sort and filters, returns them as flat params', () => { + // ACT + const result = buildTicketListPassthroughParams({ + sort: 'state', + filter: { status: 'resolved', emulator: 'RetroArch' }, + }); + + // ASSERT + expect(result).toEqual({ + sort: 'state', + 'filter[status]': 'resolved', + 'filter[emulator]': 'RetroArch', + }); + }); + + it('given the URL is bare, returns no params', () => { + // ACT + const result = buildTicketListPassthroughParams({}); + + // ASSERT + expect(result).toEqual({}); + }); + + it('given empty or non-string values, drops them', () => { + // ACT + const result = buildTicketListPassthroughParams({ + sort: '', + filter: { status: '', mode: ['softcore'] as unknown as string }, + }); + + // ASSERT + expect(result).toEqual({}); + }); + + it('given the filter param is not an object, ignores it', () => { + // ACT + const result = buildTicketListPassthroughParams({ filter: 'resolved' }); + + // ASSERT + expect(result).toEqual({}); + }); +}); diff --git a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts new file mode 100644 index 0000000000..8598dfdaf1 --- /dev/null +++ b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts @@ -0,0 +1,27 @@ +import type { AppGlobalProps } from '@/common/models'; + +// temporary - this will be deleted +// there aren't any client-side controls for filtering and sorting +// if we don't have this temporary code, pagination will discard manually +// provided filter+sort params on the url + +export function buildTicketListPassthroughParams( + query: AppGlobalProps['ziggy']['query'], +): Record { + const params: Record = {}; + + if (typeof query.sort === 'string' && query.sort.length) { + params.sort = query.sort; + } + + const filterQuery = query.filter; + if (filterQuery && typeof filterQuery === 'object') { + for (const [filterKey, filterValue] of Object.entries(filterQuery)) { + if (typeof filterValue === 'string' && filterValue.length) { + params[`filter[${filterKey}]`] = filterValue; + } + } + } + + return params; +}