From 6b80d81152f34218c7840bfe24d9aa1b89494a02 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 1 Jan 2025 12:18:47 -0500 Subject: [PATCH 1/2] feat: default sort All Games by player count (#2999) --- app/Platform/Controllers/GameController.php | 6 +- app/Platform/Requests/GameListRequest.php | 10 +- .../AllGamesDataTable/AllGamesDataTable.tsx | 6 +- .../AllGamesMainRoot.test.tsx | 8 +- .../AllGamesMainRoot/AllGamesMainRoot.tsx | 15 +- .../useAllGamesDefaultColumnState.ts | 26 ++ .../AllSystemGamesDataTable/index.ts | 1 - .../HubGamesDataTable/HubGamesDataTable.tsx | 6 +- .../components/HubMainRoot/HubMainRoot.tsx | 15 +- .../useHubGamesDefaultColumnState.ts | 26 ++ .../SystemGamesDataTable.tsx} | 10 +- .../components/SystemGamesDataTable/index.ts | 1 + .../useColumnDefinitions.ts | 0 .../SystemGamesMainRoot.tsx | 20 +- .../useSystemGamesDefaultColumnState.ts | 27 ++ .../WantToPlayGamesDataTable.tsx | 6 +- .../WantToPlayGamesMainRoot.tsx | 13 +- .../useWantToPlayGamesDefaultColumnState.ts | 28 ++ .../game-list/hooks/useGameListState.test.ts | 385 ++++++++++-------- .../game-list/hooks/useGameListState.ts | 23 +- .../hooks/useSystemGamesDefaultFilters.ts | 14 - .../game-list/hooks/useTableSync.test.ts | 209 ++++++++-- .../features/game-list/hooks/useTableSync.ts | 21 +- .../models/default-column-state.model.ts | 7 + .../js/features/game-list/models/index.ts | 1 + .../game-list/utils/allGamesDefaultFilters.ts | 5 - .../buildInitialDefaultColumnVisibility.ts | 12 + .../game-list/utils/hubGamesDefaultFilters.ts | 5 - .../utils/wantToPlayGamesDefaultFilters.ts | 5 - .../+root/GuestWelcomeCta/GuestWelcomeCta.tsx | 2 +- 30 files changed, 618 insertions(+), 295 deletions(-) create mode 100644 resources/js/features/game-list/components/AllGamesMainRoot/useAllGamesDefaultColumnState.ts delete mode 100644 resources/js/features/game-list/components/AllSystemGamesDataTable/index.ts create mode 100644 resources/js/features/game-list/components/HubMainRoot/useHubGamesDefaultColumnState.ts rename resources/js/features/game-list/components/{AllSystemGamesDataTable/AllSystemGamesDataTable.tsx => SystemGamesDataTable/SystemGamesDataTable.tsx} (91%) create mode 100644 resources/js/features/game-list/components/SystemGamesDataTable/index.ts rename resources/js/features/game-list/components/{AllSystemGamesDataTable => SystemGamesDataTable}/useColumnDefinitions.ts (100%) create mode 100644 resources/js/features/game-list/components/SystemGamesMainRoot/useSystemGamesDefaultColumnState.ts create mode 100644 resources/js/features/game-list/components/WantToPlayGamesMainRoot/useWantToPlayGamesDefaultColumnState.ts delete mode 100644 resources/js/features/game-list/hooks/useSystemGamesDefaultFilters.ts create mode 100644 resources/js/features/game-list/models/default-column-state.model.ts delete mode 100644 resources/js/features/game-list/utils/allGamesDefaultFilters.ts create mode 100644 resources/js/features/game-list/utils/buildInitialDefaultColumnVisibility.ts delete mode 100644 resources/js/features/game-list/utils/hubGamesDefaultFilters.ts delete mode 100644 resources/js/features/game-list/utils/wantToPlayGamesDefaultFilters.ts diff --git a/app/Platform/Controllers/GameController.php b/app/Platform/Controllers/GameController.php index 3c74f42d13..c744bb5905 100644 --- a/app/Platform/Controllers/GameController.php +++ b/app/Platform/Controllers/GameController.php @@ -14,6 +14,7 @@ use App\Platform\Actions\GetRandomGameAction; use App\Platform\Data\GameListPagePropsData; use App\Platform\Data\SystemData; +use App\Platform\Enums\GameListSortField; use App\Platform\Enums\GameListType; use App\Platform\Requests\GameListRequest; use App\Platform\Requests\GameRequest; @@ -46,7 +47,10 @@ public function index(GameListRequest $request): InertiaResponse GameListType::AllGames, user: $user, filters: $request->getFilters(), - sort: $request->getSort(), + sort: $request->getSort( + defaultSortField: GameListSortField::PlayersTotal, + isDefaultSortAsc: false, + ), perPage: $isMobile ? 100 : $request->getPageSize(), /** diff --git a/app/Platform/Requests/GameListRequest.php b/app/Platform/Requests/GameListRequest.php index 02810d7350..0abc1e701d 100644 --- a/app/Platform/Requests/GameListRequest.php +++ b/app/Platform/Requests/GameListRequest.php @@ -85,8 +85,10 @@ public function getPageSize(): int /** * @return array{field: string, direction: 'asc'|'desc'} */ - public function getSort(): array - { + public function getSort( + GameListSortField $defaultSortField = GameListSortField::Title, + bool $isDefaultSortAsc = true, + ): array { // URL params take precedence over cookie preferences. $sortParam = $this->input('sort'); @@ -102,9 +104,9 @@ public function getSort(): array } // If we still don't have a sort param, fall back to sorting by title. - $sortParam ??= GameListSortField::Title->value; + $sortParam ??= $defaultSortField->value; - $sortDirection = 'asc'; + $sortDirection = $isDefaultSortAsc ? 'asc' : 'desc'; if (str_starts_with($sortParam, '-')) { $sortDirection = 'desc'; $sortParam = ltrim($sortParam, '-'); diff --git a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx index 9b174173a5..862add3ee1 100644 --- a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx @@ -11,7 +11,7 @@ import { type Dispatch, type FC, lazy, type SetStateAction, Suspense } from 'rea import { usePageProps } from '@/common/hooks/usePageProps'; import { useGameListPaginatedQuery } from '@/features/game-list/hooks/useGameListPaginatedQuery'; -import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; +import { useAllGamesDefaultColumnState } from '../AllGamesMainRoot/useAllGamesDefaultColumnState'; import { DataTablePagination } from '../DataTablePagination'; import { DataTableToolbar } from '../DataTableToolbar'; import { GameListDataTable } from '../GameListDataTable'; @@ -44,6 +44,8 @@ export const AllGamesDataTable: FC = ({ }) => { const { can, ziggy } = usePageProps(); + const { defaultColumnFilters } = useAllGamesDefaultColumnState(); + const gameListQuery = useGameListPaginatedQuery({ columnFilters, pagination, @@ -82,7 +84,7 @@ export const AllGamesDataTable: FC = ({ {ziggy.device === 'mobile' ? ( diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx index e059893474..464ad038b7 100644 --- a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx +++ b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx @@ -353,7 +353,7 @@ describe('Component: AllGamesMainRoot', () => { 'filter[title]': 'dragon quest', 'page[number]': 1, 'page[size]': 25, - sort: 'title', + sort: '-playersTotal', }, ]); }); @@ -409,7 +409,7 @@ describe('Component: AllGamesMainRoot', () => { 'filter[system]': '1', 'page[number]': 1, 'page[size]': 25, - sort: 'title', + sort: '-playersTotal', }, ]); }); @@ -507,7 +507,7 @@ describe('Component: AllGamesMainRoot', () => { 'filter[achievementsPublished]': 'none', 'page[number]': 1, 'page[size]': 25, - sort: 'title', + sort: '-playersTotal', }, ]); }); @@ -716,7 +716,7 @@ describe('Component: AllGamesMainRoot', () => { 'filter[achievementsPublished]': 'has', 'page[number]': 2, 'page[size]': 50, - sort: 'title', + sort: '-playersTotal', }, ]); }); diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx index 4e2a49d7d2..80c22ec588 100644 --- a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx +++ b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx @@ -9,16 +9,19 @@ import { useGameListState } from '../../hooks/useGameListState'; import { usePreloadedTableDataQueryClient } from '../../hooks/usePreloadedTableDataQueryClient'; import { useTableSync } from '../../hooks/useTableSync'; import { isCurrentlyPersistingViewAtom } from '../../state/game-list.atoms'; -import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; import { AllGamesDataTable } from '../AllGamesDataTable'; import { DataTablePaginationScrollTarget } from '../DataTablePaginationScrollTarget'; +import { useAllGamesDefaultColumnState } from './useAllGamesDefaultColumnState'; export const AllGamesMainRoot: FC = memo(() => { - const { auth, defaultDesktopPageSize, paginatedGameListEntries } = + const { defaultDesktopPageSize, paginatedGameListEntries } = usePageProps(); const { t } = useTranslation(); + const { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility } = + useAllGamesDefaultColumnState(); + const { columnFilters, columnVisibility, @@ -29,8 +32,9 @@ export const AllGamesMainRoot: FC = memo(() => { setSorting, sorting, } = useGameListState(paginatedGameListEntries, { - canShowProgressColumn: !!auth?.user, - defaultColumnFilters: allGamesDefaultFilters, + defaultColumnSort, + defaultColumnFilters, + defaultColumnVisibility, }); const { queryClientWithInitialData } = usePreloadedTableDataQueryClient({ @@ -45,9 +49,10 @@ export const AllGamesMainRoot: FC = memo(() => { useTableSync({ columnFilters, columnVisibility, + defaultColumnFilters, + defaultColumnSort, pagination, sorting, - defaultFilters: allGamesDefaultFilters, defaultPageSize: defaultDesktopPageSize, isUserPersistenceEnabled: isCurrentlyPersistingView, }); diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/useAllGamesDefaultColumnState.ts b/resources/js/features/game-list/components/AllGamesMainRoot/useAllGamesDefaultColumnState.ts new file mode 100644 index 0000000000..2c91ff6c0e --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesMainRoot/useAllGamesDefaultColumnState.ts @@ -0,0 +1,26 @@ +import type { ColumnFiltersState, ColumnSort } from '@tanstack/react-table'; +import { useMemo } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import type { DefaultColumnState } from '../../models'; +import { buildInitialDefaultColumnVisibility } from '../../utils/buildInitialDefaultColumnVisibility'; + +export function useAllGamesDefaultColumnState(): DefaultColumnState { + const { auth } = usePageProps(); + + return useMemo(() => { + const defaultColumnFilters: ColumnFiltersState = [ + { id: 'achievementsPublished', value: ['has'] }, + ]; + + const defaultColumnSort: ColumnSort = { id: 'playersTotal', desc: true }; + + const defaultColumnVisibility: Partial> = + { + ...buildInitialDefaultColumnVisibility(!!auth?.user), + }; + + return { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility }; + }, [auth?.user]); +} diff --git a/resources/js/features/game-list/components/AllSystemGamesDataTable/index.ts b/resources/js/features/game-list/components/AllSystemGamesDataTable/index.ts deleted file mode 100644 index 5a236616cd..0000000000 --- a/resources/js/features/game-list/components/AllSystemGamesDataTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './AllSystemGamesDataTable'; diff --git a/resources/js/features/game-list/components/HubGamesDataTable/HubGamesDataTable.tsx b/resources/js/features/game-list/components/HubGamesDataTable/HubGamesDataTable.tsx index 7449c9867e..9d3b36f988 100644 --- a/resources/js/features/game-list/components/HubGamesDataTable/HubGamesDataTable.tsx +++ b/resources/js/features/game-list/components/HubGamesDataTable/HubGamesDataTable.tsx @@ -11,11 +11,11 @@ import { type Dispatch, type FC, lazy, type SetStateAction, Suspense } from 'rea import { usePageProps } from '@/common/hooks/usePageProps'; import { useGameListPaginatedQuery } from '@/features/game-list/hooks/useGameListPaginatedQuery'; -import { hubGamesDefaultFilters } from '../../utils/hubGamesDefaultFilters'; import { DataTablePagination } from '../DataTablePagination'; import { DataTableToolbar } from '../DataTableToolbar'; import { GameListDataTable } from '../GameListDataTable'; import { GameListItemsSuspenseFallback } from '../GameListItems/GameListItemsSuspenseFallback'; +import { useHubGamesDefaultColumnState } from '../HubMainRoot/useHubGamesDefaultColumnState'; import { useColumnDefinitions } from './useColumnDefinitions'; const GameListItems = lazy(() => import('../GameListItems')); @@ -44,6 +44,8 @@ export const HubGamesDataTable: FC = ({ }) => { const { can, hub, ziggy } = usePageProps(); + const { defaultColumnFilters } = useHubGamesDefaultColumnState(); + const gameListQuery = useGameListPaginatedQuery({ columnFilters, pagination, @@ -84,7 +86,7 @@ export const HubGamesDataTable: FC = ({ { - const { auth, breadcrumbs, defaultDesktopPageSize, hub, paginatedGameListEntries } = + const { breadcrumbs, defaultDesktopPageSize, hub, paginatedGameListEntries } = usePageProps(); + const { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility } = + useHubGamesDefaultColumnState(); + const { columnFilters, columnVisibility, @@ -30,8 +33,9 @@ export const HubMainRoot: FC = memo(() => { setSorting, sorting, } = useGameListState(paginatedGameListEntries, { - canShowProgressColumn: !!auth?.user, - defaultColumnFilters: hubGamesDefaultFilters, + defaultColumnSort, + defaultColumnFilters, + defaultColumnVisibility, }); const { queryClientWithInitialData } = usePreloadedTableDataQueryClient({ @@ -46,9 +50,10 @@ export const HubMainRoot: FC = memo(() => { useTableSync({ columnFilters, columnVisibility, + defaultColumnFilters, + defaultColumnSort, pagination, sorting, - defaultFilters: hubGamesDefaultFilters, defaultPageSize: defaultDesktopPageSize, isUserPersistenceEnabled: isCurrentlyPersistingView, }); diff --git a/resources/js/features/game-list/components/HubMainRoot/useHubGamesDefaultColumnState.ts b/resources/js/features/game-list/components/HubMainRoot/useHubGamesDefaultColumnState.ts new file mode 100644 index 0000000000..356865559e --- /dev/null +++ b/resources/js/features/game-list/components/HubMainRoot/useHubGamesDefaultColumnState.ts @@ -0,0 +1,26 @@ +import type { ColumnFiltersState, ColumnSort } from '@tanstack/react-table'; +import { useMemo } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import type { DefaultColumnState } from '../../models'; +import { buildInitialDefaultColumnVisibility } from '../../utils/buildInitialDefaultColumnVisibility'; + +export function useHubGamesDefaultColumnState(): DefaultColumnState { + const { auth } = usePageProps(); + + return useMemo(() => { + const defaultColumnFilters: ColumnFiltersState = [ + { id: 'achievementsPublished', value: ['either'] }, + ]; + + const defaultColumnSort: ColumnSort = { id: 'title', desc: false }; + + const defaultColumnVisibility: Partial> = + { + ...buildInitialDefaultColumnVisibility(!!auth?.user), + }; + + return { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility }; + }, [auth?.user]); +} diff --git a/resources/js/features/game-list/components/AllSystemGamesDataTable/AllSystemGamesDataTable.tsx b/resources/js/features/game-list/components/SystemGamesDataTable/SystemGamesDataTable.tsx similarity index 91% rename from resources/js/features/game-list/components/AllSystemGamesDataTable/AllSystemGamesDataTable.tsx rename to resources/js/features/game-list/components/SystemGamesDataTable/SystemGamesDataTable.tsx index 7665f9d8a6..6de5bc43b5 100644 --- a/resources/js/features/game-list/components/AllSystemGamesDataTable/AllSystemGamesDataTable.tsx +++ b/resources/js/features/game-list/components/SystemGamesDataTable/SystemGamesDataTable.tsx @@ -11,17 +11,17 @@ import { type Dispatch, type FC, lazy, type SetStateAction, Suspense } from 'rea import { usePageProps } from '@/common/hooks/usePageProps'; import { useGameListPaginatedQuery } from '@/features/game-list/hooks/useGameListPaginatedQuery'; -import { useSystemGamesDefaultFilters } from '../../hooks/useSystemGamesDefaultFilters'; import { DataTablePagination } from '../DataTablePagination'; import { DataTableToolbar } from '../DataTableToolbar'; import { GameListDataTable } from '../GameListDataTable'; import { GameListItemsSuspenseFallback } from '../GameListItems/GameListItemsSuspenseFallback'; +import { useSystemGamesDefaultColumnState } from '../SystemGamesMainRoot/useSystemGamesDefaultColumnState'; import { useColumnDefinitions } from './useColumnDefinitions'; const GameListItems = lazy(() => import('../GameListItems')); // These values are all generated from `useGameListState`. -interface AllSystemGamesDataTableProps { +interface SystemGamesDataTableProps { columnFilters: ColumnFiltersState; columnVisibility: VisibilityState; pagination: PaginationState; @@ -32,7 +32,7 @@ interface AllSystemGamesDataTableProps { sorting: SortingState; } -export const AllSystemGamesDataTable: FC = ({ +export const SystemGamesDataTable: FC = ({ columnFilters, columnVisibility, pagination, @@ -44,7 +44,7 @@ export const AllSystemGamesDataTable: FC = ({ }) => { const { can, system, ziggy } = usePageProps(); - const { systemGamesDefaultFilters } = useSystemGamesDefaultFilters(); + const { defaultColumnFilters } = useSystemGamesDefaultColumnState(); const gameListQuery = useGameListPaginatedQuery({ columnFilters, @@ -86,7 +86,7 @@ export const AllSystemGamesDataTable: FC = ({ { - const { auth, defaultDesktopPageSize, system, paginatedGameListEntries } = + const { defaultDesktopPageSize, system, paginatedGameListEntries } = usePageProps(); const { t } = useTranslation(); - const { systemGamesDefaultFilters } = useSystemGamesDefaultFilters(); + const { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility } = + useSystemGamesDefaultColumnState(); const { columnFilters, @@ -31,9 +32,9 @@ export const SystemGamesMainRoot: FC = memo(() => { setSorting, sorting, } = useGameListState(paginatedGameListEntries, { - alwaysShowPlayersTotal: true, - canShowProgressColumn: !!auth?.user, - defaultColumnFilters: systemGamesDefaultFilters, + defaultColumnSort, + defaultColumnFilters, + defaultColumnVisibility, }); const { queryClientWithInitialData } = usePreloadedTableDataQueryClient({ @@ -48,9 +49,10 @@ export const SystemGamesMainRoot: FC = memo(() => { useTableSync({ columnFilters, columnVisibility, + defaultColumnFilters, + defaultColumnSort, pagination, sorting, - defaultFilters: systemGamesDefaultFilters, defaultPageSize: defaultDesktopPageSize, isUserPersistenceEnabled: isCurrentlyPersistingView, }); @@ -67,7 +69,7 @@ export const SystemGamesMainRoot: FC = memo(() => { - (); + + return useMemo(() => { + const defaultColumnFilters: ColumnFiltersState = [ + { id: 'system', value: [system.id] }, + { id: 'achievementsPublished', value: ['has'] }, + ]; + + const defaultColumnSort: ColumnSort = { id: 'title', desc: false }; + + const defaultColumnVisibility: Partial> = + { + ...buildInitialDefaultColumnVisibility(!!auth?.user), + }; + + return { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility }; + }, [auth?.user, system.id]); +} diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx index 1148f399d9..59de17f3f7 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx @@ -11,11 +11,11 @@ import { type Dispatch, type FC, lazy, type SetStateAction, Suspense } from 'rea import { usePageProps } from '@/common/hooks/usePageProps'; import { useGameListPaginatedQuery } from '@/features/game-list/hooks/useGameListPaginatedQuery'; -import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; import { DataTablePagination } from '../DataTablePagination'; import { DataTableToolbar } from '../DataTableToolbar'; import { GameListDataTable } from '../GameListDataTable'; import { GameListItemsSuspenseFallback } from '../GameListItems/GameListItemsSuspenseFallback'; +import { useWantToPlayGamesDefaultColumnState } from '../WantToPlayGamesMainRoot/useWantToPlayGamesDefaultColumnState'; import { useColumnDefinitions } from './useColumnDefinitions'; const GameListItems = lazy(() => import('../GameListItems')); @@ -44,6 +44,8 @@ export const WantToPlayGamesDataTable: FC = ({ }) => { const { can, ziggy } = usePageProps(); + const { defaultColumnFilters } = useWantToPlayGamesDefaultColumnState(); + const gameListQuery = useGameListPaginatedQuery({ columnFilters, pagination, @@ -83,7 +85,7 @@ export const WantToPlayGamesDataTable: FC = ({ diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx index 3fd8928e9c..e06f2ec73d 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx @@ -10,9 +10,9 @@ import { useGameListState } from '../../hooks/useGameListState'; import { usePreloadedTableDataQueryClient } from '../../hooks/usePreloadedTableDataQueryClient'; import { useTableSync } from '../../hooks/useTableSync'; import { isCurrentlyPersistingViewAtom } from '../../state/game-list.atoms'; -import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; import { DataTablePaginationScrollTarget } from '../DataTablePaginationScrollTarget'; import { WantToPlayGamesDataTable } from '../WantToPlayGamesDataTable'; +import { useWantToPlayGamesDefaultColumnState } from './useWantToPlayGamesDefaultColumnState'; export const WantToPlayGamesMainRoot: FC = memo(() => { const { auth, defaultDesktopPageSize, paginatedGameListEntries } = @@ -20,6 +20,9 @@ export const WantToPlayGamesMainRoot: FC = memo(() => { const { t } = useTranslation(); + const { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility } = + useWantToPlayGamesDefaultColumnState(); + const { columnFilters, columnVisibility, @@ -30,8 +33,9 @@ export const WantToPlayGamesMainRoot: FC = memo(() => { setSorting, sorting, } = useGameListState(paginatedGameListEntries, { - canShowProgressColumn: true, - defaultColumnFilters: wantToPlayGamesDefaultFilters, + defaultColumnSort, + defaultColumnFilters, + defaultColumnVisibility, }); const { queryClientWithInitialData } = usePreloadedTableDataQueryClient({ @@ -46,9 +50,10 @@ export const WantToPlayGamesMainRoot: FC = memo(() => { useTableSync({ columnFilters, columnVisibility, + defaultColumnFilters, + defaultColumnSort, pagination, sorting, - defaultFilters: wantToPlayGamesDefaultFilters, defaultPageSize: defaultDesktopPageSize, isUserPersistenceEnabled: isCurrentlyPersistingView, }); diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/useWantToPlayGamesDefaultColumnState.ts b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/useWantToPlayGamesDefaultColumnState.ts new file mode 100644 index 0000000000..1c94dabfca --- /dev/null +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/useWantToPlayGamesDefaultColumnState.ts @@ -0,0 +1,28 @@ +import type { ColumnFiltersState, ColumnSort } from '@tanstack/react-table'; +import { useMemo } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import type { DefaultColumnState } from '../../models'; +import { buildInitialDefaultColumnVisibility } from '../../utils/buildInitialDefaultColumnVisibility'; + +export function useWantToPlayGamesDefaultColumnState(): DefaultColumnState { + const { auth } = usePageProps(); + + return useMemo(() => { + const defaultColumnFilters: ColumnFiltersState = [ + { id: 'achievementsPublished', value: ['has'] }, + ]; + + const defaultColumnSort: ColumnSort = { id: 'title', desc: false }; + + const defaultColumnVisibility: Partial> = + { + ...buildInitialDefaultColumnVisibility(!!auth?.user), + progress: true, + playersTotal: false, + }; + + return { defaultColumnFilters, defaultColumnSort, defaultColumnVisibility }; + }, [auth?.user]); +} diff --git a/resources/js/features/game-list/hooks/useGameListState.test.ts b/resources/js/features/game-list/hooks/useGameListState.test.ts index f22f82c232..f3ebcada10 100644 --- a/resources/js/features/game-list/hooks/useGameListState.test.ts +++ b/resources/js/features/game-list/hooks/useGameListState.test.ts @@ -1,3 +1,5 @@ +import type { VisibilityState } from '@tanstack/react-table'; + import { renderHook } from '@/test'; import { createPaginatedData, createZiggyProps } from '@/test/factories'; @@ -6,14 +8,11 @@ import { useGameListState } from './useGameListState'; describe('Hook: useGameListState', () => { it('renders without crashing', () => { // ARRANGE - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result).toBeDefined(); @@ -23,15 +22,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); @@ -41,15 +37,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'title', desc: false }]); @@ -59,15 +52,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps({ query: { sort: 'system' } }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps({ query: { sort: 'system' } }), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'system', desc: false }]); @@ -77,15 +67,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps({ query: [] as any }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps({ query: [] as any }), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'title', desc: false }]); @@ -95,15 +82,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps({ query: { sort: '-title' } }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps({ query: { sort: '-title' } }), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'title', desc: true }]); @@ -113,15 +97,12 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result.current.columnFilters).toEqual([]); @@ -131,19 +112,16 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps({ - query: { - filter: { system: '1,5' }, - }, - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps({ + query: { + filter: { system: '1,5' }, + }, + }), }, - ); + }); // ASSERT expect(result.current.columnFilters).toEqual([{ id: 'system', value: ['1', '5'] }]); @@ -153,19 +131,16 @@ describe('Hook: useGameListState', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - ziggy: createZiggyProps({ - query: { - filter: { system: '1', achievementsPublished: 'has' }, - }, - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + ziggy: createZiggyProps({ + query: { + filter: { system: '1', achievementsPublished: 'has' }, + }, + }), }, - ); + }); // ASSERT expect(result.current.columnFilters).toEqual([ @@ -181,7 +156,6 @@ describe('Hook: useGameListState', () => { const { result } = renderHook( () => useGameListState(createPaginatedData([]), { - canShowProgressColumn: true, defaultColumnFilters: [{ id: 'system', value: ['10'] }], }), { @@ -203,7 +177,6 @@ describe('Hook: useGameListState', () => { const { result } = renderHook( () => useGameListState(createPaginatedData([]), { - canShowProgressColumn: true, defaultColumnFilters: [{ id: 'system', value: ['10'] }], }), { @@ -222,10 +195,13 @@ describe('Hook: useGameListState', () => { expect(result.current.columnFilters).toEqual([{ id: 'system', value: ['1'] }]); }); - it('given the canShowProgressColumn option is truthy, enables progress column visibility by default', () => { + it('given the progress column should be visible by default, enables progress column visibility by default', () => { // ARRANGE const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), + () => + useGameListState(createPaginatedData([]), { + defaultColumnVisibility: { progress: true }, + }), { pageProps: { ziggy: createZiggyProps(), @@ -238,22 +214,6 @@ describe('Hook: useGameListState', () => { expect(result.current.columnVisibility.playersTotal).toBeFalsy(); }); - it('given the canShowProgressColumn option is not truthy, enables players total column visibility by default', () => { - // ARRANGE - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: false }), - { - pageProps: { - ziggy: createZiggyProps(), - }, - }, - ); - - // ASSERT - expect(result.current.columnVisibility.progress).toBeFalsy(); - expect(result.current.columnVisibility.playersTotal).toBeTruthy(); - }); - it('given persisted view preferences exist, uses those for initial pagination state', () => { // ARRANGE const paginatedGames = createPaginatedData([], { currentPage: 1, perPage: 25 }); @@ -261,16 +221,13 @@ describe('Hook: useGameListState', () => { pagination: { pageIndex: 2, pageSize: 50 }, }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - initialProps: paginatedGames, - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + initialProps: paginatedGames, + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 50 }); @@ -282,17 +239,14 @@ describe('Hook: useGameListState', () => { sorting: [{ id: 'lastUpdated', desc: true }], }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps({ - query: {}, // !! - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps({ + query: {}, // !! + }), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'lastUpdated', desc: true }]); @@ -304,19 +258,16 @@ describe('Hook: useGameListState', () => { sorting: [{ id: 'lastUpdated', desc: true }], }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps({ - query: { - sort: '-system', // !! - }, - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps({ + query: { + sort: '-system', // !! + }, + }), }, - ); + }); // ASSERT expect(result.current.sorting).toEqual([{ id: 'system', desc: true }]); @@ -335,15 +286,12 @@ describe('Hook: useGameListState', () => { }, }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps(), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps(), }, - ); + }); // ASSERT expect(result.current.columnVisibility).toEqual(persistedViewPreferences.columnVisibility); @@ -358,17 +306,14 @@ describe('Hook: useGameListState', () => { ], }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps({ - query: {}, - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps({ + query: {}, + }), }, - ); + }); // ASSERT expect(result.current.columnFilters).toEqual([ @@ -380,7 +325,12 @@ describe('Hook: useGameListState', () => { it('given persisted view preferences exist but are null, falls back to defaults', () => { // ARRANGE const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), + () => + useGameListState(createPaginatedData([]), { + defaultColumnSort: { id: 'title', desc: false }, + defaultColumnFilters: [{ id: 'achievementsPublished', value: ['has'] }], + defaultColumnVisibility: { playersTotal: true }, + }), { pageProps: { persistedViewPreferences: null, @@ -391,15 +341,8 @@ describe('Hook: useGameListState', () => { // ASSERT expect(result.current.sorting).toEqual([{ id: 'title', desc: false }]); - expect(result.current.columnFilters).toEqual([]); - expect(result.current.columnVisibility).toEqual({ - hasActiveOrInReviewClaims: false, - lastUpdated: false, - numUnresolvedTickets: false, - numVisibleLeaderboards: false, - playersTotal: false, - progress: true, - }); + expect(result.current.columnFilters).toEqual([{ id: 'achievementsPublished', value: ['has'] }]); + expect(result.current.columnVisibility).toEqual({ playersTotal: true }); }); it('given both query params and persisted preferences exist, query params should take precedence for all state', () => { @@ -410,21 +353,18 @@ describe('Hook: useGameListState', () => { columnFilters: [{ id: 'system', value: ['3', '4'] }], }; - const { result } = renderHook( - () => useGameListState(createPaginatedData([]), { canShowProgressColumn: true }), - { - pageProps: { - persistedViewPreferences, - ziggy: createZiggyProps({ - query: { - page: '1', // !! - sort: '-title', // !! - filter: { system: '1,2' }, // !! - }, - }), - }, + const { result } = renderHook(() => useGameListState(createPaginatedData([]), {}), { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps({ + query: { + page: '1', // !! + sort: '-title', // !! + filter: { system: '1,2' }, // !! + }, + }), }, - ); + }); // ASSERT - these values come from query params!! expect(result.current.sorting).toEqual([{ id: 'title', desc: true }]); @@ -438,7 +378,6 @@ describe('Hook: useGameListState', () => { const { result } = renderHook( () => useGameListState(createPaginatedData([]), { - canShowProgressColumn: true, defaultColumnFilters: [ { id: 'status', value: ['active'] }, // !! different id than URL param ], @@ -461,4 +400,104 @@ describe('Hook: useGameListState', () => { { id: 'status', value: ['active'] }, ]); }); + + it('given a defaultColumnSort option, uses it when no other sort preferences exist', () => { + // ARRANGE + const defaultSort = { id: 'lastUpdated', desc: true }; + + const { result } = renderHook( + () => + useGameListState(createPaginatedData([]), { + defaultColumnSort: defaultSort, + }), + { + pageProps: { + ziggy: createZiggyProps(), + }, + }, + ); + + // ASSERT + expect(result.current.sorting).toEqual([defaultSort]); + }); + + it('given both defaultColumnSort and query params, query params take precedence', () => { + // ARRANGE + const defaultSort = { id: 'lastUpdated', desc: true }; + + const { result } = renderHook( + () => + useGameListState(createPaginatedData([]), { + defaultColumnSort: defaultSort, + }), + { + pageProps: { + ziggy: createZiggyProps({ + query: { sort: 'title' }, + }), + }, + }, + ); + + // ASSERT + expect(result.current.sorting).toEqual([{ id: 'title', desc: false }]); + }); + + it('given multiple defaultColumnVisibility values, correctly initializes all visibility states', () => { + // ARRANGE + const defaultVisibility: VisibilityState = { + lastUpdated: true, + numUnresolvedTickets: true, + progress: false, + }; + + const { result } = renderHook( + () => + useGameListState(createPaginatedData([]), { + defaultColumnVisibility: defaultVisibility, + }), + { + pageProps: { + ziggy: createZiggyProps(), + }, + }, + ); + + // ASSERT + expect(result.current.columnVisibility).toEqual(defaultVisibility); + }); + + it('given persisted preferences override only some defaultColumnVisibility values, maintains non-overridden defaults', () => { + // ARRANGE + const defaultVisibility = { + lastUpdated: true, + numUnresolvedTickets: true, + progress: false, + }; + + const persistedPreferences = { + columnVisibility: { + lastUpdated: false, // !! only override one value. + }, + }; + + const { result } = renderHook( + () => + useGameListState(createPaginatedData([]), { + defaultColumnVisibility: defaultVisibility, + }), + { + pageProps: { + persistedViewPreferences: persistedPreferences, + ziggy: createZiggyProps(), + }, + }, + ); + + // ASSERT + expect(result.current.columnVisibility).toEqual({ + ...defaultVisibility, + ...persistedPreferences.columnVisibility, + }); + }); }); diff --git a/resources/js/features/game-list/hooks/useGameListState.ts b/resources/js/features/game-list/hooks/useGameListState.ts index 5cf6d299ab..8befbd7aa8 100644 --- a/resources/js/features/game-list/hooks/useGameListState.ts +++ b/resources/js/features/game-list/hooks/useGameListState.ts @@ -1,5 +1,6 @@ import type { ColumnFiltersState, + ColumnSort, PaginationState, SortingState, TableState, @@ -19,15 +20,9 @@ import type { AppGlobalProps } from '@/common/models'; export function useGameListState( paginatedGames: App.Data.PaginatedData, options: { - /** - * Should be set to truthy if the user is authenticated. - * If the user is not authenticated, the player count column will - * be shown instead. - */ - canShowProgressColumn: boolean; - - alwaysShowPlayersTotal?: boolean; defaultColumnFilters?: ColumnFiltersState; + defaultColumnSort?: ColumnSort; + defaultColumnVisibility?: Partial>; }, ) { const { @@ -40,16 +35,11 @@ export function useGameListState( ); const [sorting, setSorting] = useState( - generateInitialSortingState(query, persistedViewPreferences), + generateInitialSortingState(query, persistedViewPreferences, options?.defaultColumnSort), ); const [columnVisibility, setColumnVisibility] = useState({ - hasActiveOrInReviewClaims: false, - lastUpdated: false, - numUnresolvedTickets: false, - numVisibleLeaderboards: false, - playersTotal: options?.alwaysShowPlayersTotal ?? !options.canShowProgressColumn, - progress: options.canShowProgressColumn, + ...options.defaultColumnVisibility, ...(persistedViewPreferences?.columnVisibility ?? null), }); @@ -99,6 +89,7 @@ function generateInitialPaginationState( function generateInitialSortingState( query: AppGlobalProps['ziggy']['query'], persistedViewPreferences: Partial | null, + defaultColumnSort?: ColumnSort, ): SortingState { // `sort` is actually part of `query`'s prototype, so we have to be // extra explicit in how we check for the presence of the param. @@ -110,7 +101,7 @@ function generateInitialSortingState( return persistedViewPreferences.sorting; } - return [{ id: 'title', desc: false }]; + return defaultColumnSort ? [defaultColumnSort] : [{ id: 'title', desc: false }]; } function mapPaginatedGamesToPaginationState( diff --git a/resources/js/features/game-list/hooks/useSystemGamesDefaultFilters.ts b/resources/js/features/game-list/hooks/useSystemGamesDefaultFilters.ts deleted file mode 100644 index 60363d560e..0000000000 --- a/resources/js/features/game-list/hooks/useSystemGamesDefaultFilters.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; - -import { usePageProps } from '@/common/hooks/usePageProps'; - -export function useSystemGamesDefaultFilters() { - const { system } = usePageProps(); - - const systemGamesDefaultFilters: ColumnFiltersState = [ - { id: 'system', value: [system.id] }, - { id: 'achievementsPublished', value: ['has'] }, - ]; - - return { systemGamesDefaultFilters }; -} diff --git a/resources/js/features/game-list/hooks/useTableSync.test.ts b/resources/js/features/game-list/hooks/useTableSync.test.ts index 2fb50059c4..ea6cfa082c 100644 --- a/resources/js/features/game-list/hooks/useTableSync.test.ts +++ b/resources/js/features/game-list/hooks/useTableSync.test.ts @@ -7,11 +7,13 @@ import type { import { renderHook } from '@/test'; -import { allGamesDefaultFilters } from '../utils/allGamesDefaultFilters'; import { useTableSync } from './useTableSync'; +const defaultColumnFilters: ColumnFiltersState = [{ id: 'achievementsPublished', value: ['has'] }]; + describe('Hook: useTableSync', () => { let replaceStateSpy: ReturnType; + let cookieSpy: ReturnType; let originalLocation: Location; beforeEach(() => { @@ -26,12 +28,15 @@ describe('Hook: useTableSync', () => { // Mock the history.replaceState function. replaceStateSpy = vi.spyOn(window.history, 'replaceState').mockImplementation(vi.fn()) as any; + + // Mock document.cookie for persistence tests. + cookieSpy = vi.spyOn(document, 'cookie', 'set'); }); afterEach(() => { - // Restore the location and history values. window.location = originalLocation; replaceStateSpy.mockRestore(); + cookieSpy.mockRestore(); }); it('renders without crashing', () => { @@ -47,7 +52,7 @@ describe('Hook: useTableSync', () => { columnVisibility, pagination, sorting, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }), ); @@ -68,7 +73,7 @@ describe('Hook: useTableSync', () => { columnVisibility, pagination, sorting, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }), ); @@ -83,7 +88,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -92,7 +97,7 @@ describe('Hook: useTableSync', () => { columnFilters, sorting, pagination: updatedPagination, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -106,7 +111,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -115,7 +120,7 @@ describe('Hook: useTableSync', () => { columnFilters, sorting, pagination: updatedPagination, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -129,7 +134,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -138,7 +143,7 @@ describe('Hook: useTableSync', () => { columnFilters, pagination, sorting: updatedSorting, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -152,7 +157,7 @@ describe('Hook: useTableSync', () => { const sorting = [{ id: 'system', desc: true }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -161,7 +166,7 @@ describe('Hook: useTableSync', () => { columnFilters, pagination, sorting: updatedSorting, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -175,7 +180,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -187,7 +192,7 @@ describe('Hook: useTableSync', () => { pagination, sorting, columnFilters: updatedFilters, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -201,7 +206,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -213,7 +218,7 @@ describe('Hook: useTableSync', () => { pagination, sorting, columnFilters: updatedFilters, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -235,7 +240,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -244,7 +249,7 @@ describe('Hook: useTableSync', () => { pagination, sorting, columnFilters: updatedFilters, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -284,7 +289,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -293,7 +298,7 @@ describe('Hook: useTableSync', () => { pagination, sorting, columnFilters: updatedFilters, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -310,7 +315,7 @@ describe('Hook: useTableSync', () => { const sorting: SortingState = [{ id: 'title', desc: false }]; const { rerender } = renderHook((props: any) => useTableSync(props), { - initialProps: { columnFilters, pagination, sorting, defaultFilters: allGamesDefaultFilters }, + initialProps: { columnFilters, pagination, sorting, defaultColumnFilters }, }); // ACT @@ -322,7 +327,7 @@ describe('Hook: useTableSync', () => { pagination, sorting, columnFilters: updatedFilters, - defaultFilters: allGamesDefaultFilters, + defaultColumnFilters, }); // ASSERT @@ -337,14 +342,14 @@ describe('Hook: useTableSync', () => { ]; const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; const sorting: SortingState = [{ id: 'title', desc: false }]; - const defaultFilters: ColumnFiltersState = [{ id: 'title', value: 'default-value' }]; + const defaultColumnFilters: ColumnFiltersState = [{ id: 'title', value: 'default-value' }]; const { rerender } = renderHook((props: any) => useTableSync(props), { initialProps: { columnFilters, pagination, sorting, - defaultFilters, + defaultColumnFilters, }, }); @@ -354,7 +359,7 @@ describe('Hook: useTableSync', () => { columnFilters: updatedColumnFilters, pagination, sorting, - defaultFilters, + defaultColumnFilters, }); // ASSERT @@ -559,4 +564,158 @@ describe('Hook: useTableSync', () => { const lastCall = setCookieSpy.mock.calls[setCookieSpy.mock.calls.length - 1][0]; expect(lastCall).toContain(`${cookieName}=;`); }); + + it('given user persistence is enabled and there is a title filter, excludes the title filter from cookie persistence', () => { + // ARRANGE + const cookieName = 'test_cookie_name'; + const setCookieSpy = vi.spyOn(document, 'cookie', 'set'); + + const columnFilters: ColumnFiltersState = [ + { id: 'system', value: '1' }, + { id: 'title', value: 'mario' }, // !! this should be excluded + ]; + const columnVisibility: VisibilityState = {}; + const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; + const sorting: SortingState = [{ id: 'title', desc: false }]; + + const { rerender } = renderHook((props: any) => useTableSync(props), { + initialProps: { + columnFilters, + columnVisibility, + pagination, + sorting, + isUserPersistenceEnabled: true, + }, + pageProps: { + persistenceCookieName: cookieName, + }, + }); + + // ACT + const updatedFilters = [...columnFilters]; + rerender({ + columnFilters: updatedFilters, + columnVisibility, + pagination, + sorting, + isUserPersistenceEnabled: true, + }); + + // ASSERT + const cookieValue = setCookieSpy.mock.calls[0][0]; + const cookieMatch = cookieValue.match(new RegExp(`${cookieName}=(.+?);`)); + const parsedCookie = JSON.parse(decodeURIComponent(cookieMatch![1])); + + // ... only the system filter should be persisted in the cookie ... + expect(parsedCookie.columnFilters).toEqual([{ id: 'system', value: '1' }]); + }); + + it('given the user changes the page size, updates URL params correctly', () => { + // ARRANGE + const columnFilters: ColumnFiltersState = []; + const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; + const sorting: SortingState = [{ id: 'title', desc: false }]; + + const { rerender } = renderHook((props: any) => useTableSync(props), { + initialProps: { columnFilters, pagination, sorting }, + }); + + // ACT + const updatedPagination = { pageIndex: 0, pageSize: 50 }; + rerender({ + columnFilters, + sorting, + pagination: updatedPagination, + }); + + // ASSERT + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', encodeURI('/games?page[size]=50')); + }); + + it('given the user changes the sort direction to ascending, updates URL params correctly', () => { + // ARRANGE + const columnFilters: ColumnFiltersState = []; + const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; + const sorting: SortingState = [{ id: 'system', desc: true }]; + + const { rerender } = renderHook((props: any) => useTableSync(props), { + initialProps: { columnFilters, pagination, sorting }, + }); + + // ACT + const updatedSorting: SortingState = [{ id: 'system', desc: false }]; + rerender({ + columnFilters, + pagination, + sorting: updatedSorting, + }); + + // ASSERT + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + '', + encodeURI('/games?sort=system'), // !! no minus prefix on "system" when using ascending sort + ); + }); + + it('given inactive filters are present in the URL, removes them when updating params', () => { + // ARRANGE + const columnFilters: ColumnFiltersState = []; + const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; + const sorting: SortingState = [{ id: 'title', desc: false }]; + + // ... mock a URL with an inactive filter param ... + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '?filter[oldParam]=value', pathname: '/games' }, + }); + + const { rerender } = renderHook((props: any) => useTableSync(props), { + initialProps: { columnFilters, pagination, sorting }, + }); + + // ACT + const updatedFilters: ColumnFiltersState = [{ id: 'system', value: '1' }]; + rerender({ + columnFilters: updatedFilters, + pagination, + sorting, + }); + + // ASSERT + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + '', + encodeURI('/games?filter[system]=1'), // !! oldParam is removed + ); + }); + + it('given a filter value has a different length than the default filter value, treats them as different values', () => { + // ARRANGE + const columnFilters: ColumnFiltersState = []; + const pagination: PaginationState = { pageIndex: 0, pageSize: 25 }; + const sorting: SortingState = [{ id: 'title', desc: false }]; + const defaultFilters: ColumnFiltersState = [{ id: 'system', value: ['1', '2'] }]; + + const { rerender } = renderHook((props: any) => useTableSync(props), { + initialProps: { + columnFilters, + pagination, + sorting, + defaultColumnFilters: defaultFilters, + }, + }); + + // ACT + const updatedFilters: ColumnFiltersState = [{ id: 'system', value: ['1'] }]; + rerender({ + columnFilters: updatedFilters, + pagination, + sorting, + defaultColumnFilters: defaultFilters, + }); + + // ASSERT + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', encodeURI('/games?filter[system]=1')); + }); }); diff --git a/resources/js/features/game-list/hooks/useTableSync.ts b/resources/js/features/game-list/hooks/useTableSync.ts index b1e65623a2..b81607636f 100644 --- a/resources/js/features/game-list/hooks/useTableSync.ts +++ b/resources/js/features/game-list/hooks/useTableSync.ts @@ -1,5 +1,6 @@ import type { ColumnFiltersState, + ColumnSort, PaginationState, SortingState, TableState, @@ -12,10 +13,11 @@ import { usePageProps } from '@/common/hooks/usePageProps'; interface UseAutoUpdatingQueryParamsProps { columnFilters: ColumnFiltersState; columnVisibility: VisibilityState; - defaultFilters: ColumnFiltersState; + defaultColumnFilters: ColumnFiltersState; pagination: PaginationState; sorting: SortingState; + defaultColumnSort?: ColumnSort; defaultPageSize?: number; isUserPersistenceEnabled?: boolean; } @@ -29,7 +31,8 @@ export function useTableSync({ columnVisibility, pagination, sorting, - defaultFilters = [], + defaultColumnSort = { id: 'title', desc: false }, + defaultColumnFilters = [], defaultPageSize = 25, isUserPersistenceEnabled = false, }: UseAutoUpdatingQueryParamsProps) { @@ -61,8 +64,8 @@ export function useTableSync({ // Update individual components of the query params. updatePagination(searchParams, pagination, defaultPageSize); - updateFilters(searchParams, columnFilters, defaultFilters); - updateSorting(searchParams, sorting); + updateFilters(searchParams, columnFilters, defaultColumnFilters); + updateSorting(searchParams, sorting, defaultColumnSort); // `searchParams.size` is not supported in all envs, especially Node.js (Vitest). const searchParamsSize = Array.from(searchParams).length; @@ -93,13 +96,17 @@ function updatePagination( } } -function updateSorting(searchParams: URLSearchParams, sorting: SortingState): void { +function updateSorting( + searchParams: URLSearchParams, + sorting: SortingState, + defaultColumnSort: ColumnSort, +): void { // We only support a single active sort. The table is always sorted, // so it's fine to assume index 0 (activeSort) is always present. const [activeSort] = sorting; if (activeSort) { - if (activeSort.id === 'title' && !activeSort.desc) { + if (activeSort.id === defaultColumnSort.id && activeSort.desc === defaultColumnSort.desc) { searchParams.delete('sort'); } else { searchParams.set('sort', `${activeSort.desc ? '-' : ''}${activeSort.id}`); @@ -110,7 +117,7 @@ function updateSorting(searchParams: URLSearchParams, sorting: SortingState): vo function updateFilters( searchParams: URLSearchParams, columnFilters: ColumnFiltersState, - defaultFilters: ColumnFiltersState = [], + defaultFilters: ColumnFiltersState, ): void { const activeFilterIds = new Set(columnFilters.map((filter) => `filter[${filter.id}]`)); const defaultFilterMap = new Map(defaultFilters.map((filter) => [filter.id, filter.value])); diff --git a/resources/js/features/game-list/models/default-column-state.model.ts b/resources/js/features/game-list/models/default-column-state.model.ts new file mode 100644 index 0000000000..9f089d7048 --- /dev/null +++ b/resources/js/features/game-list/models/default-column-state.model.ts @@ -0,0 +1,7 @@ +import type { ColumnFiltersState, ColumnSort } from '@tanstack/react-table'; + +export interface DefaultColumnState { + defaultColumnFilters: ColumnFiltersState; + defaultColumnSort: ColumnSort; + defaultColumnVisibility: Partial>; +} diff --git a/resources/js/features/game-list/models/index.ts b/resources/js/features/game-list/models/index.ts index bc4ac4acc1..a8631fc217 100644 --- a/resources/js/features/game-list/models/index.ts +++ b/resources/js/features/game-list/models/index.ts @@ -1,2 +1,3 @@ +export * from './default-column-state.model'; export * from './sort-config.model'; export * from './sort-config-kind.model'; diff --git a/resources/js/features/game-list/utils/allGamesDefaultFilters.ts b/resources/js/features/game-list/utils/allGamesDefaultFilters.ts deleted file mode 100644 index 8b7b54278a..0000000000 --- a/resources/js/features/game-list/utils/allGamesDefaultFilters.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; - -export const allGamesDefaultFilters: ColumnFiltersState = [ - { id: 'achievementsPublished', value: ['has'] }, -]; diff --git a/resources/js/features/game-list/utils/buildInitialDefaultColumnVisibility.ts b/resources/js/features/game-list/utils/buildInitialDefaultColumnVisibility.ts new file mode 100644 index 0000000000..5a7dc01398 --- /dev/null +++ b/resources/js/features/game-list/utils/buildInitialDefaultColumnVisibility.ts @@ -0,0 +1,12 @@ +export function buildInitialDefaultColumnVisibility( + isUserAuthenticated: boolean, +): Partial> { + return { + hasActiveOrInReviewClaims: false, + lastUpdated: false, + numUnresolvedTickets: false, + numVisibleLeaderboards: false, + playersTotal: true, + progress: isUserAuthenticated, + }; +} diff --git a/resources/js/features/game-list/utils/hubGamesDefaultFilters.ts b/resources/js/features/game-list/utils/hubGamesDefaultFilters.ts deleted file mode 100644 index 27b89a8e7f..0000000000 --- a/resources/js/features/game-list/utils/hubGamesDefaultFilters.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; - -export const hubGamesDefaultFilters: ColumnFiltersState = [ - { id: 'achievementsPublished', value: ['either'] }, -]; diff --git a/resources/js/features/game-list/utils/wantToPlayGamesDefaultFilters.ts b/resources/js/features/game-list/utils/wantToPlayGamesDefaultFilters.ts deleted file mode 100644 index bccdeccacf..0000000000 --- a/resources/js/features/game-list/utils/wantToPlayGamesDefaultFilters.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; - -export const wantToPlayGamesDefaultFilters: ColumnFiltersState = [ - { id: 'achievementsPublished', value: ['has'] }, -]; diff --git a/resources/js/features/home/components/+root/GuestWelcomeCta/GuestWelcomeCta.tsx b/resources/js/features/home/components/+root/GuestWelcomeCta/GuestWelcomeCta.tsx index 1a97069feb..c34c18c840 100644 --- a/resources/js/features/home/components/+root/GuestWelcomeCta/GuestWelcomeCta.tsx +++ b/resources/js/features/home/components/+root/GuestWelcomeCta/GuestWelcomeCta.tsx @@ -41,7 +41,7 @@ export const GuestWelcomeCta: FC = () => { ), 2: ( ), From e5b24bc97569aafe77aa911b53e6b30dec82d359 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 1 Jan 2025 16:50:33 -0500 Subject: [PATCH 2/2] fix(GameListItems): prevent over-prefetching (#3001) --- .../GameListItems/GameListItems.test.tsx | 103 ++++++++++++++++++ .../GameListItems/GameListItems.tsx | 14 ++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/resources/js/features/game-list/components/GameListItems/GameListItems.test.tsx b/resources/js/features/game-list/components/GameListItems/GameListItems.test.tsx index 9a04dfc90f..d127ad1073 100644 --- a/resources/js/features/game-list/components/GameListItems/GameListItems.test.tsx +++ b/resources/js/features/game-list/components/GameListItems/GameListItems.test.tsx @@ -294,4 +294,107 @@ describe('Component: GameListItems', () => { expect(screen.getByText(/game 4/i)).toBeVisible(); }); + + it( + 'given the user scrolls to the bottom multiple times, only prefetches each page once', + { timeout: 10_000 }, + async () => { + // ARRANGE + mockAllIsIntersecting(false); + + const firstPageGames = [ + createGameListEntry({ game: createGame({ title: 'Game 1' }) }), + createGameListEntry({ game: createGame({ title: 'Game 2' }) }), + ]; + const secondPageGames = [ + createGameListEntry({ game: createGame({ title: 'Game 3' }) }), + createGameListEntry({ game: createGame({ title: 'Game 4' }) }), + ]; + const thirdPageGames = [ + createGameListEntry({ game: createGame({ title: 'Game 5' }) }), + createGameListEntry({ game: createGame({ title: 'Game 6' }) }), + ]; + + const firstPageData: App.Data.PaginatedData = { + currentPage: 1, + lastPage: 3, + perPage: 2, + total: 6, + unfilteredTotal: 6, + items: firstPageGames, + links: { firstPageUrl: '#', lastPageUrl: '#', nextPageUrl: '#', previousPageUrl: '#' }, + }; + + const secondPageData: App.Data.PaginatedData = { + currentPage: 2, + lastPage: 3, + perPage: 2, + total: 6, + unfilteredTotal: 6, + items: secondPageGames, + links: { firstPageUrl: '#', lastPageUrl: '#', nextPageUrl: '#', previousPageUrl: '#' }, + }; + + const thirdPageData: App.Data.PaginatedData = { + currentPage: 3, + lastPage: 3, + perPage: 2, + total: 6, + unfilteredTotal: 6, + items: thirdPageGames, + links: { firstPageUrl: '#', lastPageUrl: '#', nextPageUrl: '#', previousPageUrl: '#' }, + }; + + const getSpy = vi + .spyOn(axios, 'get') + .mockResolvedValueOnce({ data: firstPageData }) + .mockResolvedValueOnce({ data: secondPageData }) + .mockResolvedValueOnce({ data: thirdPageData }); + + render( + + + , + { + pageProps: { + ziggy: createZiggyProps({ device: 'mobile' }), + }, + }, + ); + + // ... wait for the initial load ... + await waitFor(() => { + screen.getByText(/game 1/i); + }); + + // ACT + // ... simulate scrolling to the bottom the first time ... + mockAllIsIntersecting(true); + + // ... wait for the prefetch to complete ... + await waitFor(() => { + expect(getSpy).toHaveBeenCalledTimes(2); + }); + + // ... simulate scrolling back up ... + mockAllIsIntersecting(false); + + // ... simulate scrolling to the bottom again ... + mockAllIsIntersecting(true); + + // ASSERT + /** + * The spy should still only have been called twice - once for the initial load + * and once for the prefetch. Scrolling to the bottom again shouldn't trigger + * another prefetch of the same page or the Nth+1 page. + */ + await waitFor(() => { + expect(getSpy).toHaveBeenCalledTimes(2); + }); + }, + ); }); diff --git a/resources/js/features/game-list/components/GameListItems/GameListItems.tsx b/resources/js/features/game-list/components/GameListItems/GameListItems.tsx index eaa177ea0e..8f0f50d92d 100644 --- a/resources/js/features/game-list/components/GameListItems/GameListItems.tsx +++ b/resources/js/features/game-list/components/GameListItems/GameListItems.tsx @@ -59,7 +59,7 @@ const GameListItems: FC = ({ }); const [visiblePageNumbers, setVisiblePageNumbers] = useState([1]); - + const [prefetchedPageNumbers, setPrefetchedPageNumbers] = useState([1]); const [isLoadingMore, setIsLoadingMore] = useState(false); const isEmpty = dataInfiniteQuery.data?.pages?.[0].total === 0; @@ -74,9 +74,17 @@ const GameListItems: FC = ({ const isLastPageResultsVisible = visiblePageNumbers[visiblePageNumbers.length - 1] === lastPageNumber; - const handleLoadMore = () => { + const handleLoadMore = async () => { if (dataInfiniteQuery.hasNextPage && !dataInfiniteQuery.isFetchingNextPage) { - dataInfiniteQuery.fetchNextPage(); + const nextPageNumber = Math.max(...visiblePageNumbers) + 1; + + // Don't prefetch the next page if it has already been prefetched. + if (prefetchedPageNumbers.includes(nextPageNumber)) { + return; + } + + await dataInfiniteQuery.fetchNextPage(); + setPrefetchedPageNumbers([...prefetchedPageNumbers, nextPageNumber]); } };