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 (
-
+
);
};
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 ? (
+

+ ) : 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 (
-
-
+
+
- {hasIdColumn ? null :
}
-
- {visibleColumns.map((column) => (
-
-
- {column.columnDef.meta?.t_label}
-
-
- {column.id === 'id' ? (
-
- ) : null}
-
- ))}
-
-
- {rows.map((row) => (
-
-
- {hasIdColumn ? null : (
-
-
-
- )}
+ {hasIdColumn ? null :
}
- {row.getVisibleCells().map((cell) => (
-
+ {visibleColumns.map((column) => (
+
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ {column.columnDef.meta?.t_label}
- {cell.column.id === 'id' ? (
-
-
-
+ {column.id === 'id' ? (
+
) : null}
))}
- ))}
+
+ {rows.map((row) => (
+
+
+
+
+
+
+ {hasIdColumn ? null : (
+
+
+
+ )}
+
+ {row.getVisibleCells().map((cell) => (
+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+
+ {cell.column.id === 'id' ? (
+
+
+
+ ) : null}
+
+ ))}
+
+
+ ))}
+
+
+ {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;
+}