From 020a349c3551fb5080aaeb0471d85365c5745865 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 29 Sep 2024 18:20:18 -0400 Subject: [PATCH 01/11] feat: migrate All Games to React.js datatable --- .../Requests/UserGameListRequest.php | 48 +--- app/Models/Emulator.php | 4 + app/Models/System.php | 4 + app/Platform/Actions/BuildGameListAction.php | 12 +- .../Controllers/Api/GameListApiController.php | 49 ++++ .../Controllers/GameListController.php | 74 +++++ app/Platform/Data/GameListPagePropsData.php | 24 ++ app/Platform/Requests/GameListRequest.php | 54 ++++ app/Platform/RouteServiceProvider.php | 8 + .../DataTableColumnHeader.tsx | 16 +- .../components/DataTableColumnHeader/index.ts | 1 + .../DataTableFacetedFilter.tsx | 19 +- .../DataTableFacetedFilter/index.ts | 1 + .../DataTablePagination.tsx | 16 +- .../components/DataTablePagination/index.ts | 1 + .../DataTableRowActions.tsx | 115 ++++++++ .../components/DataTableRowActions/index.ts | 1 + .../DataTableSearchInput.tsx | 0 .../components}/DataTableSearchInput/index.ts | 0 .../useSearchInputHotkey.ts | 0 .../DataTableViewOptions.tsx | 4 +- .../components/DataTableViewOptions/index.ts | 1 + .../hooks/useDataTablePrefetchPagination.ts} | 13 +- .../hooks/useDataTablePrefetchSort.ts} | 10 +- .../useGameListQuery/useGameListQuery.ts | 13 +- .../AllGamesDataTable/AllGamesDataTable.tsx | 164 +++++++++++ .../AllGamesDataTableToolbar.tsx | 104 +++++++ .../buildColumnDefinitions.tsx | 257 ++++++++++++++++++ .../components/AllGamesDataTable/index.ts | 1 + .../AllGamesMainRoot/AllGamesMainRoot.tsx | 58 ++++ .../components/AllGamesMainRoot/index.ts | 1 + .../DataTableRowActions.tsx | 87 ------ .../WantToPlayGamesDataTable.tsx | 14 +- .../WantToPlayGamesDataTableToolbar.tsx | 25 +- .../buildColumnDefinitions.tsx | 96 +++++-- ...t.tsx => WantToPlayGamesMainRoot.test.tsx} | 48 ++-- ...esRoot.tsx => WantToPlayGamesMainRoot.tsx} | 2 +- .../WantToPlayGamesMainRoot/index.ts | 2 +- .../game-list/utils/allGamesDefaultFilters.ts | 5 + .../utils/getAreNonDefaultFiltersSet.ts | 16 ++ resources/js/pages/game-list/index.tsx | 25 ++ resources/js/pages/game-list/play.tsx | 4 +- resources/js/types/generated.d.ts | 5 + resources/js/ziggy.d.ts | 2 + 44 files changed, 1166 insertions(+), 238 deletions(-) create mode 100644 app/Platform/Controllers/Api/GameListApiController.php create mode 100644 app/Platform/Controllers/GameListController.php create mode 100644 app/Platform/Data/GameListPagePropsData.php create mode 100644 app/Platform/Requests/GameListRequest.php rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components/DataTableColumnHeader}/DataTableColumnHeader.tsx (90%) create mode 100644 resources/js/common/components/DataTableColumnHeader/index.ts rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components/DataTableFacetedFilter}/DataTableFacetedFilter.tsx (92%) create mode 100644 resources/js/common/components/DataTableFacetedFilter/index.ts rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components/DataTablePagination}/DataTablePagination.tsx (85%) create mode 100644 resources/js/common/components/DataTablePagination/index.ts create mode 100644 resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx create mode 100644 resources/js/common/components/DataTableRowActions/index.ts rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components}/DataTableSearchInput/DataTableSearchInput.tsx (100%) rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components}/DataTableSearchInput/index.ts (100%) rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components}/DataTableSearchInput/useSearchInputHotkey.ts (100%) rename resources/js/{features/game-list/components/WantToPlayGamesDataTable => common/components/DataTableViewOptions}/DataTableViewOptions.tsx (92%) create mode 100644 resources/js/common/components/DataTableViewOptions/index.ts rename resources/js/{features/game-list/hooks/usePrefetchPagination.ts => common/hooks/useDataTablePrefetchPagination.ts} (70%) rename resources/js/{features/game-list/hooks/usePrefetchSort.ts => common/hooks/useDataTablePrefetchSort.ts} (70%) create mode 100644 resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx create mode 100644 resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx create mode 100644 resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx create mode 100644 resources/js/features/game-list/components/AllGamesDataTable/index.ts create mode 100644 resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx create mode 100644 resources/js/features/game-list/components/AllGamesMainRoot/index.ts delete mode 100644 resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableRowActions.tsx rename resources/js/features/game-list/components/WantToPlayGamesMainRoot/{WantToPlayGamesRoot.test.tsx => WantToPlayGamesMainRoot.test.tsx} (97%) rename resources/js/features/game-list/components/WantToPlayGamesMainRoot/{WantToPlayGamesRoot.tsx => WantToPlayGamesMainRoot.tsx} (97%) create mode 100644 resources/js/features/game-list/utils/allGamesDefaultFilters.ts create mode 100644 resources/js/features/game-list/utils/getAreNonDefaultFiltersSet.ts create mode 100644 resources/js/pages/game-list/index.tsx diff --git a/app/Community/Requests/UserGameListRequest.php b/app/Community/Requests/UserGameListRequest.php index 8facdb6ac..a1b5bc1d0 100644 --- a/app/Community/Requests/UserGameListRequest.php +++ b/app/Community/Requests/UserGameListRequest.php @@ -4,56 +4,12 @@ namespace App\Community\Requests; -use Illuminate\Foundation\Http\FormRequest; +use App\Platform\Requests\GameListRequest; -class UserGameListRequest extends FormRequest +class UserGameListRequest extends GameListRequest { public function authorize(): bool { return true; } - - public function rules(): array - { - return [ - 'page.number' => 'integer|min:1', - 'sort' => 'string|in:title,system,achievementsPublished,pointsTotal,retroRatio,lastUpdated,releasedAt,playersTotal,numVisibleLeaderboards,numUnresolvedTickets,progress,-title,-system,-achievementsPublished,-pointsTotal,-retroRatio,-lastUpdated,-releasedAt,-playersTotal,-numVisibleLeaderboards,-numUnresolvedTickets,-progress', - 'filter.*' => 'string', - ]; - } - - public function getPage(): int - { - return (int) $this->input('page.number', 1); - } - - public function getSort(): array - { - $sortParam = $this->input('sort', 'title'); - $sortDirection = 'asc'; - - if (str_starts_with($sortParam, '-')) { - $sortDirection = 'desc'; - $sortParam = ltrim($sortParam, '-'); - } - - return [ - 'field' => $sortParam, - 'direction' => $sortDirection, - ]; - } - - public function getFilters(): array - { - $filters = []; - foreach ($this->query('filter', []) as $key => $value) { - $filters[$key] = explode(',', $value); - } - - if (!isset($filters['achievementsPublished'])) { - $filters['achievementsPublished'] = ['has']; - } - - return $filters; - } } diff --git a/app/Models/Emulator.php b/app/Models/Emulator.php index 06decb5a8..31d477a1d 100644 --- a/app/Models/Emulator.php +++ b/app/Models/Emulator.php @@ -50,6 +50,10 @@ class Emulator extends BaseModel implements HasMedia 'source_url', ]; + protected $casts = [ + 'active' => 'boolean', + ]; + public static function boot() { parent::boot(); diff --git a/app/Models/System.php b/app/Models/System.php index 82e26a406..52e7d669b 100644 --- a/app/Models/System.php +++ b/app/Models/System.php @@ -78,6 +78,10 @@ protected static function newFactory(): SystemFactory 'active', ]; + protected $casts = [ + 'active' => 'boolean', + ]; + // == constants public const Arduboy = 71; diff --git a/app/Platform/Actions/BuildGameListAction.php b/app/Platform/Actions/BuildGameListAction.php index ec8e299e8..9480a4f57 100644 --- a/app/Platform/Actions/BuildGameListAction.php +++ b/app/Platform/Actions/BuildGameListAction.php @@ -11,6 +11,7 @@ use App\Models\Game; use App\Models\Leaderboard; use App\Models\PlayerGame; +use App\Models\System; use App\Models\Ticket; use App\Models\User; use App\Platform\Data\GameData; @@ -156,6 +157,16 @@ private function buildBaseQuery(GameListType $listType, ?User $user = null): Bui } switch ($listType) { + case GameListType::AllGames: + // Exclude events, hubs, subsets, and inactive systems. + $query->where('GameData.ConsoleID', '!=', System::Events) + ->where('GameData.ConsoleID', '!=', System::Hubs) + ->where('GameData.Title', 'not like', "%[Subset -%") + ->whereHas('system', function ($q) { + return $q->active(); + }); + break; + case GameListType::UserPlay: $query->whereHas('gameListEntries', function ($query) use ($user) { $query->where('user_id', $user->id) @@ -165,7 +176,6 @@ private function buildBaseQuery(GameListType $listType, ?User $user = null): Bui // TODO implement these other use cases case GameListType::UserDevelop: - case GameListType::AllGames: case GameListType::System: case GameListType::Hub: case GameListType::DeveloperSets: diff --git a/app/Platform/Controllers/Api/GameListApiController.php b/app/Platform/Controllers/Api/GameListApiController.php new file mode 100644 index 000000000..1cf8e14ba --- /dev/null +++ b/app/Platform/Controllers/Api/GameListApiController.php @@ -0,0 +1,49 @@ +execute( + GameListType::AllGames, + user: $request->user(), + page: $request->getPage(), + filters: $request->getFilters(), + sort: $request->getSort(), + ); + + return response()->json($paginatedData); + } + + public function create(): void + { + } + + public function store(): void + { + } + + public function show(): void + { + } + + public function edit(): void + { + } + + public function update(): void + { + } + + public function destroy(): void + { + } +} \ No newline at end of file diff --git a/app/Platform/Controllers/GameListController.php b/app/Platform/Controllers/GameListController.php new file mode 100644 index 000000000..8ae16270d --- /dev/null +++ b/app/Platform/Controllers/GameListController.php @@ -0,0 +1,74 @@ +user(); + + $paginatedData = (new BuildGameListAction())->execute( + GameListType::AllGames, + user: $user, + page: $request->getPage(), + filters: $request->getFilters(), + sort: $request->getSort(), + ); + + $filterableSystemOptions = System::active() + ->get() + ->map(fn ($system) => SystemData::fromSystem($system)->include('nameShort')) + ->values() + ->all(); + + $can = UserPermissionsData::fromUser($user)->include('develop'); + + $props = new GameListPagePropsData( + paginatedGameListEntries: $paginatedData, + filterableSystemOptions: $filterableSystemOptions, + can: $can, + ); + + return Inertia::render('game-list/index', $props); + } + + public function create(): void + { + } + + public function store(): void + { + } + + public function show(): void + { + } + + public function edit(): void + { + } + + public function update(): void + { + } + + public function destroy(): void + { + } +} diff --git a/app/Platform/Data/GameListPagePropsData.php b/app/Platform/Data/GameListPagePropsData.php new file mode 100644 index 000000000..897ca0170 --- /dev/null +++ b/app/Platform/Data/GameListPagePropsData.php @@ -0,0 +1,24 @@ +')] +class GameListPagePropsData extends Data +{ + /** + * @param SystemData[] $filterableSystemOptions + */ + public function __construct( + public PaginatedData $paginatedGameListEntries, + public array $filterableSystemOptions, + public UserPermissionsData $can, + ) { + } +} diff --git a/app/Platform/Requests/GameListRequest.php b/app/Platform/Requests/GameListRequest.php new file mode 100644 index 000000000..7f508262f --- /dev/null +++ b/app/Platform/Requests/GameListRequest.php @@ -0,0 +1,54 @@ + 'integer|min:1', + 'sort' => 'string|in:title,system,achievementsPublished,pointsTotal,retroRatio,lastUpdated,releasedAt,playersTotal,numVisibleLeaderboards,numUnresolvedTickets,progress,-title,-system,-achievementsPublished,-pointsTotal,-retroRatio,-lastUpdated,-releasedAt,-playersTotal,-numVisibleLeaderboards,-numUnresolvedTickets,-progress', + 'filter.*' => 'string', + ]; + } + + public function getPage(): int + { + return (int) $this->input('page.number', 1); + } + + public function getSort(): array + { + $sortParam = $this->input('sort', 'title'); + $sortDirection = 'asc'; + + if (str_starts_with($sortParam, '-')) { + $sortDirection = 'desc'; + $sortParam = ltrim($sortParam, '-'); + } + + return [ + 'field' => $sortParam, + 'direction' => $sortDirection, + ]; + } + + public function getFilters(): array + { + $filters = []; + foreach ($this->query('filter', []) as $key => $value) { + $filters[$key] = explode(',', $value); + } + + if (!isset($filters['achievementsPublished'])) { + $filters['achievementsPublished'] = ['has']; + } + + return $filters; + } +} diff --git a/app/Platform/RouteServiceProvider.php b/app/Platform/RouteServiceProvider.php index d86c3b6e4..93c92c12b 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -6,8 +6,10 @@ use App\Models\GameHash; use App\Platform\Controllers\AchievementController; +use App\Platform\Controllers\Api\GameListApiController; use App\Platform\Controllers\GameController; use App\Platform\Controllers\GameHashController; +use App\Platform\Controllers\GameListController; use App\Platform\Controllers\PlayerAchievementController; use App\Platform\Controllers\PlayerGameController; use App\Platform\Controllers\ReportAchievementIssueController; @@ -44,8 +46,14 @@ public function map(): void protected function mapWebRoutes(): void { Route::middleware(['web', 'csp'])->group(function () { + Route::group(['prefix' => 'internal-api'], function () { + Route::get('game-list', [GameListApiController::class, 'index'])->name('api.game-list.index'); + }); + Route::middleware(['inertia'])->group(function () { Route::get('game/{game}/hashes', [GameHashController::class, 'index'])->name('game.hashes.index'); + + Route::get('game-list', [GameListController::class, 'index'])->name('game-list.index'); }); // Route::get('achievement/{achievement}{slug?}', [AchievementController::class, 'show'])->name('achievement.show'); diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx b/resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx similarity index 90% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx rename to resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx index 8634586d7..b0aef32a5 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx +++ b/resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx @@ -2,18 +2,19 @@ import type { Column, Table } from '@tanstack/react-table'; import type { FC, HTMLAttributes, ReactNode } from 'react'; import type { IconType } from 'react-icons/lib'; import { RxArrowDown, RxArrowUp, RxCaretSort, RxEyeNone } from 'react-icons/rx'; +import type { RouteName } from 'ziggy-js'; -import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { cn } from '@/utils/cn'; + +import { useDataTablePrefetchSort } from '../../hooks/useDataTablePrefetchSort'; +import { BaseButton } from '../+vendor/BaseButton'; import { BaseDropdownMenu, BaseDropdownMenuContent, BaseDropdownMenuItem, BaseDropdownMenuSeparator, BaseDropdownMenuTrigger, -} from '@/common/components/+vendor/BaseDropdownMenu'; -import { cn } from '@/utils/cn'; - -import { usePrefetchSort } from '../../hooks/usePrefetchSort'; +} from '../+vendor/BaseDropdownMenu'; type SortDirection = 'asc' | 'desc'; type SortConfig = { @@ -48,6 +49,8 @@ interface DataTableColumnHeaderProps extends HTMLAttributes; sortType?: SortConfigKind; + /** The controller route name where client-side calls for this datatable are made. */ + tableApiRouteName?: RouteName; } export function DataTableColumnHeader({ @@ -55,8 +58,9 @@ export function DataTableColumnHeader({ column, table, sortType = 'default', + tableApiRouteName = 'api.game-list.index', }: DataTableColumnHeaderProps): ReactNode { - const { prefetchSort } = usePrefetchSort(table); + const { prefetchSort } = useDataTablePrefetchSort(table, tableApiRouteName); if (!column.getCanSort()) { return
{column.columnDef.meta?.label}
; diff --git a/resources/js/common/components/DataTableColumnHeader/index.ts b/resources/js/common/components/DataTableColumnHeader/index.ts new file mode 100644 index 000000000..6aa469ad2 --- /dev/null +++ b/resources/js/common/components/DataTableColumnHeader/index.ts @@ -0,0 +1 @@ +export * from './DataTableColumnHeader'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableFacetedFilter.tsx b/resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx similarity index 92% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableFacetedFilter.tsx rename to resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx index 040d2b1ac..233fa4bb7 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableFacetedFilter.tsx +++ b/resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx @@ -2,8 +2,11 @@ import type { Column } from '@tanstack/react-table'; import type { FC } from 'react'; import { RxCheck, RxPlusCircled } from 'react-icons/rx'; -import { BaseBadge } from '@/common/components/+vendor/BaseBadge'; -import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { cn } from '@/utils/cn'; + +import { buildTrackingClassNames } from '../../utils/buildTrackingClassNames'; +import { BaseBadge } from '../+vendor/BaseBadge'; +import { BaseButton } from '../+vendor/BaseButton'; import { BaseCommand, BaseCommandEmpty, @@ -12,15 +15,9 @@ import { BaseCommandItem, BaseCommandList, BaseCommandSeparator, -} from '@/common/components/+vendor/BaseCommand'; -import { - BasePopover, - BasePopoverContent, - BasePopoverTrigger, -} from '@/common/components/+vendor/BasePopover'; -import { BaseSeparator } from '@/common/components/+vendor/BaseSeparator'; -import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; -import { cn } from '@/utils/cn'; +} from '../+vendor/BaseCommand'; +import { BasePopover, BasePopoverContent, BasePopoverTrigger } from '../+vendor/BasePopover'; +import { BaseSeparator } from '../+vendor/BaseSeparator'; interface DataTableFacetedFilterProps { options: Array<{ diff --git a/resources/js/common/components/DataTableFacetedFilter/index.ts b/resources/js/common/components/DataTableFacetedFilter/index.ts new file mode 100644 index 000000000..ff3e45f9c --- /dev/null +++ b/resources/js/common/components/DataTableFacetedFilter/index.ts @@ -0,0 +1 @@ +export * from './DataTableFacetedFilter'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTablePagination.tsx b/resources/js/common/components/DataTablePagination/DataTablePagination.tsx similarity index 85% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTablePagination.tsx rename to resources/js/common/components/DataTablePagination/DataTablePagination.tsx index 377cf63d8..16c37b2f0 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTablePagination.tsx +++ b/resources/js/common/components/DataTablePagination/DataTablePagination.tsx @@ -1,22 +1,26 @@ import type { Table } from '@tanstack/react-table'; import type { ReactNode } from 'react'; import { LuArrowLeft, LuArrowLeftToLine, LuArrowRight, LuArrowRightToLine } 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 { usePrefetchPagination } from '../../hooks/usePrefetchPagination'; +import { useDataTablePrefetchPagination } from '../../hooks/useDataTablePrefetchPagination'; +import { BaseButton } from '../+vendor/BaseButton'; +import { BasePagination, BasePaginationContent } from '../+vendor/BasePagination'; interface DataTablePaginationProps { table: Table; + tableApiRouteName?: RouteName; } -export function DataTablePagination({ table }: DataTablePaginationProps): ReactNode { +export function DataTablePagination({ + table, + tableApiRouteName = 'api.game-list.index', +}: DataTablePaginationProps): ReactNode { 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 } = usePrefetchPagination(table); + const { prefetchPagination } = useDataTablePrefetchPagination(table, tableApiRouteName); const handlePageChange = (newPageIndex: number, isNext: boolean) => { table.setPageIndex(newPageIndex); diff --git a/resources/js/common/components/DataTablePagination/index.ts b/resources/js/common/components/DataTablePagination/index.ts new file mode 100644 index 000000000..374ba7bfc --- /dev/null +++ b/resources/js/common/components/DataTablePagination/index.ts @@ -0,0 +1 @@ +export * from './DataTablePagination'; diff --git a/resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx b/resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx new file mode 100644 index 000000000..c393697ef --- /dev/null +++ b/resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx @@ -0,0 +1,115 @@ +import { useQueryClient } from '@tanstack/react-query'; +import type { Row } from '@tanstack/react-table'; +import { MdClose } from 'react-icons/md'; + +import { usePageProps } from '@/common/hooks/usePageProps'; +import { cn } from '@/utils/cn'; + +import { useAddToBacklogMutation } from '../../hooks/useAddToBacklogMutation'; +import { useRemoveFromBacklogMutation } from '../../hooks/useRemoveFromBacklogMutation'; +import { BaseButton } from '../+vendor/BaseButton'; +import { toastMessage } from '../+vendor/BaseToaster'; +import { BaseTooltip, BaseTooltipContent, BaseTooltipTrigger } from '../+vendor/BaseTooltip'; + +/** + * If the table row needs to have more than one action, it should go into a menu. + * @see https://ui.shadcn.com/examples/tasks + */ + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const { auth } = usePageProps(); + + const queryClient = useQueryClient(); + + const removeFromBacklogMutation = useRemoveFromBacklogMutation(); + + const addToBacklogMutation = useAddToBacklogMutation(); + + const rowData = row.original as Partial; + const gameId = rowData?.game?.id ?? 0; + const gameTitle = rowData?.game?.title ?? ''; + const isInBacklog = rowData?.isInBacklog ?? false; + + // TODO put this in a hook? useBacklog()? + const addToBacklog = (gameId: number, options?: Partial<{ isUndo: boolean }>) => { + toastMessage.promise(addToBacklogMutation.mutateAsync(gameId), { + loading: options?.isUndo ? 'Restoring...' : 'Adding...', + success: () => { + // Trigger a refetch of the current table page data and bust the entire cache. + queryClient.invalidateQueries({ queryKey: ['data'] }); + + return `${options?.isUndo ? 'Restored' : 'Added'} ${gameTitle}!`; + }, + error: 'Something went wrong.', + }); + }; + + // TODO put this in a hook? useBacklog()? + const removeFromBacklog = (gameId: number) => { + toastMessage.promise(removeFromBacklogMutation.mutateAsync(gameId), { + action: { + label: 'Undo', + onClick: () => addToBacklog(gameId, { isUndo: true }), + }, + loading: 'Removing...', + success: () => { + // Trigger a refetch of the current table page data and bust the entire cache. + queryClient.invalidateQueries({ queryKey: ['data'] }); + + return `Removed ${gameTitle}!`; + }, + error: 'Something went wrong.', + }); + }; + + const handleToggleFromBacklogClick = () => { + // This should never happen. + if (!gameId) { + throw new Error('No game ID.'); + } + + if (!auth?.user) { + // TODO handle user unauthenticated + return; + } + + if (isInBacklog) { + removeFromBacklog(gameId); + } else { + addToBacklog(gameId); + } + }; + + return ( + + +
+ + + + + {isInBacklog ? 'Remove from Want To Play Games' : 'Add to Want to Play Games'} + + +
+
+ + +

{isInBacklog ? 'Remove from Want to Play Games' : 'Add to Want to Play Games'}

+
+
+ ); +} diff --git a/resources/js/common/components/DataTableRowActions/index.ts b/resources/js/common/components/DataTableRowActions/index.ts new file mode 100644 index 000000000..f54451b0c --- /dev/null +++ b/resources/js/common/components/DataTableRowActions/index.ts @@ -0,0 +1 @@ +export * from './DataTableRowActions'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/DataTableSearchInput.tsx b/resources/js/common/components/DataTableSearchInput/DataTableSearchInput.tsx similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/DataTableSearchInput.tsx rename to resources/js/common/components/DataTableSearchInput/DataTableSearchInput.tsx diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/index.ts b/resources/js/common/components/DataTableSearchInput/index.ts similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/index.ts rename to resources/js/common/components/DataTableSearchInput/index.ts diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/useSearchInputHotkey.ts b/resources/js/common/components/DataTableSearchInput/useSearchInputHotkey.ts similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/useSearchInputHotkey.ts rename to resources/js/common/components/DataTableSearchInput/useSearchInputHotkey.ts diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableViewOptions.tsx b/resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx similarity index 92% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableViewOptions.tsx rename to resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx index 3d6ef6ad0..59069e4ce 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableViewOptions.tsx +++ b/resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx @@ -1,7 +1,7 @@ import type { Table } from '@tanstack/react-table'; import { RxMixerHorizontal } from 'react-icons/rx'; -import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { BaseButton } from '../+vendor/BaseButton'; import { BaseDropdownMenu, BaseDropdownMenuCheckboxItem, @@ -9,7 +9,7 @@ import { BaseDropdownMenuLabel, BaseDropdownMenuSeparator, BaseDropdownMenuTrigger, -} from '@/common/components/+vendor/BaseDropdownMenu'; +} from '../+vendor/BaseDropdownMenu'; interface DataTableViewOptionsProps { table: Table; diff --git a/resources/js/common/components/DataTableViewOptions/index.ts b/resources/js/common/components/DataTableViewOptions/index.ts new file mode 100644 index 000000000..122358e02 --- /dev/null +++ b/resources/js/common/components/DataTableViewOptions/index.ts @@ -0,0 +1 @@ +export * from './DataTableViewOptions'; diff --git a/resources/js/features/game-list/hooks/usePrefetchPagination.ts b/resources/js/common/hooks/useDataTablePrefetchPagination.ts similarity index 70% rename from resources/js/features/game-list/hooks/usePrefetchPagination.ts rename to resources/js/common/hooks/useDataTablePrefetchPagination.ts index 70f9c1dba..752fd05d9 100644 --- a/resources/js/features/game-list/hooks/usePrefetchPagination.ts +++ b/resources/js/common/hooks/useDataTablePrefetchPagination.ts @@ -1,22 +1,27 @@ import { useQueryClient } from '@tanstack/react-query'; import type { Table } from '@tanstack/react-table'; import axios from 'axios'; +import type { RouteName } from 'ziggy-js'; -import { buildGameListQueryFilterParams } from '@/common/utils/buildGameListQueryFilterParams'; -import { buildGameListQuerySortParam } from '@/common/utils/buildGameListQuerySortParam'; +import { buildGameListQueryFilterParams } from '../utils/buildGameListQueryFilterParams'; +import { buildGameListQuerySortParam } from '../utils/buildGameListQuerySortParam'; /** * 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 usePrefetchPagination(table: Table) { +export function useDataTablePrefetchPagination( + table: Table, + tableApiRouteName: RouteName, +) { const { columnFilters, pagination, sorting } = table.getState(); const queryClient = useQueryClient(); const prefetchPagination = (newPageIndex: number) => { queryClient.prefetchQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- tableApiRouteName is not part of the key queryKey: [ 'data', { pageIndex: newPageIndex, pageSize: pagination.pageSize }, @@ -26,7 +31,7 @@ export function usePrefetchPagination(table: Table) { staleTime: 1 * 60 * 1000, // 1 minute queryFn: async () => { const response = await axios.get>( - route('api.user-game-list.index', { + route(tableApiRouteName, { 'page[number]': newPageIndex + 1, sort: buildGameListQuerySortParam(sorting), ...buildGameListQueryFilterParams(columnFilters), diff --git a/resources/js/features/game-list/hooks/usePrefetchSort.ts b/resources/js/common/hooks/useDataTablePrefetchSort.ts similarity index 70% rename from resources/js/features/game-list/hooks/usePrefetchSort.ts rename to resources/js/common/hooks/useDataTablePrefetchSort.ts index 2e40077bf..bfb0d819c 100644 --- a/resources/js/features/game-list/hooks/usePrefetchSort.ts +++ b/resources/js/common/hooks/useDataTablePrefetchSort.ts @@ -1,27 +1,29 @@ import { useQueryClient } from '@tanstack/react-query'; import type { Table } from '@tanstack/react-table'; import axios from 'axios'; +import type { RouteName } from 'ziggy-js'; -import { buildGameListQueryFilterParams } from '@/common/utils/buildGameListQueryFilterParams'; -import { buildGameListQuerySortParam } from '@/common/utils/buildGameListQuerySortParam'; +import { buildGameListQueryFilterParams } from '../utils/buildGameListQueryFilterParams'; +import { buildGameListQuerySortParam } from '../utils/buildGameListQuerySortParam'; /** * Given the user hovers over a sort option, it is very likely they will * wind up clicking the option. Queries are cheap, so prefetch the destination. */ -export function usePrefetchSort(table: Table) { +export function useDataTablePrefetchSort(table: Table, tableApiRouteName: RouteName) { const { columnFilters, pagination } = table.getState(); const queryClient = useQueryClient(); const prefetchSort = (columnId = '', direction: 'asc' | 'desc') => { queryClient.prefetchQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- tableApiRouteName is not part of the key queryKey: ['data', pagination, [{ id: columnId, desc: direction === 'desc' }], columnFilters], staleTime: 1 * 60 * 1000, // 1 minute queryFn: async () => { const response = await axios.get>( - route('api.user-game-list.index', { + route(tableApiRouteName, { 'page[number]': pagination.pageIndex + 1, sort: buildGameListQuerySortParam([{ id: columnId, desc: direction === 'desc' }]), ...buildGameListQueryFilterParams(columnFilters), diff --git a/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts b/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts index 11c729cb5..b75e25a0e 100644 --- a/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts +++ b/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts @@ -1,6 +1,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import type { ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table'; import axios from 'axios'; +import type { RouteName } from 'ziggy-js'; import { buildGameListQueryFilterParams } from '../../utils/buildGameListQueryFilterParams'; import { buildGameListQuerySortParam } from '../../utils/buildGameListQuerySortParam'; @@ -11,15 +12,23 @@ interface UseGameListQueryProps { pagination: PaginationState; sorting: SortingState; columnFilters: ColumnFiltersState; + + apiRouteName?: RouteName; } -export function useGameListQuery({ columnFilters, pagination, sorting }: UseGameListQueryProps) { +export function useGameListQuery({ + columnFilters, + pagination, + sorting, + apiRouteName = 'api.game-list.index', +}: UseGameListQueryProps) { const dataQuery = useQuery>({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- tableApiRouteName is not part of the key queryKey: ['data', pagination, sorting, columnFilters], queryFn: async () => { const response = await axios.get>( - route('api.user-game-list.index', { + route(apiRouteName, { 'page[number]': pagination.pageIndex + 1, sort: buildGameListQuerySortParam(sorting), ...buildGameListQueryFilterParams(columnFilters), diff --git a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx new file mode 100644 index 000000000..64581c18b --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx @@ -0,0 +1,164 @@ +import type { + ColumnFiltersState, + PaginationState, + SortingState, + VisibilityState, +} from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'; + +import { + BaseTable, + BaseTableBody, + BaseTableCell, + BaseTableHead, + BaseTableHeader, + BaseTableRow, +} from '@/common/components/+vendor/BaseTable'; +import { DataTablePagination } from '@/common/components/DataTablePagination'; +import { useGameListQuery } from '@/common/hooks/useGameListQuery'; +import { usePageProps } from '@/common/hooks/usePageProps'; +import { cn } from '@/utils/cn'; + +import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; +import { AllGamesDataTableToolbar } from './AllGamesDataTableToolbar'; +import { buildColumnDefinitions } from './buildColumnDefinitions'; + +// These values are all generated from `useGameListState`. +interface AllGamesDataTableProps { + columnFilters: ColumnFiltersState; + columnVisibility: VisibilityState; + pagination: PaginationState; + setColumnFilters: Dispatch>; + setColumnVisibility: Dispatch>; + setPagination: Dispatch>; + setSorting: Dispatch>; + sorting: SortingState; +} + +export const AllGamesDataTable: FC = ({ + columnFilters, + columnVisibility, + pagination, + setColumnFilters, + setColumnVisibility, + setPagination, + setSorting, + sorting, +}) => { + const { can } = usePageProps(); + + const gameListQuery = useGameListQuery({ columnFilters, pagination, sorting }); + + const table = useReactTable({ + columns: useMemo( + () => + buildColumnDefinitions({ + canSeeOpenTicketsColumn: can.develop ?? false, + }), + [can.develop], + ), + data: gameListQuery.data?.items ?? [], + manualPagination: true, + manualSorting: true, + manualFiltering: true, + rowCount: gameListQuery.data?.total, + pageCount: gameListQuery.data?.lastPage, + onColumnVisibilityChange: setColumnVisibility, + onColumnFiltersChange: (updateOrValue) => { + table.setPageIndex(0); + + setColumnFilters(updateOrValue); + }, + onPaginationChange: (newPaginationValue) => { + setPagination(newPaginationValue); + }, + onSortingChange: (newSortingValue) => { + table.setPageIndex(0); + + setSorting(newSortingValue); + }, + getCoreRowModel: getCoreRowModel(), + state: { columnFilters, columnVisibility, pagination, sorting }, + }); + + const visibleColumnCount = table.getVisibleFlatColumns().length; + + return ( +
+ + + {/* TODO reusable component */} + 8 ? 'lg:!overflow-x-scroll' : '', + visibleColumnCount > 10 ? 'xl:!overflow-x-scroll' : '', + )} + > + + {table.getHeaderGroups().map((headerGroup) => ( + 8 ? 'lg:!top-0' : '', + visibleColumnCount > 10 ? 'xl:!top-0' : '', + )} + > + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + + +
+ ); +}; diff --git a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx new file mode 100644 index 000000000..90f1dc54d --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx @@ -0,0 +1,104 @@ +import type { ColumnFiltersState, Table } from '@tanstack/react-table'; +import { RxCross2 } from 'react-icons/rx'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { DataTableFacetedFilter } from '@/common/components/DataTableFacetedFilter'; +import { DataTableSearchInput } from '@/common/components/DataTableSearchInput'; +import { DataTableViewOptions } from '@/common/components/DataTableViewOptions'; +import { usePageProps } from '@/common/hooks/usePageProps'; +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { getAreNonDefaultFiltersSet } from '../../utils/getAreNonDefaultFiltersSet'; + +interface AllGamesDataTableToolbarProps { + table: Table; + unfilteredTotal: number | null; + + defaultColumnFilters?: ColumnFiltersState; +} + +export function AllGamesDataTableToolbar({ + table, + unfilteredTotal, + defaultColumnFilters = [], +}: AllGamesDataTableToolbarProps) { + const { filterableSystemOptions } = usePageProps(); + + const currentFilters = table.getState().columnFilters; + const isFiltered = getAreNonDefaultFiltersSet(currentFilters, defaultColumnFilters); + + const resetFiltersToDefault = () => { + if (defaultColumnFilters) { + table.setColumnFilters(defaultColumnFilters); + } else { + table.resetColumnFilters(); + } + }; + + return ( +
+
+ + + {table.getColumn('system') ? ( + a.name.localeCompare(b.name)) + .map((system) => ({ + label: system.name, + selectedLabel: system.nameShort, + value: String(system.id), + }))} + /> + ) : null} + + {table.getColumn('achievementsPublished') ? ( + + ) : null} + + {isFiltered ? ( + + Reset + + ) : null} +
+ +
+

+ {unfilteredTotal && unfilteredTotal !== table.options.rowCount ? ( + <> + {formatNumber(table.options.rowCount ?? 0)} of {formatNumber(unfilteredTotal)}{' '} + {unfilteredTotal === 1 ? 'game' : 'games'} + + ) : ( + <> + {formatNumber(table.options.rowCount ?? 0)}{' '} + {table.options.rowCount === 1 ? 'game' : 'games'} + + )} +

+ + +
+
+ ); +} diff --git a/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx b/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx new file mode 100644 index 000000000..dc399a46d --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx @@ -0,0 +1,257 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import utc from 'dayjs/plugin/utc'; + +import { DataTableColumnHeader } from '@/common/components/DataTableColumnHeader'; +import { DataTableRowActions } from '@/common/components/DataTableRowActions'; +import { GameAvatar } from '@/common/components/GameAvatar'; +import { PlayerGameProgressBar } from '@/common/components/PlayerGameProgressBar'; +import { SystemChip } from '@/common/components/SystemChip'; +import { WeightedPointsContainer } from '@/common/components/WeightedPointsContainer'; +import { formatDate } from '@/common/utils/l10n/formatDate'; +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +dayjs.extend(utc); +dayjs.extend(localizedFormat); + +export function buildColumnDefinitions(options: { + canSeeOpenTicketsColumn: boolean; + forUsername?: string; +}): ColumnDef[] { + const columnDefinitions: ColumnDef[] = [ + { + id: 'title', + accessorKey: 'game', + meta: { label: 'Title' }, + enableHiding: false, + header: ({ column, table }) => , + cell: ({ row }) => { + // TODO this should be a reusable component + + if (!row.original.game) { + return null; + } + + return ( +
+
+ +
+
+ ); + }, + }, + + { + id: 'system', + accessorKey: 'game', + meta: { label: 'System' }, + header: ({ column, table }) => , + cell: ({ row }) => { + if (!row.original.game?.system) { + return null; + } + + return ; + }, + }, + + { + id: 'achievementsPublished', + accessorKey: 'game', + meta: { label: 'Achievements', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const achievementsPublished = row.original.game?.achievementsPublished ?? 0; + + return ( +

{achievementsPublished}

+ ); + }, + }, + + { + id: 'pointsTotal', + accessorKey: 'game', + meta: { label: 'Points', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const pointsTotal = row.original.game?.pointsTotal ?? 0; + const pointsWeighted = row.original.game?.pointsWeighted ?? 0; + + if (pointsTotal === 0) { + return

{pointsTotal}

; + } + + return ( +

+ {formatNumber(pointsTotal)}{' '} + ({formatNumber(pointsWeighted)}) +

+ ); + }, + }, + + { + id: 'retroRatio', + accessorKey: 'game', + meta: { label: 'Rarity', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const pointsTotal = row.original.game?.pointsTotal ?? 0; + + if (pointsTotal === 0) { + return

none

; + } + + const pointsWeighted = row.original.game?.pointsWeighted ?? 0; + + const result = pointsWeighted / pointsTotal; + + return

×{(Math.round((result + Number.EPSILON) * 100) / 100).toFixed(2)}

; + }, + }, + + { + id: 'lastUpdated', + accessorKey: 'game', + meta: { label: 'Last Updated' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const date = row.original.game?.lastUpdated ?? new Date(); + + return

{formatDate(dayjs.utc(date), 'll')}

; + }, + }, + + { + id: 'releasedAt', + accessorKey: 'game', + meta: { label: 'Release Date' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const date = row.original.game?.releasedAt ?? null; + const granularity = row.original.game?.releasedAtGranularity ?? 'day'; + + if (!date) { + return

unknown

; + } + + const dayjsDate = dayjs.utc(date); + let formattedDate; + if (granularity === 'day') { + formattedDate = formatDate(dayjsDate, 'll'); + } else if (granularity === 'month') { + formattedDate = dayjsDate.format('MMM YYYY'); + } else { + formattedDate = dayjsDate.format('YYYY'); + } + + return

{formattedDate}

; + }, + }, + + { + id: 'playersTotal', + accessorKey: 'game', + meta: { label: 'Players', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const playersTotal = row.original.game?.playersTotal ?? 0; + + return ( +

{formatNumber(playersTotal)}

+ ); + }, + }, + + { + id: 'numVisibleLeaderboards', + accessorKey: 'game', + meta: { label: 'Leaderboards', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const numVisibleLeaderboards = row.original.game?.numVisibleLeaderboards ?? 0; + + return ( +

+ {formatNumber(numVisibleLeaderboards)} +

+ ); + }, + }, + ]; + + if (options.canSeeOpenTicketsColumn) { + columnDefinitions.push({ + id: 'numUnresolvedTickets', + accessorKey: 'game', + meta: { label: 'Tickets', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const numUnresolvedTickets = row.original.game?.numUnresolvedTickets ?? 0; + const gameId = row.original.game?.id ?? 0; + + return ( + + {numUnresolvedTickets} + + ); + }, + }); + } + + columnDefinitions.push( + ...([ + { + id: 'progress', + accessorKey: 'game', + meta: { label: 'Progress', align: 'left' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const { game, playerGame } = row.original; + + return ; + }, + }, + + { + id: 'actions', + cell: ({ row }) => ( + // Prevent spurious tooltip re-openings after a toast pops and closes. +
event.stopPropagation()}> + +
+ ), + }, + ] satisfies ColumnDef[]), + ); + + return columnDefinitions; +} diff --git a/resources/js/features/game-list/components/AllGamesDataTable/index.ts b/resources/js/features/game-list/components/AllGamesDataTable/index.ts new file mode 100644 index 000000000..ffa9a1fdf --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/index.ts @@ -0,0 +1 @@ +export * from './AllGamesDataTable'; diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx new file mode 100644 index 000000000..9fb21cdae --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx @@ -0,0 +1,58 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import { type FC } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import { useAutoUpdatingQueryParams } from '../../hooks/useAutoUpdatingQueryParams'; +import { useGameListState } from '../../hooks/useGameListState'; +import { usePreloadedTableDataQueryClient } from '../../hooks/usePreloadedTableDataQueryClient'; +import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; +import { AllGamesDataTable } from '../AllGamesDataTable'; + +export const AllGamesMainRoot: FC = () => { + const { paginatedGameListEntries } = usePageProps(); + + const { + columnFilters, + columnVisibility, + pagination, + setColumnFilters, + setColumnVisibility, + setPagination, + setSorting, + sorting, + } = useGameListState(paginatedGameListEntries, { defaultColumnFilters: allGamesDefaultFilters }); + + const { queryClientWithInitialData } = usePreloadedTableDataQueryClient({ + columnFilters, + pagination, + sorting, + paginatedData: paginatedGameListEntries, + }); + + useAutoUpdatingQueryParams({ columnFilters, pagination, sorting }); + + return ( +
+ {/* TODO reusable component */} +
+
+

All Games

+
+
+ + + + +
+ ); +}; diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/index.ts b/resources/js/features/game-list/components/AllGamesMainRoot/index.ts new file mode 100644 index 000000000..a7a0a7eb6 --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesMainRoot/index.ts @@ -0,0 +1 @@ +export * from './AllGamesMainRoot'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableRowActions.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableRowActions.tsx deleted file mode 100644 index 5ffe97b1b..000000000 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableRowActions.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import type { Row } from '@tanstack/react-table'; -import { RxCross2 } from 'react-icons/rx'; - -import { BaseButton } from '@/common/components/+vendor/BaseButton'; -import { toastMessage } from '@/common/components/+vendor/BaseToaster'; -import { - BaseTooltip, - BaseTooltipContent, - BaseTooltipTrigger, -} from '@/common/components/+vendor/BaseTooltip'; -import { useAddToBacklogMutation } from '@/common/hooks/useAddToBacklogMutation'; -import { useRemoveFromBacklogMutation } from '@/common/hooks/useRemoveFromBacklogMutation'; - -/** - * If the table row needs to have more than one action, it should go into a menu. - * @see https://ui.shadcn.com/examples/tasks - */ - -interface DataTableRowActionsProps { - row: Row; -} - -export function DataTableRowActions({ row }: DataTableRowActionsProps) { - const queryClient = useQueryClient(); - - const removeFromBacklogMutation = useRemoveFromBacklogMutation(); - - const addToBacklogMutation = useAddToBacklogMutation(); - - const rowData = row.original as { game?: App.Platform.Data.Game }; - const gameId = rowData?.game?.id ?? 0; - const gameTitle = rowData?.game?.title ?? ''; - - const handleRestoreToBacklogClick = () => { - toastMessage.promise(addToBacklogMutation.mutateAsync(gameId), { - loading: 'Removing...', - success: () => { - // Trigger a refetch of the current table page data and bust the entire cache. - queryClient.invalidateQueries({ queryKey: ['data'] }); - - return `Restored ${gameTitle}!`; - }, - error: 'Something went wrong.', - }); - }; - - const handleRemoveFromBacklogClick = () => { - if (gameId) { - toastMessage.promise(removeFromBacklogMutation.mutateAsync(gameId), { - action: { - label: 'Undo', - onClick: handleRestoreToBacklogClick, - }, - loading: 'Removing...', - success: () => { - // Trigger a refetch of the current table page data and bust the entire cache. - queryClient.invalidateQueries({ queryKey: ['data'] }); - - return `Removed ${gameTitle}!`; - }, - error: 'Something went wrong.', - }); - } - }; - - return ( - - -
- - - Remove from backlog - -
-
- - -

Remove

-
-
- ); -} diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx index 4ee026846..c237ca687 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx @@ -5,8 +5,7 @@ import type { VisibilityState, } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { type Dispatch, type FC, type SetStateAction } from 'react'; -import { useMemo } from 'react'; +import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'; import { BaseTable, @@ -16,13 +15,13 @@ import { BaseTableHeader, BaseTableRow, } from '@/common/components/+vendor/BaseTable'; +import { DataTablePagination } from '@/common/components/DataTablePagination'; import { useGameListQuery } from '@/common/hooks/useGameListQuery'; import { usePageProps } from '@/common/hooks/usePageProps'; import { cn } from '@/utils/cn'; import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; import { buildColumnDefinitions } from './buildColumnDefinitions'; -import { DataTablePagination } from './DataTablePagination'; import { WantToPlayGamesDataTableToolbar } from './WantToPlayGamesDataTableToolbar'; // These values are all generated from `useGameListState`. @@ -49,7 +48,12 @@ export const WantToPlayGamesDataTable: FC = ({ }) => { const { can } = usePageProps(); - const gameListQuery = useGameListQuery({ columnFilters, pagination, sorting }); + const gameListQuery = useGameListQuery({ + columnFilters, + pagination, + sorting, + apiRouteName: 'api.user-game-list.index', + }); const table = useReactTable({ columns: useMemo( @@ -158,7 +162,7 @@ export const WantToPlayGamesDataTable: FC = ({ - + ); }; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx index 15309be3d..c4845136f 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx @@ -2,12 +2,13 @@ import type { ColumnFiltersState, Table } from '@tanstack/react-table'; import { RxCross2 } from 'react-icons/rx'; import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { DataTableFacetedFilter } from '@/common/components/DataTableFacetedFilter'; +import { DataTableSearchInput } from '@/common/components/DataTableSearchInput'; +import { DataTableViewOptions } from '@/common/components/DataTableViewOptions'; import { usePageProps } from '@/common/hooks/usePageProps'; import { formatNumber } from '@/common/utils/l10n/formatNumber'; -import { DataTableFacetedFilter } from './DataTableFacetedFilter'; -import { DataTableSearchInput } from './DataTableSearchInput'; -import { DataTableViewOptions } from './DataTableViewOptions'; +import { getAreNonDefaultFiltersSet } from '../../utils/getAreNonDefaultFiltersSet'; interface WantToPlayGamesDataTableToolbarProps { table: Table; @@ -24,7 +25,7 @@ export function WantToPlayGamesDataTableToolbar({ const { filterableSystemOptions } = usePageProps(); const currentFilters = table.getState().columnFilters; - const isFiltered = getHasNonDefaultFilters(currentFilters, defaultColumnFilters); + const isFiltered = getAreNonDefaultFiltersSet(currentFilters, defaultColumnFilters); const resetFiltersToDefault = () => { if (defaultColumnFilters) { @@ -101,19 +102,3 @@ export function WantToPlayGamesDataTableToolbar({ ); } - -// Are there any non-default filters set? If so, we need to show the Reset button. -function getHasNonDefaultFilters( - currentFilters: ColumnFiltersState, - defaultColumnFilters?: ColumnFiltersState, -): boolean { - if (currentFilters.length !== defaultColumnFilters?.length) { - return true; - } - - return currentFilters.some((filter, index) => { - const defaultFilter = defaultColumnFilters[index]; - - return filter.id !== defaultFilter.id || filter.value !== defaultFilter.value; - }); -} diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx index 75da7854c..06963c96f 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx @@ -2,7 +2,10 @@ import type { ColumnDef } from '@tanstack/react-table'; import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import utc from 'dayjs/plugin/utc'; +import type { RouteName } from 'ziggy-js'; +import { DataTableColumnHeader } from '@/common/components/DataTableColumnHeader'; +import { DataTableRowActions } from '@/common/components/DataTableRowActions'; import { GameAvatar } from '@/common/components/GameAvatar'; import { PlayerGameProgressBar } from '@/common/components/PlayerGameProgressBar'; import { SystemChip } from '@/common/components/SystemChip'; @@ -10,12 +13,11 @@ import { WeightedPointsContainer } from '@/common/components/WeightedPointsConta import { formatDate } from '@/common/utils/l10n/formatDate'; import { formatNumber } from '@/common/utils/l10n/formatNumber'; -import { DataTableColumnHeader } from './DataTableColumnHeader'; -import { DataTableRowActions } from './DataTableRowActions'; - dayjs.extend(utc); dayjs.extend(localizedFormat); +const tableApiRouteName: RouteName = 'api.user-game-list.index'; + export function buildColumnDefinitions(options: { canSeeOpenTicketsColumn: boolean; forUsername?: string; @@ -26,7 +28,13 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Title' }, enableHiding: false, - header: ({ column, table }) => , + header: ({ column, table }) => ( + + ), cell: ({ row }) => { if (!row.original.game) { return null; @@ -50,7 +58,13 @@ export function buildColumnDefinitions(options: { id: 'system', accessorKey: 'game', meta: { label: 'System' }, - header: ({ column, table }) => , + header: ({ column, table }) => ( + + ), cell: ({ row }) => { if (!row.original.game?.system) { return null; @@ -65,7 +79,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Achievements', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const achievementsPublished = row.original.game?.achievementsPublished ?? 0; @@ -81,7 +100,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Points', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const pointsTotal = row.original.game?.pointsTotal ?? 0; @@ -105,7 +129,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Rarity', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const pointsTotal = row.original.game?.pointsTotal ?? 0; @@ -127,7 +156,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Last Updated' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const date = row.original.game?.lastUpdated ?? new Date(); @@ -141,7 +175,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Release Date' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const date = row.original.game?.releasedAt ?? null; @@ -170,7 +209,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Players', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const playersTotal = row.original.game?.playersTotal ?? 0; @@ -186,14 +230,19 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Leaderboards', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const numVisibleLeaderboards = row.original.game?.numVisibleLeaderboards ?? 0; return (

- {numVisibleLeaderboards} + {formatNumber(numVisibleLeaderboards)}

); }, @@ -206,7 +255,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Tickets', align: 'right' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const numUnresolvedTickets = row.original.game?.numUnresolvedTickets ?? 0; @@ -231,7 +285,12 @@ export function buildColumnDefinitions(options: { accessorKey: 'game', meta: { label: 'Progress', align: 'left' }, header: ({ column, table }) => ( - + ), cell: ({ row }) => { const { game, playerGame } = row.original; @@ -242,7 +301,12 @@ export function buildColumnDefinitions(options: { { id: 'actions', - cell: ({ row }) => , + cell: ({ row }) => ( + // Prevent spurious tooltip re-openings after a toast pops and closes. +
event.stopPropagation()}> + +
+ ), }, ] satisfies ColumnDef[]), ); diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.test.tsx b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx similarity index 97% rename from resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.test.tsx rename to resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx index ce953a2bd..78cece112 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.test.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx @@ -12,16 +12,16 @@ import { createZiggyProps, } from '@/test/factories'; -import { WantToPlayGamesRoot } from './WantToPlayGamesRoot'; +import { WantToPlayGamesMainRoot } from './WantToPlayGamesMainRoot'; // Suppress AggregateError invocations from unmocked fetch calls to the back-end. console.error = vi.fn(); -describe('Component: WantToPlayGamesRoot', () => { +describe('Component: WantToPlayGamesMainRoot', () => { it('renders without crashing', () => { // ARRANGE const { container } = render( - , + , { pageProps: { filterableSystemOptions: [], @@ -38,7 +38,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('displays default columns', () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -74,7 +74,7 @@ describe('Component: WantToPlayGamesRoot', () => { releasedAtGranularity: 'day', }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -114,7 +114,7 @@ describe('Component: WantToPlayGamesRoot', () => { releasedAtGranularity: 'day', }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -155,7 +155,7 @@ describe('Component: WantToPlayGamesRoot', () => { releasedAtGranularity: 'day', }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -183,7 +183,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('allows users to toggle column visibility', async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -203,7 +203,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('given the user cannot develop achievements, they cannot enable an Open Tickets column', async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -224,7 +224,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('given the user can develop achievements, they can enable an Open Tickets column', async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -261,7 +261,7 @@ describe('Component: WantToPlayGamesRoot', () => { numUnresolvedTickets: 2, }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -283,7 +283,7 @@ describe('Component: WantToPlayGamesRoot', () => { // ARRANGE const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -312,7 +312,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('by default, has the achievements published filter set to "Yes"', () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -331,7 +331,7 @@ describe('Component: WantToPlayGamesRoot', () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [ @@ -371,7 +371,7 @@ describe('Component: WantToPlayGamesRoot', () => { .mockResolvedValueOnce({ data: createPaginatedData([]) }) .mockResolvedValueOnce({ data: createPaginatedData([]) }); // the GET will be called twice - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [ @@ -404,7 +404,7 @@ describe('Component: WantToPlayGamesRoot', () => { data: createPaginatedData([], { total: 3, unfilteredTotal: 587 }), }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -430,7 +430,7 @@ describe('Component: WantToPlayGamesRoot', () => { const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -462,7 +462,7 @@ describe('Component: WantToPlayGamesRoot', () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -494,7 +494,7 @@ describe('Component: WantToPlayGamesRoot', () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -526,7 +526,7 @@ describe('Component: WantToPlayGamesRoot', () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -555,7 +555,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('allows the user to hide a column via the column header button', async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -575,7 +575,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('always displays the number of total games', () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -594,7 +594,7 @@ describe('Component: WantToPlayGamesRoot', () => { window.scrollTo = vi.fn(); const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], @@ -626,7 +626,7 @@ describe('Component: WantToPlayGamesRoot', () => { it("given the user presses the '/' hotkey, focuses the search input", async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.tsx b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx similarity index 97% rename from resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.tsx rename to resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx index 34be217ad..569d1dae7 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx @@ -10,7 +10,7 @@ import { usePreloadedTableDataQueryClient } from '../../hooks/usePreloadedTableD import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; import { WantToPlayGamesDataTable } from '../WantToPlayGamesDataTable'; -export const WantToPlayGamesRoot: FC = () => { +export const WantToPlayGamesMainRoot: FC = () => { const { auth, paginatedGameListEntries } = usePageProps(); diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/index.ts b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/index.ts index 0aef7f801..98d13f1f9 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/index.ts +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/index.ts @@ -1 +1 @@ -export * from './WantToPlayGamesRoot'; +export * from './WantToPlayGamesMainRoot'; diff --git a/resources/js/features/game-list/utils/allGamesDefaultFilters.ts b/resources/js/features/game-list/utils/allGamesDefaultFilters.ts new file mode 100644 index 000000000..8b7b54278 --- /dev/null +++ b/resources/js/features/game-list/utils/allGamesDefaultFilters.ts @@ -0,0 +1,5 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +export const allGamesDefaultFilters: ColumnFiltersState = [ + { id: 'achievementsPublished', value: ['has'] }, +]; diff --git a/resources/js/features/game-list/utils/getAreNonDefaultFiltersSet.ts b/resources/js/features/game-list/utils/getAreNonDefaultFiltersSet.ts new file mode 100644 index 000000000..f32253bca --- /dev/null +++ b/resources/js/features/game-list/utils/getAreNonDefaultFiltersSet.ts @@ -0,0 +1,16 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +export function getAreNonDefaultFiltersSet( + currentFilters: ColumnFiltersState, + defaultColumnFilters?: ColumnFiltersState, +): boolean { + if (currentFilters.length !== defaultColumnFilters?.length) { + return true; + } + + return currentFilters.some((filter, index) => { + const defaultFilter = defaultColumnFilters[index]; + + return filter.id !== defaultFilter.id || filter.value !== defaultFilter.value; + }); +} diff --git a/resources/js/pages/game-list/index.tsx b/resources/js/pages/game-list/index.tsx new file mode 100644 index 000000000..2fd76b1ed --- /dev/null +++ b/resources/js/pages/game-list/index.tsx @@ -0,0 +1,25 @@ +import { Head } from '@inertiajs/react'; + +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { AllGamesMainRoot } from '@/features/game-list/components/AllGamesMainRoot'; + +const AllGames: AppPage = () => { + return ( + <> + + + + +
+ + + +
+ + ); +}; + +AllGames.layout = (page) => {page}; + +export default AllGames; diff --git a/resources/js/pages/game-list/play.tsx b/resources/js/pages/game-list/play.tsx index 6c7a37669..cc6401a20 100644 --- a/resources/js/pages/game-list/play.tsx +++ b/resources/js/pages/game-list/play.tsx @@ -2,7 +2,7 @@ import { Head } from '@inertiajs/react'; import { AppLayout } from '@/common/layouts/AppLayout'; import type { AppPage } from '@/common/models'; -import { WantToPlayGamesRoot } from '@/features/game-list/components/WantToPlayGamesMainRoot'; +import { WantToPlayGamesMainRoot } from '@/features/game-list/components/WantToPlayGamesMainRoot'; const WantToPlayGames: AppPage = () => { return ( @@ -13,7 +13,7 @@ const WantToPlayGames: AppPage = () => {
- +
diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 5727382a6..25c6d4333 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -177,6 +177,11 @@ declare namespace App.Platform.Data { playerGame: App.Platform.Data.PlayerGame | null; isInBacklog: boolean | null; }; + export type GameListPageProps = { + paginatedGameListEntries: App.Data.PaginatedData; + filterableSystemOptions: Array; + can: App.Data.UserPermissions; + }; export type PlayerBadge = { awardType: number; awardData: number; diff --git a/resources/js/ziggy.d.ts b/resources/js/ziggy.d.ts index e3364ecee..309957428 100644 --- a/resources/js/ziggy.d.ts +++ b/resources/js/ziggy.d.ts @@ -243,6 +243,7 @@ declare module 'ziggy-js' { "claims.completed": [], "claims.active": [], "pulse": [], + "api.game-list.index": [], "game.hashes.index": [ { "name": "game", @@ -250,6 +251,7 @@ declare module 'ziggy-js' { "binding": "ID" } ], + "game-list.index": [], "game.random": [], "api.user.game.destroy": [ { From 2e46d95c9888343320c62d5ff8e31de9c487cbe7 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 29 Sep 2024 20:46:38 -0400 Subject: [PATCH 02/11] feat: finalize --- app/Helpers/render/game.php | 4 +- ...piController.php => GameApiController.php} | 9 +- app/Platform/Controllers/GameController.php | 48 +- .../Controllers/GameListController.php | 74 -- app/Platform/RouteServiceProvider.php | 8 +- app/Providers/RouteServiceProvider.php | 1 - .../components/+vendor/BaseBreadcrumb.tsx | 1 + .../useGameListQuery/useGameListQuery.ts | 2 +- .../AchievementBreadcrumbs.test.tsx | 2 +- .../AchievementBreadcrumbs.tsx | 5 +- .../AllGamesDataTable/AllGamesDataTable.tsx | 82 +-- .../AllGamesDataTableToolbar.tsx | 6 +- .../buildColumnDefinitions.tsx | 261 +------ .../AllGamesMainRoot.test.tsx | 686 ++++++++++++++++++ .../DataTableColumnHeader.tsx | 12 +- .../components/DataTableColumnHeader/index.ts | 0 .../DataTableFacetedFilter.tsx | 19 +- .../DataTableFacetedFilter/index.ts | 0 .../DataTablePagination.tsx | 7 +- .../components/DataTablePagination/index.ts | 0 .../DataTableRowActions.tsx | 15 +- .../components/DataTableRowActions/index.ts | 0 .../DataTableSearchInput.tsx | 0 .../components/DataTableSearchInput/index.ts | 0 .../useSearchInputHotkey.ts | 0 .../DataTableViewOptions.tsx | 4 +- .../components/DataTableViewOptions/index.ts | 0 .../GameListDataTable/GameListDataTable.tsx | 87 +++ .../components/GameListDataTable/index.ts | 0 .../WantToPlayGamesDataTable.tsx | 81 +-- .../WantToPlayGamesDataTableToolbar.tsx | 6 +- .../buildColumnDefinitions.tsx | 316 +------- .../WantToPlayGamesMainRoot.test.tsx | 8 +- .../hooks/useDataTablePrefetchPagination.ts | 4 +- .../hooks/useDataTablePrefetchSort.ts | 4 +- .../buildAchievementsPublishedColumnDef.tsx | 33 + .../buildLastUpdatedColumnDef.tsx | 39 + .../buildNumUnresolvedTicketsColumnDef.tsx | 41 ++ .../buildNumVisibleLeaderboardsColumnDef.tsx | 37 + .../buildPlayerGameProgressColumnDef.tsx | 33 + .../buildPlayersTotalColumnDef.tsx | 33 + .../buildPointsTotalColumnDef.tsx | 44 ++ .../buildReleasedAtColumnDef.tsx | 54 ++ .../buildRetroRatioColumnDef.tsx | 39 + .../buildRowActionsColumnDef.tsx | 15 + .../buildSystemColumnDef.tsx | 30 + .../buildTitleColumnDef.tsx | 43 ++ .../GameBreadcrumbs/GameBreadcrumbs.test.tsx | 2 +- .../GameBreadcrumbs/GameBreadcrumbs.tsx | 5 +- resources/js/test/setup.tsx | 13 +- resources/js/ziggy.d.ts | 5 +- .../achievement/breadcrumbs.blade.php | 2 +- .../components/game/breadcrumbs.blade.php | 2 +- .../global-statistics.blade.php | 2 +- .../views/components/head-analytics.blade.php | 8 - .../leaderboard/breadcrumbs.blade.php | 2 +- .../views/components/menu/main.blade.php | 2 +- .../Action/BuildGameListActionTest.php | 26 + .../Controllers/Api/GameApiControllerTest.php | 68 ++ .../Controllers/GameControllerTest.php | 44 ++ 60 files changed, 1535 insertions(+), 839 deletions(-) rename app/Platform/Controllers/Api/{GameListApiController.php => GameApiController.php} (88%) delete mode 100644 app/Platform/Controllers/GameListController.php create mode 100644 resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx rename resources/js/{common => features/game-list}/components/DataTableColumnHeader/DataTableColumnHeader.tsx (96%) rename resources/js/{common => features/game-list}/components/DataTableColumnHeader/index.ts (100%) rename resources/js/{common => features/game-list}/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx (92%) rename resources/js/{common => features/game-list}/components/DataTableFacetedFilter/index.ts (100%) rename resources/js/{common => features/game-list}/components/DataTablePagination/DataTablePagination.tsx (94%) rename resources/js/{common => features/game-list}/components/DataTablePagination/index.ts (100%) rename resources/js/{common => features/game-list}/components/DataTableRowActions/DataTableRowActions.tsx (88%) rename resources/js/{common => features/game-list}/components/DataTableRowActions/index.ts (100%) rename resources/js/{common => features/game-list}/components/DataTableSearchInput/DataTableSearchInput.tsx (100%) rename resources/js/{common => features/game-list}/components/DataTableSearchInput/index.ts (100%) rename resources/js/{common => features/game-list}/components/DataTableSearchInput/useSearchInputHotkey.ts (100%) rename resources/js/{common => features/game-list}/components/DataTableViewOptions/DataTableViewOptions.tsx (92%) rename resources/js/{common => features/game-list}/components/DataTableViewOptions/index.ts (100%) create mode 100644 resources/js/features/game-list/components/GameListDataTable/GameListDataTable.tsx create mode 100644 resources/js/features/game-list/components/GameListDataTable/index.ts rename resources/js/{common => features/game-list}/hooks/useDataTablePrefetchPagination.ts (88%) rename resources/js/{common => features/game-list}/hooks/useDataTablePrefetchSort.ts (88%) create mode 100644 resources/js/features/game-list/utils/column-definitions/buildAchievementsPublishedColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildLastUpdatedColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildNumUnresolvedTicketsColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildNumVisibleLeaderboardsColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildPlayerGameProgressColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildPlayersTotalColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildPointsTotalColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildReleasedAtColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildRetroRatioColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildRowActionsColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildSystemColumnDef.tsx create mode 100644 resources/js/features/game-list/utils/column-definitions/buildTitleColumnDef.tsx create mode 100644 tests/Feature/Platform/Controllers/Api/GameApiControllerTest.php create mode 100644 tests/Feature/Platform/Controllers/GameControllerTest.php diff --git a/app/Helpers/render/game.php b/app/Helpers/render/game.php index f205aa200..6939b6561 100644 --- a/app/Helpers/render/game.php +++ b/app/Helpers/render/game.php @@ -121,11 +121,13 @@ function renderGameBreadcrumb(array|int $data, bool $addLinkToLastCrumb = true): return [$mainID, $renderedMain, $subsetID ?? null, $renderedSubset ?? null]; }; + $allGamesHref = route('game.index'); + $gameListHref = System::isGameSystem($consoleID) ? route('system.game.index', ['system' => $consoleID]) : '/gameList.php?c=' . $consoleID; - $html = "All Games" + $html = "All Games" . $nextCrumb($consoleName, $gameListHref); [$mainID, $renderedMain, $subsetID, $renderedSubset] = $getSplitData($data); diff --git a/app/Platform/Controllers/Api/GameListApiController.php b/app/Platform/Controllers/Api/GameApiController.php similarity index 88% rename from app/Platform/Controllers/Api/GameListApiController.php rename to app/Platform/Controllers/Api/GameApiController.php index 1cf8e14ba..c0c773c8c 100644 --- a/app/Platform/Controllers/Api/GameListApiController.php +++ b/app/Platform/Controllers/Api/GameApiController.php @@ -2,16 +2,19 @@ namespace App\Platform\Controllers\Api; -use App\Platform\Requests\GameListRequest; use App\Http\Controller; +use App\Models\Game; use App\Platform\Actions\BuildGameListAction; use App\Platform\Enums\GameListType; +use App\Platform\Requests\GameListRequest; use Illuminate\Http\JsonResponse; -class GameListApiController extends Controller +class GameApiController extends Controller { public function index(GameListRequest $request): JsonResponse { + $this->authorize('viewAny', Game::class); + $paginatedData = (new BuildGameListAction())->execute( GameListType::AllGames, user: $request->user(), @@ -46,4 +49,4 @@ public function update(): void public function destroy(): void { } -} \ No newline at end of file +} diff --git a/app/Platform/Controllers/GameController.php b/app/Platform/Controllers/GameController.php index 1a7813ff1..c79f4b749 100644 --- a/app/Platform/Controllers/GameController.php +++ b/app/Platform/Controllers/GameController.php @@ -4,13 +4,22 @@ namespace App\Platform\Controllers; +use App\Data\UserPermissionsData; use App\Http\Controller; use App\Models\Game; use App\Models\System; +use App\Models\User; +use App\Platform\Actions\BuildGameListAction; +use App\Platform\Data\GameListPagePropsData; +use App\Platform\Data\SystemData; +use App\Platform\Enums\GameListType; +use App\Platform\Requests\GameListRequest; use App\Platform\Requests\GameRequest; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class GameController extends Controller { @@ -19,16 +28,37 @@ protected function resourceName(): string return 'game'; } - public function index(): View + public function index(GameListRequest $request): InertiaResponse { - $this->authorize('viewAny', $this->resourceClass()); - - /* - * TODO: if slug is empty or does not match -> redirect to correctly slugged url - */ - - return view('resource.index') - ->with('resource', $this->resourceName()); + /** @var ?User $user */ + $user = $request->user(); + + $this->authorize('viewAny', [Game::class, $user]); + + $paginatedData = (new BuildGameListAction())->execute( + GameListType::AllGames, + user: $user, + page: $request->getPage(), + filters: $request->getFilters(), + sort: $request->getSort(), + ); + + $filterableSystemOptions = System::active() + ->gameSystems() + ->get() + ->map(fn ($system) => SystemData::fromSystem($system)->include('nameShort')) + ->values() + ->all(); + + $can = UserPermissionsData::fromUser($user)->include('develop'); + + $props = new GameListPagePropsData( + paginatedGameListEntries: $paginatedData, + filterableSystemOptions: $filterableSystemOptions, + can: $can, + ); + + return Inertia::render('game-list/index', $props); } public function popular(): void diff --git a/app/Platform/Controllers/GameListController.php b/app/Platform/Controllers/GameListController.php deleted file mode 100644 index 8ae16270d..000000000 --- a/app/Platform/Controllers/GameListController.php +++ /dev/null @@ -1,74 +0,0 @@ -user(); - - $paginatedData = (new BuildGameListAction())->execute( - GameListType::AllGames, - user: $user, - page: $request->getPage(), - filters: $request->getFilters(), - sort: $request->getSort(), - ); - - $filterableSystemOptions = System::active() - ->get() - ->map(fn ($system) => SystemData::fromSystem($system)->include('nameShort')) - ->values() - ->all(); - - $can = UserPermissionsData::fromUser($user)->include('develop'); - - $props = new GameListPagePropsData( - paginatedGameListEntries: $paginatedData, - filterableSystemOptions: $filterableSystemOptions, - can: $can, - ); - - return Inertia::render('game-list/index', $props); - } - - public function create(): void - { - } - - public function store(): void - { - } - - public function show(): void - { - } - - public function edit(): void - { - } - - public function update(): void - { - } - - public function destroy(): void - { - } -} diff --git a/app/Platform/RouteServiceProvider.php b/app/Platform/RouteServiceProvider.php index 93c92c12b..c821fa69a 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -6,10 +6,9 @@ use App\Models\GameHash; use App\Platform\Controllers\AchievementController; -use App\Platform\Controllers\Api\GameListApiController; +use App\Platform\Controllers\Api\GameApiController; use App\Platform\Controllers\GameController; use App\Platform\Controllers\GameHashController; -use App\Platform\Controllers\GameListController; use App\Platform\Controllers\PlayerAchievementController; use App\Platform\Controllers\PlayerGameController; use App\Platform\Controllers\ReportAchievementIssueController; @@ -47,13 +46,13 @@ protected function mapWebRoutes(): void { Route::middleware(['web', 'csp'])->group(function () { Route::group(['prefix' => 'internal-api'], function () { - Route::get('game-list', [GameListApiController::class, 'index'])->name('api.game-list.index'); + Route::get('games', [GameApiController::class, 'index'])->name('api.game.index'); }); Route::middleware(['inertia'])->group(function () { Route::get('game/{game}/hashes', [GameHashController::class, 'index'])->name('game.hashes.index'); - Route::get('game-list', [GameListController::class, 'index'])->name('game-list.index'); + Route::get('games', [GameController::class, 'index'])->name('game.index'); }); // Route::get('achievement/{achievement}{slug?}', [AchievementController::class, 'show'])->name('achievement.show'); @@ -73,7 +72,6 @@ protected function mapWebRoutes(): void // ->name('system.achievement.index'); // Route::get('game/{game}{slug?}', [GameController::class, 'show'])->name('game.show'); - // Route::resource('games', GameController::class)->only('index')->names(['index' => 'game.index']); // Route::get('games/popular', [GameController::class, 'popular'])->name('games.popular'); // Route::get('game/{game}/badges', [GameBadgeController::class, 'index'])->name('game.badge.index'); // Route::get('game/{game}/assets', [GameAssetsController::class, 'index'])->name('game.asset.index'); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 90b9de276..479ea4175 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -64,7 +64,6 @@ protected function mapWebRoutes(): void Route::middleware(['web', 'csp'])->group(function () { Route::get('download.php', fn () => $this->handlePageRequest('download'))->name('download.index'); - Route::get('gameList.php', fn () => $this->handlePageRequest('gameList'))->name('game.index'); Route::get('{path}.php', fn (string $path) => $this->handlePageRequest($path))->where('path', '(.*)'); Route::get('user/{user}', fn (string $user) => $this->handlePageRequest('userInfo', $user))->name('user.show'); Route::get('achievement/{achievement}{slug?}', fn ($achievement) => $this->handlePageRequest('achievementInfo', $achievement))->name('achievement.show'); diff --git a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx index a760c7b9a..fdbfeb171 100644 --- a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx +++ b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ +import { Link } from '@inertiajs/react'; import { Slot } from '@radix-ui/react-slot'; import { type ComponentProps, diff --git a/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts b/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts index b75e25a0e..703e272fe 100644 --- a/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts +++ b/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts @@ -20,7 +20,7 @@ export function useGameListQuery({ columnFilters, pagination, sorting, - apiRouteName = 'api.game-list.index', + apiRouteName = 'api.game.index', }: UseGameListQueryProps) { const dataQuery = useQuery>({ // eslint-disable-next-line @tanstack/query/exhaustive-deps -- tableApiRouteName is not part of the key diff --git a/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx b/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx index 598386934..ba46ddf40 100644 --- a/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx +++ b/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx @@ -19,7 +19,7 @@ describe('Component: AchievementBreadcrumbs', () => { // ASSERT const allGamesLinkEl = screen.getByRole('link', { name: /all games/i }); expect(allGamesLinkEl).toBeVisible(); - expect(allGamesLinkEl).toHaveAttribute('href', '/gameList.php'); + expect(allGamesLinkEl).toHaveAttribute('href', 'game.index'); }); it('given a system, has a link to the system games list', () => { diff --git a/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.tsx b/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.tsx index edfce5e67..d85f4219c 100644 --- a/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.tsx +++ b/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.tsx @@ -1,3 +1,4 @@ +import { Link } from '@inertiajs/react'; import type { FC } from 'react'; import { @@ -34,7 +35,9 @@ export const AchievementBreadcrumbs: FC = ({ - All Games + + All Games + {system ? ( diff --git a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx index 64581c18b..bc9a92928 100644 --- a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx @@ -4,23 +4,15 @@ import type { SortingState, VisibilityState, } from '@tanstack/react-table'; -import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'; -import { - BaseTable, - BaseTableBody, - BaseTableCell, - BaseTableHead, - BaseTableHeader, - BaseTableRow, -} from '@/common/components/+vendor/BaseTable'; -import { DataTablePagination } from '@/common/components/DataTablePagination'; import { useGameListQuery } from '@/common/hooks/useGameListQuery'; import { usePageProps } from '@/common/hooks/usePageProps'; -import { cn } from '@/utils/cn'; +import { DataTablePagination } from '@/features/game-list/components/DataTablePagination'; import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; +import { GameListDataTable } from '../GameListDataTable/GameListDataTable'; import { AllGamesDataTableToolbar } from './AllGamesDataTableToolbar'; import { buildColumnDefinitions } from './buildColumnDefinitions'; @@ -82,8 +74,6 @@ export const AllGamesDataTable: FC = ({ state: { columnFilters, columnVisibility, pagination, sorting }, }); - const visibleColumnCount = table.getVisibleFlatColumns().length; - return (
= ({ defaultColumnFilters={allGamesDefaultFilters} /> - {/* TODO reusable component */} - 8 ? 'lg:!overflow-x-scroll' : '', - visibleColumnCount > 10 ? 'xl:!overflow-x-scroll' : '', - )} - > - - {table.getHeaderGroups().map((headerGroup) => ( - 8 ? 'lg:!top-0' : '', - visibleColumnCount > 10 ? 'xl:!top-0' : '', - )} - > - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - - +
diff --git a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx index 90f1dc54d..b88d86b70 100644 --- a/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx @@ -1,10 +1,10 @@ import type { ColumnFiltersState, Table } from '@tanstack/react-table'; import { RxCross2 } from 'react-icons/rx'; +import { DataTableFacetedFilter } from '@/features/game-list/components/DataTableFacetedFilter'; +import { DataTableSearchInput } from '@/features/game-list/components/DataTableSearchInput'; +import { DataTableViewOptions } from '@/features/game-list/components/DataTableViewOptions'; import { BaseButton } from '@/common/components/+vendor/BaseButton'; -import { DataTableFacetedFilter } from '@/common/components/DataTableFacetedFilter'; -import { DataTableSearchInput } from '@/common/components/DataTableSearchInput'; -import { DataTableViewOptions } from '@/common/components/DataTableViewOptions'; import { usePageProps } from '@/common/hooks/usePageProps'; import { formatNumber } from '@/common/utils/l10n/formatNumber'; diff --git a/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx b/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx index dc399a46d..497f3e6d7 100644 --- a/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx +++ b/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx @@ -1,255 +1,42 @@ import type { ColumnDef } from '@tanstack/react-table'; -import dayjs from 'dayjs'; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import utc from 'dayjs/plugin/utc'; -import { DataTableColumnHeader } from '@/common/components/DataTableColumnHeader'; -import { DataTableRowActions } from '@/common/components/DataTableRowActions'; -import { GameAvatar } from '@/common/components/GameAvatar'; -import { PlayerGameProgressBar } from '@/common/components/PlayerGameProgressBar'; -import { SystemChip } from '@/common/components/SystemChip'; -import { WeightedPointsContainer } from '@/common/components/WeightedPointsContainer'; -import { formatDate } from '@/common/utils/l10n/formatDate'; -import { formatNumber } from '@/common/utils/l10n/formatNumber'; - -dayjs.extend(utc); -dayjs.extend(localizedFormat); +import { buildAchievementsPublishedColumnDef } from '../../utils/column-definitions/buildAchievementsPublishedColumnDef'; +import { buildLastUpdatedColumnDef } from '../../utils/column-definitions/buildLastUpdatedColumnDef'; +import { buildNumUnresolvedTicketsColumnDef } from '../../utils/column-definitions/buildNumUnresolvedTicketsColumnDef'; +import { buildNumVisibleLeaderboardsColumnDef } from '../../utils/column-definitions/buildNumVisibleLeaderboardsColumnDef'; +import { buildPlayerGameProgressColumnDef } from '../../utils/column-definitions/buildPlayerGameProgressColumnDef'; +import { buildPlayersTotalColumnDef } from '../../utils/column-definitions/buildPlayersTotalColumnDef'; +import { buildPointsTotalColumnDef } from '../../utils/column-definitions/buildPointsTotalColumnDef'; +import { buildReleasedAtColumnDef } from '../../utils/column-definitions/buildReleasedAtColumnDef'; +import { buildRetroRatioColumnDef } from '../../utils/column-definitions/buildRetroRatioColumnDef'; +import { buildRowActionsColumnDef } from '../../utils/column-definitions/buildRowActionsColumnDef'; +import { buildSystemColumnDef } from '../../utils/column-definitions/buildSystemColumnDef'; +import { buildTitleColumnDef } from '../../utils/column-definitions/buildTitleColumnDef'; export function buildColumnDefinitions(options: { canSeeOpenTicketsColumn: boolean; forUsername?: string; }): ColumnDef[] { const columnDefinitions: ColumnDef[] = [ - { - id: 'title', - accessorKey: 'game', - meta: { label: 'Title' }, - enableHiding: false, - header: ({ column, table }) => , - cell: ({ row }) => { - // TODO this should be a reusable component - - if (!row.original.game) { - return null; - } - - return ( -
-
- -
-
- ); - }, - }, - - { - id: 'system', - accessorKey: 'game', - meta: { label: 'System' }, - header: ({ column, table }) => , - cell: ({ row }) => { - if (!row.original.game?.system) { - return null; - } - - return ; - }, - }, - - { - id: 'achievementsPublished', - accessorKey: 'game', - meta: { label: 'Achievements', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const achievementsPublished = row.original.game?.achievementsPublished ?? 0; - - return ( -

{achievementsPublished}

- ); - }, - }, - - { - id: 'pointsTotal', - accessorKey: 'game', - meta: { label: 'Points', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const pointsTotal = row.original.game?.pointsTotal ?? 0; - const pointsWeighted = row.original.game?.pointsWeighted ?? 0; - - if (pointsTotal === 0) { - return

{pointsTotal}

; - } - - return ( -

- {formatNumber(pointsTotal)}{' '} - ({formatNumber(pointsWeighted)}) -

- ); - }, - }, - - { - id: 'retroRatio', - accessorKey: 'game', - meta: { label: 'Rarity', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const pointsTotal = row.original.game?.pointsTotal ?? 0; - - if (pointsTotal === 0) { - return

none

; - } - - const pointsWeighted = row.original.game?.pointsWeighted ?? 0; - - const result = pointsWeighted / pointsTotal; - - return

×{(Math.round((result + Number.EPSILON) * 100) / 100).toFixed(2)}

; - }, - }, - - { - id: 'lastUpdated', - accessorKey: 'game', - meta: { label: 'Last Updated' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const date = row.original.game?.lastUpdated ?? new Date(); - - return

{formatDate(dayjs.utc(date), 'll')}

; - }, - }, - - { - id: 'releasedAt', - accessorKey: 'game', - meta: { label: 'Release Date' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const date = row.original.game?.releasedAt ?? null; - const granularity = row.original.game?.releasedAtGranularity ?? 'day'; - - if (!date) { - return

unknown

; - } - - const dayjsDate = dayjs.utc(date); - let formattedDate; - if (granularity === 'day') { - formattedDate = formatDate(dayjsDate, 'll'); - } else if (granularity === 'month') { - formattedDate = dayjsDate.format('MMM YYYY'); - } else { - formattedDate = dayjsDate.format('YYYY'); - } - - return

{formattedDate}

; - }, - }, - - { - id: 'playersTotal', - accessorKey: 'game', - meta: { label: 'Players', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const playersTotal = row.original.game?.playersTotal ?? 0; - - return ( -

{formatNumber(playersTotal)}

- ); - }, - }, - - { - id: 'numVisibleLeaderboards', - accessorKey: 'game', - meta: { label: 'Leaderboards', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const numVisibleLeaderboards = row.original.game?.numVisibleLeaderboards ?? 0; - - return ( -

- {formatNumber(numVisibleLeaderboards)} -

- ); - }, - }, + buildTitleColumnDef({ forUsername: options.forUsername }), + buildSystemColumnDef({}), + buildAchievementsPublishedColumnDef({}), + buildPointsTotalColumnDef({}), + buildRetroRatioColumnDef({}), + buildLastUpdatedColumnDef({}), + buildReleasedAtColumnDef({}), + buildPlayersTotalColumnDef({}), + buildNumVisibleLeaderboardsColumnDef({}), ]; if (options.canSeeOpenTicketsColumn) { - columnDefinitions.push({ - id: 'numUnresolvedTickets', - accessorKey: 'game', - meta: { label: 'Tickets', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const numUnresolvedTickets = row.original.game?.numUnresolvedTickets ?? 0; - const gameId = row.original.game?.id ?? 0; - - return ( - - {numUnresolvedTickets} - - ); - }, - }); + columnDefinitions.push(buildNumUnresolvedTicketsColumnDef({})); } columnDefinitions.push( ...([ - { - id: 'progress', - accessorKey: 'game', - meta: { label: 'Progress', align: 'left' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const { game, playerGame } = row.original; - - return ; - }, - }, - - { - id: 'actions', - cell: ({ row }) => ( - // Prevent spurious tooltip re-openings after a toast pops and closes. -
event.stopPropagation()}> - -
- ), - }, + buildPlayerGameProgressColumnDef({}), + buildRowActionsColumnDef(), ] satisfies ColumnDef[]), ); diff --git a/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx new file mode 100644 index 000000000..58e118bbf --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx @@ -0,0 +1,686 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { createAuthenticatedUser } from '@/common/models'; +import { UserGameListType } from '@/common/utils/generatedAppConstants'; +import { render, screen, waitFor } from '@/test'; +import { + createGame, + createGameListEntry, + createPaginatedData, + createSystem, + createZiggyProps, +} from '@/test/factories'; + +import { AllGamesMainRoot } from './AllGamesMainRoot'; + +// Suppress AggregateError invocations from unmocked fetch calls to the back-end. +console.error = vi.fn(); + +describe('Component: AllGamesMainRoot', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('displays default columns', () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ASSERT + expect(screen.getByRole('columnheader', { name: /title/i })); + expect(screen.getByRole('columnheader', { name: /system/i })); + expect(screen.getByRole('columnheader', { name: /achievements/i })); + expect(screen.getByRole('columnheader', { name: /points/i })); + expect(screen.getByRole('columnheader', { name: /rarity/i })); + expect(screen.getByRole('columnheader', { name: /release date/i })); + }); + + it('shows game rows', () => { + // ARRANGE + const mockSystem = createSystem({ + nameShort: 'MD', + iconUrl: 'https://retroachievements.org/test.png', + }); + + const mockGame = createGame({ + title: 'Sonic the Hedgehog', + system: mockSystem, + achievementsPublished: 42, + pointsTotal: 500, + pointsWeighted: 1000, + releasedAt: '2006-08-24T00:56:00+00:00', + releasedAtGranularity: 'day', + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, playerGame: null }), + ]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ASSERT + expect(screen.getByRole('cell', { name: /sonic/i })).toBeVisible(); + expect(screen.getByRole('cell', { name: /md/i })).toBeVisible(); + expect(screen.getByRole('cell', { name: '42' })).toBeVisible(); + expect(screen.getByRole('cell', { name: '500 (1,000)' })).toBeVisible(); + expect(screen.getByRole('cell', { name: '×2.00' })).toBeVisible(); + expect(screen.getByRole('cell', { name: 'Aug 24, 2006' })).toBeVisible(); + }); + + it('allows users to add games to their backlog', async () => { + // ARRANGE + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ success: true }); + + const mockSystem = createSystem({ + nameShort: 'MD', + iconUrl: 'https://retroachievements.org/test.png', + }); + + const mockGame = createGame({ + title: 'Sonic the Hedgehog', + system: mockSystem, + achievementsPublished: 42, + pointsTotal: 500, + pointsWeighted: 1000, + releasedAt: '2006-08-24T00:56:00+00:00', + releasedAtGranularity: 'day', + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: false }), + ]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /add to want to play/i })); + + // ASSERT + expect(postSpy).toHaveBeenCalledWith(['api.user-game-list.store', mockGame.id], { + userGameListType: UserGameListType.Play, + }); + }); + + it('allows users to remove games from their backlog', async () => { + // ARRANGE + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + const mockSystem = createSystem({ + nameShort: 'MD', + iconUrl: 'https://retroachievements.org/test.png', + }); + + const mockGame = createGame({ + title: 'Sonic the Hedgehog', + system: mockSystem, + achievementsPublished: 42, + pointsTotal: 500, + pointsWeighted: 1000, + releasedAt: '2006-08-24T00:56:00+00:00', + releasedAtGranularity: 'day', + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /remove/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(['api.user-game-list.destroy', mockGame.id], { + data: { userGameListType: UserGameListType.Play }, + }); + }); + + it('allows users to undo removing games from their backlog', async () => { + // ARRANGE + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ success: true }); + + const mockSystem = createSystem({ + nameShort: 'MD', + iconUrl: 'https://retroachievements.org/test.png', + }); + + const mockGame = createGame({ + title: 'Sonic the Hedgehog', + system: mockSystem, + achievementsPublished: 42, + pointsTotal: 500, + pointsWeighted: 1000, + releasedAt: '2006-08-24T00:56:00+00:00', + releasedAtGranularity: 'day', + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /remove/i })); + + const undoButtonEl = await screen.findByRole('button', { name: /undo/i }); + await userEvent.click(undoButtonEl); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(['api.user-game-list.destroy', mockGame.id], { + data: { userGameListType: UserGameListType.Play }, + }); + + expect(postSpy).toHaveBeenCalledWith(['api.user-game-list.store', mockGame.id], { + userGameListType: UserGameListType.Play, + }); + }); + + it('allows users to toggle column visibility', async () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /view/i })); + await userEvent.click(screen.getByRole('menuitemcheckbox', { name: /points/i })); + + // ASSERT + expect(screen.queryByRole('columnheader', { name: /points/i })).not.toBeInTheDocument(); + }); + + it('given the user cannot develop achievements, they cannot enable an Open Tickets column', async () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /view/i })); + + // ASSERT + expect( + screen.queryByRole('menuitemcheckbox', { name: /open tickets/i }), + ).not.toBeInTheDocument(); + }); + + it('given the user can develop achievements, they can enable an Open Tickets column', async () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: true }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /view/i })); + await userEvent.click(screen.getByRole('menuitemcheckbox', { name: /tickets/i })); + + // ASSERT + expect(screen.getByRole('columnheader', { name: /tickets/i })).toBeVisible(); + }); + + it('given a game row has a non-zero amount of open tickets, the cell links to the tickets page', async () => { + // ARRANGE + const mockSystem = createSystem({ + nameShort: 'MD', + iconUrl: 'https://retroachievements.org/test.png', + }); + + const mockGame = createGame({ + id: 1, + title: 'Sonic the Hedgehog', + system: mockSystem, + achievementsPublished: 42, + pointsTotal: 500, + pointsWeighted: 1000, + releasedAt: '2006-08-24T00:56:00+00:00', + releasedAtGranularity: 'day', + numUnresolvedTickets: 2, + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([createGameListEntry({ game: mockGame })]), + can: { develop: true }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /view/i })); + await userEvent.click(screen.getByRole('menuitemcheckbox', { name: /tickets/i })); + + // ASSERT + expect(screen.getByRole('link', { name: '2' })); + }); + + it('allows the user to search for games on the list', async () => { + // ARRANGE + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.type(screen.getByRole('textbox', { name: /search games/i }), 'dragon quest'); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'filter[title]': 'dragon quest', + 'page[number]': 1, + sort: null, + }, + ]); + }); + }); + + it('by default, has the achievements published filter set to "Yes"', () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ASSERT + expect(screen.getByRole('button', { name: /yes/i })).toBeVisible(); + }); + + it('allows the user to filter by system/console', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [ + createSystem({ id: 1, name: 'Genesis/Mega Drive', nameShort: 'MD' }), + ], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('filter-System')); + await userEvent.click(screen.getByRole('option', { name: /genesis/i })); + + // ASSERT + const systemFilterButtonEl = screen.getByTestId('filter-System'); + expect(systemFilterButtonEl).toHaveTextContent(/md/i); + + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'filter[system]': '1', + 'page[number]': 1, + sort: null, + }, + ]); + }); + }); + + it('given a non-default filter is set, allows the user to click a button to reset their filters', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + vi.spyOn(axios, 'get') + .mockResolvedValueOnce({ data: createPaginatedData([]) }) + .mockResolvedValueOnce({ data: createPaginatedData([]) }); // the GET will be called twice + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [ + createSystem({ id: 1, name: 'Genesis/Mega Drive', nameShort: 'MD' }), + ], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('filter-System')); + await userEvent.click(screen.getByRole('option', { name: /genesis/i })); + + await userEvent.click(screen.getByRole('button', { name: /reset/i })); + + // ASSERT + const defaultFilterButtonEl = screen.getByTestId('filter-Has achievements'); + expect(defaultFilterButtonEl).toHaveTextContent(/yes/i); + + const systemFilterButtonEl = screen.getByTestId('filter-System'); + expect(systemFilterButtonEl).not.toHaveTextContent(/md/i); + }); + + it('given a filter is currently applied, shows both the filtered and unfiltered game totals', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: createPaginatedData([], { total: 3, unfilteredTotal: 587 }), + }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('filter-System')); + await userEvent.click(screen.getByRole('option', { name: /genesis/i })); + + // ASSERT + await waitFor(() => { + expect(screen.getByText(/3 of 587 games/i)).toBeVisible(); + }); + }); + + it('allows the user to change the "has achievements" filter, with only a single option being set', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('filter-Has achievements')); + await userEvent.click(screen.getByRole('option', { name: /no/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'none', + 'page[number]': 1, + sort: null, + }, + ]); + }); + }); + + it('allows the user to sort by a string column', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('column-header-System')); + await userEvent.click(screen.getByRole('menuitem', { name: /desc/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'page[number]': 1, + sort: '-system', + }, + ]); + }); + }); + + it('allows the user to sort by a numeric column', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('column-header-Achievements')); + await userEvent.click(screen.getByRole('menuitem', { name: /less/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'page[number]': 1, + sort: 'achievementsPublished', + }, + ]); + }); + }); + + it('allows the user to sort by a date column', async () => { + // ARRANGE + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('column-header-Release Date')); + await userEvent.click(screen.getByRole('menuitem', { name: /earliest/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'page[number]': 1, + sort: 'releasedAt', + }, + ]); + }); + }); + + it('allows the user to hide a column via the column header button', async () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByTestId('column-header-Achievements')); + await userEvent.click(screen.getByRole('menuitem', { name: /hide/i })); + + // ASSERT + expect(screen.queryByTestId('column-header-Achievements')).not.toBeInTheDocument(); + }); + + it('always displays the number of total games', () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([], { total: 300 }), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ASSERT + expect(screen.getByText(/300 games/i)).toBeVisible(); + }); + + it('given there are multiple pages, allows the user to advance to the next page', async () => { + // ARRANGE + window.scrollTo = vi.fn(); + const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([createGameListEntry()], { + total: 300, + currentPage: 1, + perPage: 1, + }), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /next page/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.game.index', + { + 'filter[achievementsPublished]': 'has', + 'page[number]': 2, + sort: null, + }, + ]); + }); + }); + + it("given the user presses the '/' hotkey, focuses the search input", async () => { + // ARRANGE + render(, { + pageProps: { + auth: { user: createAuthenticatedUser() }, + filterableSystemOptions: [createSystem({ id: 1, name: 'Genesis/Mega Drive' })], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + ziggy: createZiggyProps(), + }, + }); + + // ACT + await userEvent.keyboard('/'); + + // ASSERT + expect(screen.getByRole('textbox', { name: /search/i })).toHaveFocus(); + }); +}); diff --git a/resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx b/resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx similarity index 96% rename from resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx rename to resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx index b0aef32a5..f527d2037 100644 --- a/resources/js/common/components/DataTableColumnHeader/DataTableColumnHeader.tsx +++ b/resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx @@ -4,17 +4,17 @@ import type { IconType } from 'react-icons/lib'; import { RxArrowDown, RxArrowUp, RxCaretSort, RxEyeNone } from 'react-icons/rx'; import type { RouteName } from 'ziggy-js'; -import { cn } from '@/utils/cn'; - -import { useDataTablePrefetchSort } from '../../hooks/useDataTablePrefetchSort'; -import { BaseButton } from '../+vendor/BaseButton'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; import { BaseDropdownMenu, BaseDropdownMenuContent, BaseDropdownMenuItem, BaseDropdownMenuSeparator, BaseDropdownMenuTrigger, -} from '../+vendor/BaseDropdownMenu'; +} from '@/common/components/+vendor/BaseDropdownMenu'; +import { cn } from '@/utils/cn'; + +import { useDataTablePrefetchSort } from '../../hooks/useDataTablePrefetchSort'; type SortDirection = 'asc' | 'desc'; type SortConfig = { @@ -58,7 +58,7 @@ export function DataTableColumnHeader({ column, table, sortType = 'default', - tableApiRouteName = 'api.game-list.index', + tableApiRouteName = 'api.game.index', }: DataTableColumnHeaderProps): ReactNode { const { prefetchSort } = useDataTablePrefetchSort(table, tableApiRouteName); diff --git a/resources/js/common/components/DataTableColumnHeader/index.ts b/resources/js/features/game-list/components/DataTableColumnHeader/index.ts similarity index 100% rename from resources/js/common/components/DataTableColumnHeader/index.ts rename to resources/js/features/game-list/components/DataTableColumnHeader/index.ts diff --git a/resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx b/resources/js/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx similarity index 92% rename from resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx rename to resources/js/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx index 233fa4bb7..040d2b1ac 100644 --- a/resources/js/common/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx +++ b/resources/js/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx @@ -2,11 +2,8 @@ import type { Column } from '@tanstack/react-table'; import type { FC } from 'react'; import { RxCheck, RxPlusCircled } from 'react-icons/rx'; -import { cn } from '@/utils/cn'; - -import { buildTrackingClassNames } from '../../utils/buildTrackingClassNames'; -import { BaseBadge } from '../+vendor/BaseBadge'; -import { BaseButton } from '../+vendor/BaseButton'; +import { BaseBadge } from '@/common/components/+vendor/BaseBadge'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; import { BaseCommand, BaseCommandEmpty, @@ -15,9 +12,15 @@ import { BaseCommandItem, BaseCommandList, BaseCommandSeparator, -} from '../+vendor/BaseCommand'; -import { BasePopover, BasePopoverContent, BasePopoverTrigger } from '../+vendor/BasePopover'; -import { BaseSeparator } from '../+vendor/BaseSeparator'; +} from '@/common/components/+vendor/BaseCommand'; +import { + BasePopover, + BasePopoverContent, + BasePopoverTrigger, +} from '@/common/components/+vendor/BasePopover'; +import { BaseSeparator } from '@/common/components/+vendor/BaseSeparator'; +import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; +import { cn } from '@/utils/cn'; interface DataTableFacetedFilterProps { options: Array<{ diff --git a/resources/js/common/components/DataTableFacetedFilter/index.ts b/resources/js/features/game-list/components/DataTableFacetedFilter/index.ts similarity index 100% rename from resources/js/common/components/DataTableFacetedFilter/index.ts rename to resources/js/features/game-list/components/DataTableFacetedFilter/index.ts diff --git a/resources/js/common/components/DataTablePagination/DataTablePagination.tsx b/resources/js/features/game-list/components/DataTablePagination/DataTablePagination.tsx similarity index 94% rename from resources/js/common/components/DataTablePagination/DataTablePagination.tsx rename to resources/js/features/game-list/components/DataTablePagination/DataTablePagination.tsx index 16c37b2f0..b5f5f82dd 100644 --- a/resources/js/common/components/DataTablePagination/DataTablePagination.tsx +++ b/resources/js/features/game-list/components/DataTablePagination/DataTablePagination.tsx @@ -3,9 +3,10 @@ import type { ReactNode } from 'react'; import { LuArrowLeft, LuArrowLeftToLine, LuArrowRight, LuArrowRightToLine } 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 { useDataTablePrefetchPagination } from '../../hooks/useDataTablePrefetchPagination'; -import { BaseButton } from '../+vendor/BaseButton'; -import { BasePagination, BasePaginationContent } from '../+vendor/BasePagination'; interface DataTablePaginationProps { table: Table; @@ -14,7 +15,7 @@ interface DataTablePaginationProps { export function DataTablePagination({ table, - tableApiRouteName = 'api.game-list.index', + tableApiRouteName = 'api.game.index', }: DataTablePaginationProps): ReactNode { const { pagination } = table.getState(); diff --git a/resources/js/common/components/DataTablePagination/index.ts b/resources/js/features/game-list/components/DataTablePagination/index.ts similarity index 100% rename from resources/js/common/components/DataTablePagination/index.ts rename to resources/js/features/game-list/components/DataTablePagination/index.ts diff --git a/resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx b/resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx similarity index 88% rename from resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx rename to resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx index c393697ef..498f6520e 100644 --- a/resources/js/common/components/DataTableRowActions/DataTableRowActions.tsx +++ b/resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx @@ -2,15 +2,18 @@ import { useQueryClient } from '@tanstack/react-query'; import type { Row } from '@tanstack/react-table'; import { MdClose } from 'react-icons/md'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import { + BaseTooltip, + BaseTooltipContent, + BaseTooltipTrigger, +} from '@/common/components/+vendor/BaseTooltip'; +import { useAddToBacklogMutation } from '@/common/hooks/useAddToBacklogMutation'; import { usePageProps } from '@/common/hooks/usePageProps'; +import { useRemoveFromBacklogMutation } from '@/common/hooks/useRemoveFromBacklogMutation'; import { cn } from '@/utils/cn'; -import { useAddToBacklogMutation } from '../../hooks/useAddToBacklogMutation'; -import { useRemoveFromBacklogMutation } from '../../hooks/useRemoveFromBacklogMutation'; -import { BaseButton } from '../+vendor/BaseButton'; -import { toastMessage } from '../+vendor/BaseToaster'; -import { BaseTooltip, BaseTooltipContent, BaseTooltipTrigger } from '../+vendor/BaseTooltip'; - /** * If the table row needs to have more than one action, it should go into a menu. * @see https://ui.shadcn.com/examples/tasks diff --git a/resources/js/common/components/DataTableRowActions/index.ts b/resources/js/features/game-list/components/DataTableRowActions/index.ts similarity index 100% rename from resources/js/common/components/DataTableRowActions/index.ts rename to resources/js/features/game-list/components/DataTableRowActions/index.ts diff --git a/resources/js/common/components/DataTableSearchInput/DataTableSearchInput.tsx b/resources/js/features/game-list/components/DataTableSearchInput/DataTableSearchInput.tsx similarity index 100% rename from resources/js/common/components/DataTableSearchInput/DataTableSearchInput.tsx rename to resources/js/features/game-list/components/DataTableSearchInput/DataTableSearchInput.tsx diff --git a/resources/js/common/components/DataTableSearchInput/index.ts b/resources/js/features/game-list/components/DataTableSearchInput/index.ts similarity index 100% rename from resources/js/common/components/DataTableSearchInput/index.ts rename to resources/js/features/game-list/components/DataTableSearchInput/index.ts diff --git a/resources/js/common/components/DataTableSearchInput/useSearchInputHotkey.ts b/resources/js/features/game-list/components/DataTableSearchInput/useSearchInputHotkey.ts similarity index 100% rename from resources/js/common/components/DataTableSearchInput/useSearchInputHotkey.ts rename to resources/js/features/game-list/components/DataTableSearchInput/useSearchInputHotkey.ts diff --git a/resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx b/resources/js/features/game-list/components/DataTableViewOptions/DataTableViewOptions.tsx similarity index 92% rename from resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx rename to resources/js/features/game-list/components/DataTableViewOptions/DataTableViewOptions.tsx index 59069e4ce..3d6ef6ad0 100644 --- a/resources/js/common/components/DataTableViewOptions/DataTableViewOptions.tsx +++ b/resources/js/features/game-list/components/DataTableViewOptions/DataTableViewOptions.tsx @@ -1,7 +1,7 @@ import type { Table } from '@tanstack/react-table'; import { RxMixerHorizontal } from 'react-icons/rx'; -import { BaseButton } from '../+vendor/BaseButton'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; import { BaseDropdownMenu, BaseDropdownMenuCheckboxItem, @@ -9,7 +9,7 @@ import { BaseDropdownMenuLabel, BaseDropdownMenuSeparator, BaseDropdownMenuTrigger, -} from '../+vendor/BaseDropdownMenu'; +} from '@/common/components/+vendor/BaseDropdownMenu'; interface DataTableViewOptionsProps { table: Table; diff --git a/resources/js/common/components/DataTableViewOptions/index.ts b/resources/js/features/game-list/components/DataTableViewOptions/index.ts similarity index 100% rename from resources/js/common/components/DataTableViewOptions/index.ts rename to resources/js/features/game-list/components/DataTableViewOptions/index.ts diff --git a/resources/js/features/game-list/components/GameListDataTable/GameListDataTable.tsx b/resources/js/features/game-list/components/GameListDataTable/GameListDataTable.tsx new file mode 100644 index 000000000..b960567f0 --- /dev/null +++ b/resources/js/features/game-list/components/GameListDataTable/GameListDataTable.tsx @@ -0,0 +1,87 @@ +import type { Table } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; + +import { + BaseTable, + BaseTableBody, + BaseTableCell, + BaseTableHead, + BaseTableHeader, + BaseTableRow, +} from '@/common/components/+vendor/BaseTable'; +import { cn } from '@/utils/cn'; + +interface GameListDataTableProps { + table: Table; +} + +export function GameListDataTable({ table }: GameListDataTableProps) { + const visibleColumnCount = table.getVisibleFlatColumns().length; + + return ( + 8 ? 'lg:!overflow-x-scroll' : '', + visibleColumnCount > 10 ? 'xl:!overflow-x-scroll' : '', + )} + > + + {table.getHeaderGroups().map((headerGroup) => ( + 8 ? 'lg:!top-0' : '', + visibleColumnCount > 10 ? 'xl:!top-0' : '', + )} + > + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + ); +} diff --git a/resources/js/features/game-list/components/GameListDataTable/index.ts b/resources/js/features/game-list/components/GameListDataTable/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx index c237ca687..8a9cdb039 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx @@ -4,23 +4,15 @@ import type { SortingState, VisibilityState, } from '@tanstack/react-table'; -import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'; -import { - BaseTable, - BaseTableBody, - BaseTableCell, - BaseTableHead, - BaseTableHeader, - BaseTableRow, -} from '@/common/components/+vendor/BaseTable'; -import { DataTablePagination } from '@/common/components/DataTablePagination'; import { useGameListQuery } from '@/common/hooks/useGameListQuery'; import { usePageProps } from '@/common/hooks/usePageProps'; -import { cn } from '@/utils/cn'; +import { DataTablePagination } from '@/features/game-list/components/DataTablePagination'; import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; +import { GameListDataTable } from '../GameListDataTable/GameListDataTable'; import { buildColumnDefinitions } from './buildColumnDefinitions'; import { WantToPlayGamesDataTableToolbar } from './WantToPlayGamesDataTableToolbar'; @@ -87,8 +79,6 @@ export const WantToPlayGamesDataTable: FC = ({ state: { columnFilters, columnVisibility, pagination, sorting }, }); - const visibleColumnCount = table.getVisibleFlatColumns().length; - return (
= ({ defaultColumnFilters={wantToPlayGamesDefaultFilters} /> - 8 ? 'lg:!overflow-x-scroll' : '', - visibleColumnCount > 10 ? 'xl:!overflow-x-scroll' : '', - )} - > - - {table.getHeaderGroups().map((headerGroup) => ( - 8 ? 'lg:!top-0' : '', - visibleColumnCount > 10 ? 'xl:!top-0' : '', - )} - > - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - - +
diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx index c4845136f..a7b42454e 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx @@ -1,10 +1,10 @@ import type { ColumnFiltersState, Table } from '@tanstack/react-table'; import { RxCross2 } from 'react-icons/rx'; +import { DataTableFacetedFilter } from '@/features/game-list/components/DataTableFacetedFilter'; +import { DataTableSearchInput } from '@/features/game-list/components/DataTableSearchInput'; +import { DataTableViewOptions } from '@/features/game-list/components/DataTableViewOptions'; import { BaseButton } from '@/common/components/+vendor/BaseButton'; -import { DataTableFacetedFilter } from '@/common/components/DataTableFacetedFilter'; -import { DataTableSearchInput } from '@/common/components/DataTableSearchInput'; -import { DataTableViewOptions } from '@/common/components/DataTableViewOptions'; import { usePageProps } from '@/common/hooks/usePageProps'; import { formatNumber } from '@/common/utils/l10n/formatNumber'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx b/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx index 06963c96f..307cc4a44 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx @@ -1,20 +1,18 @@ import type { ColumnDef } from '@tanstack/react-table'; -import dayjs from 'dayjs'; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import utc from 'dayjs/plugin/utc'; import type { RouteName } from 'ziggy-js'; -import { DataTableColumnHeader } from '@/common/components/DataTableColumnHeader'; -import { DataTableRowActions } from '@/common/components/DataTableRowActions'; -import { GameAvatar } from '@/common/components/GameAvatar'; -import { PlayerGameProgressBar } from '@/common/components/PlayerGameProgressBar'; -import { SystemChip } from '@/common/components/SystemChip'; -import { WeightedPointsContainer } from '@/common/components/WeightedPointsContainer'; -import { formatDate } from '@/common/utils/l10n/formatDate'; -import { formatNumber } from '@/common/utils/l10n/formatNumber'; - -dayjs.extend(utc); -dayjs.extend(localizedFormat); +import { buildAchievementsPublishedColumnDef } from '../../utils/column-definitions/buildAchievementsPublishedColumnDef'; +import { buildLastUpdatedColumnDef } from '../../utils/column-definitions/buildLastUpdatedColumnDef'; +import { buildNumUnresolvedTicketsColumnDef } from '../../utils/column-definitions/buildNumUnresolvedTicketsColumnDef'; +import { buildNumVisibleLeaderboardsColumnDef } from '../../utils/column-definitions/buildNumVisibleLeaderboardsColumnDef'; +import { buildPlayerGameProgressColumnDef } from '../../utils/column-definitions/buildPlayerGameProgressColumnDef'; +import { buildPlayersTotalColumnDef } from '../../utils/column-definitions/buildPlayersTotalColumnDef'; +import { buildPointsTotalColumnDef } from '../../utils/column-definitions/buildPointsTotalColumnDef'; +import { buildReleasedAtColumnDef } from '../../utils/column-definitions/buildReleasedAtColumnDef'; +import { buildRetroRatioColumnDef } from '../../utils/column-definitions/buildRetroRatioColumnDef'; +import { buildRowActionsColumnDef } from '../../utils/column-definitions/buildRowActionsColumnDef'; +import { buildSystemColumnDef } from '../../utils/column-definitions/buildSystemColumnDef'; +import { buildTitleColumnDef } from '../../utils/column-definitions/buildTitleColumnDef'; const tableApiRouteName: RouteName = 'api.user-game-list.index'; @@ -23,291 +21,25 @@ export function buildColumnDefinitions(options: { forUsername?: string; }): ColumnDef[] { const columnDefinitions: ColumnDef[] = [ - { - id: 'title', - accessorKey: 'game', - meta: { label: 'Title' }, - enableHiding: false, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - if (!row.original.game) { - return null; - } - - return ( -
-
- -
-
- ); - }, - }, - - { - id: 'system', - accessorKey: 'game', - meta: { label: 'System' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - if (!row.original.game?.system) { - return null; - } - - return ; - }, - }, - - { - id: 'achievementsPublished', - accessorKey: 'game', - meta: { label: 'Achievements', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const achievementsPublished = row.original.game?.achievementsPublished ?? 0; - - return ( -

{achievementsPublished}

- ); - }, - }, - - { - id: 'pointsTotal', - accessorKey: 'game', - meta: { label: 'Points', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const pointsTotal = row.original.game?.pointsTotal ?? 0; - const pointsWeighted = row.original.game?.pointsWeighted ?? 0; - - if (pointsTotal === 0) { - return

{pointsTotal}

; - } - - return ( -

- {formatNumber(pointsTotal)}{' '} - ({formatNumber(pointsWeighted)}) -

- ); - }, - }, - - { - id: 'retroRatio', - accessorKey: 'game', - meta: { label: 'Rarity', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const pointsTotal = row.original.game?.pointsTotal ?? 0; - - if (pointsTotal === 0) { - return

none

; - } - - const pointsWeighted = row.original.game?.pointsWeighted ?? 0; - - const result = pointsWeighted / pointsTotal; - - return

×{(Math.round((result + Number.EPSILON) * 100) / 100).toFixed(2)}

; - }, - }, - - { - id: 'lastUpdated', - accessorKey: 'game', - meta: { label: 'Last Updated' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const date = row.original.game?.lastUpdated ?? new Date(); - - return

{formatDate(dayjs.utc(date), 'll')}

; - }, - }, - - { - id: 'releasedAt', - accessorKey: 'game', - meta: { label: 'Release Date' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const date = row.original.game?.releasedAt ?? null; - const granularity = row.original.game?.releasedAtGranularity ?? 'day'; - - if (!date) { - return

unknown

; - } - - const dayjsDate = dayjs.utc(date); - let formattedDate; - if (granularity === 'day') { - formattedDate = formatDate(dayjsDate, 'll'); - } else if (granularity === 'month') { - formattedDate = dayjsDate.format('MMM YYYY'); - } else { - formattedDate = dayjsDate.format('YYYY'); - } - - return

{formattedDate}

; - }, - }, - - { - id: 'playersTotal', - accessorKey: 'game', - meta: { label: 'Players', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const playersTotal = row.original.game?.playersTotal ?? 0; - - return ( -

{formatNumber(playersTotal)}

- ); - }, - }, - - { - id: 'numVisibleLeaderboards', - accessorKey: 'game', - meta: { label: 'Leaderboards', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const numVisibleLeaderboards = row.original.game?.numVisibleLeaderboards ?? 0; - - return ( -

- {formatNumber(numVisibleLeaderboards)} -

- ); - }, - }, + buildTitleColumnDef({ tableApiRouteName, forUsername: options.forUsername }), + buildSystemColumnDef({ tableApiRouteName }), + buildAchievementsPublishedColumnDef({ tableApiRouteName }), + buildPointsTotalColumnDef({ tableApiRouteName }), + buildRetroRatioColumnDef({ tableApiRouteName }), + buildLastUpdatedColumnDef({ tableApiRouteName }), + buildReleasedAtColumnDef({ tableApiRouteName }), + buildPlayersTotalColumnDef({ tableApiRouteName }), + buildNumVisibleLeaderboardsColumnDef({ tableApiRouteName }), ]; if (options.canSeeOpenTicketsColumn) { - columnDefinitions.push({ - id: 'numUnresolvedTickets', - accessorKey: 'game', - meta: { label: 'Tickets', align: 'right' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const numUnresolvedTickets = row.original.game?.numUnresolvedTickets ?? 0; - const gameId = row.original.game?.id ?? 0; - - return ( - - {numUnresolvedTickets} - - ); - }, - }); + columnDefinitions.push(buildNumUnresolvedTicketsColumnDef({ tableApiRouteName })); } columnDefinitions.push( ...([ - { - id: 'progress', - accessorKey: 'game', - meta: { label: 'Progress', align: 'left' }, - header: ({ column, table }) => ( - - ), - cell: ({ row }) => { - const { game, playerGame } = row.original; - - return ; - }, - }, - - { - id: 'actions', - cell: ({ row }) => ( - // Prevent spurious tooltip re-openings after a toast pops and closes. -
event.stopPropagation()}> - -
- ), - }, + buildPlayerGameProgressColumnDef({ tableApiRouteName }), + buildRowActionsColumnDef(), ] satisfies ColumnDef[]), ); diff --git a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx index 78cece112..2c69a616b 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.test.tsx @@ -118,7 +118,9 @@ describe('Component: WantToPlayGamesMainRoot', () => { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], - paginatedGameListEntries: createPaginatedData([createGameListEntry({ game: mockGame })]), + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), can: { develop: false }, ziggy: createZiggyProps(), }, @@ -159,7 +161,9 @@ describe('Component: WantToPlayGamesMainRoot', () => { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], - paginatedGameListEntries: createPaginatedData([createGameListEntry({ game: mockGame })]), + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), can: { develop: false }, ziggy: createZiggyProps(), }, diff --git a/resources/js/common/hooks/useDataTablePrefetchPagination.ts b/resources/js/features/game-list/hooks/useDataTablePrefetchPagination.ts similarity index 88% rename from resources/js/common/hooks/useDataTablePrefetchPagination.ts rename to resources/js/features/game-list/hooks/useDataTablePrefetchPagination.ts index 752fd05d9..844c8de65 100644 --- a/resources/js/common/hooks/useDataTablePrefetchPagination.ts +++ b/resources/js/features/game-list/hooks/useDataTablePrefetchPagination.ts @@ -3,8 +3,8 @@ import type { Table } from '@tanstack/react-table'; import axios from 'axios'; import type { RouteName } from 'ziggy-js'; -import { buildGameListQueryFilterParams } from '../utils/buildGameListQueryFilterParams'; -import { buildGameListQuerySortParam } from '../utils/buildGameListQuerySortParam'; +import { buildGameListQueryFilterParams } from '@/common/utils/buildGameListQueryFilterParams'; +import { buildGameListQuerySortParam } from '@/common/utils/buildGameListQuerySortParam'; /** * Given the user hovers over a pagination button, it is very likely they will diff --git a/resources/js/common/hooks/useDataTablePrefetchSort.ts b/resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts similarity index 88% rename from resources/js/common/hooks/useDataTablePrefetchSort.ts rename to resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts index bfb0d819c..7880309dc 100644 --- a/resources/js/common/hooks/useDataTablePrefetchSort.ts +++ b/resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts @@ -3,8 +3,8 @@ import type { Table } from '@tanstack/react-table'; import axios from 'axios'; import type { RouteName } from 'ziggy-js'; -import { buildGameListQueryFilterParams } from '../utils/buildGameListQueryFilterParams'; -import { buildGameListQuerySortParam } from '../utils/buildGameListQuerySortParam'; +import { buildGameListQueryFilterParams } from '@/common/utils/buildGameListQueryFilterParams'; +import { buildGameListQuerySortParam } from '@/common/utils/buildGameListQuerySortParam'; /** * Given the user hovers over a sort option, it is very likely they will diff --git a/resources/js/features/game-list/utils/column-definitions/buildAchievementsPublishedColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildAchievementsPublishedColumnDef.tsx new file mode 100644 index 000000000..66fef4c8d --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildAchievementsPublishedColumnDef.tsx @@ -0,0 +1,33 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildAchievementsPublishedColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildAchievementsPublishedColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildAchievementsPublishedColumnDefProps): ColumnDef { + return { + id: 'achievementsPublished', + accessorKey: 'game', + meta: { label: 'Achievements', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const achievementsPublished = row.original.game?.achievementsPublished ?? 0; + + return ( +

{achievementsPublished}

+ ); + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildLastUpdatedColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildLastUpdatedColumnDef.tsx new file mode 100644 index 000000000..2fd634611 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildLastUpdatedColumnDef.tsx @@ -0,0 +1,39 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import utc from 'dayjs/plugin/utc'; +import type { RouteName } from 'ziggy-js'; + +import { formatDate } from '@/common/utils/l10n/formatDate'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +dayjs.extend(utc); +dayjs.extend(localizedFormat); + +interface BuildLastUpdatedColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildLastUpdatedColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildLastUpdatedColumnDefProps): ColumnDef { + return { + id: 'lastUpdated', + accessorKey: 'game', + meta: { label: 'Last Updated' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const date = row.original.game?.lastUpdated ?? new Date(); + + return

{formatDate(dayjs.utc(date), 'll')}

; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildNumUnresolvedTicketsColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildNumUnresolvedTicketsColumnDef.tsx new file mode 100644 index 000000000..256e860bd --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildNumUnresolvedTicketsColumnDef.tsx @@ -0,0 +1,41 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildNumUnresolvedTicketsColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildNumUnresolvedTicketsColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildNumUnresolvedTicketsColumnDefProps): ColumnDef { + return { + id: 'numUnresolvedTickets', + accessorKey: 'game', + meta: { label: 'Tickets', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const numUnresolvedTickets = row.original.game?.numUnresolvedTickets ?? 0; + const gameId = row.original.game?.id ?? 0; + + return ( + + {formatNumber(numUnresolvedTickets)} + + ); + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildNumVisibleLeaderboardsColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildNumVisibleLeaderboardsColumnDef.tsx new file mode 100644 index 000000000..bd2f93ae7 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildNumVisibleLeaderboardsColumnDef.tsx @@ -0,0 +1,37 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildNumVisibleLeaderboardsColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildNumVisibleLeaderboardsColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildNumVisibleLeaderboardsColumnDefProps): ColumnDef { + return { + id: 'numVisibleLeaderboards', + accessorKey: 'game', + meta: { label: 'Leaderboards', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const numVisibleLeaderboards = row.original.game?.numVisibleLeaderboards ?? 0; + + return ( +

+ {formatNumber(numVisibleLeaderboards)} +

+ ); + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildPlayerGameProgressColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildPlayerGameProgressColumnDef.tsx new file mode 100644 index 000000000..c69ae7cab --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildPlayerGameProgressColumnDef.tsx @@ -0,0 +1,33 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { PlayerGameProgressBar } from '@/common/components/PlayerGameProgressBar'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildPlayerGameProgressColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildPlayerGameProgressColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildPlayerGameProgressColumnDefProps): ColumnDef { + return { + id: 'progress', + accessorKey: 'game', + meta: { label: 'Progress', align: 'left' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const { game, playerGame } = row.original; + + return ; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildPlayersTotalColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildPlayersTotalColumnDef.tsx new file mode 100644 index 000000000..0c913f61b --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildPlayersTotalColumnDef.tsx @@ -0,0 +1,33 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildPlayersTotalColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildPlayersTotalColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildPlayersTotalColumnDefProps): ColumnDef { + return { + id: 'playersTotal', + accessorKey: 'game', + meta: { label: 'Players', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const playersTotal = row.original.game?.playersTotal ?? 0; + + return

{formatNumber(playersTotal)}

; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildPointsTotalColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildPointsTotalColumnDef.tsx new file mode 100644 index 000000000..f93a76f2f --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildPointsTotalColumnDef.tsx @@ -0,0 +1,44 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { WeightedPointsContainer } from '@/common/components/WeightedPointsContainer'; +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildPointsTotalColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildPointsTotalColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildPointsTotalColumnDefProps): ColumnDef { + return { + id: 'pointsTotal', + accessorKey: 'game', + meta: { label: 'Points', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const pointsTotal = row.original.game?.pointsTotal ?? 0; + const pointsWeighted = row.original.game?.pointsWeighted ?? 0; + + if (pointsTotal === 0) { + return

{pointsTotal}

; + } + + return ( +

+ {formatNumber(pointsTotal)}{' '} + ({formatNumber(pointsWeighted)}) +

+ ); + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildReleasedAtColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildReleasedAtColumnDef.tsx new file mode 100644 index 000000000..93b963795 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildReleasedAtColumnDef.tsx @@ -0,0 +1,54 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import utc from 'dayjs/plugin/utc'; +import type { RouteName } from 'ziggy-js'; + +import { formatDate } from '@/common/utils/l10n/formatDate'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +dayjs.extend(utc); +dayjs.extend(localizedFormat); + +interface BuildReleasedAtColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildReleasedAtColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildReleasedAtColumnDefProps): ColumnDef { + return { + id: 'releasedAt', + accessorKey: 'game', + meta: { label: 'Release Date' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const date = row.original.game?.releasedAt ?? null; + const granularity = row.original.game?.releasedAtGranularity ?? 'day'; + + if (!date) { + return

unknown

; + } + + const dayjsDate = dayjs.utc(date); + let formattedDate; + if (granularity === 'day') { + formattedDate = formatDate(dayjsDate, 'll'); + } else if (granularity === 'month') { + formattedDate = dayjsDate.format('MMM YYYY'); + } else { + formattedDate = dayjsDate.format('YYYY'); + } + + return

{formattedDate}

; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildRetroRatioColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildRetroRatioColumnDef.tsx new file mode 100644 index 000000000..54f740674 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildRetroRatioColumnDef.tsx @@ -0,0 +1,39 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildRetroRatioColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildRetroRatioColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildRetroRatioColumnDefProps): ColumnDef { + return { + id: 'retroRatio', + accessorKey: 'game', + meta: { label: 'Rarity', align: 'right' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + const pointsTotal = row.original.game?.pointsTotal ?? 0; + + if (pointsTotal === 0) { + return

none

; + } + + const pointsWeighted = row.original.game?.pointsWeighted ?? 0; + + const result = pointsWeighted / pointsTotal; + + return

×{(Math.round((result + Number.EPSILON) * 100) / 100).toFixed(2)}

; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildRowActionsColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildRowActionsColumnDef.tsx new file mode 100644 index 000000000..ab2d1eb01 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildRowActionsColumnDef.tsx @@ -0,0 +1,15 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTableRowActions } from '../../components/DataTableRowActions'; + +export function buildRowActionsColumnDef(): ColumnDef { + return { + id: 'actions', + cell: ({ row }) => ( + // Prevent spurious tooltip re-openings after a toast pops and closes. +
event.stopPropagation()}> + +
+ ), + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildSystemColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildSystemColumnDef.tsx new file mode 100644 index 000000000..f651a733f --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildSystemColumnDef.tsx @@ -0,0 +1,30 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { SystemChip } from '@/common/components/SystemChip'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildSystemColumnDefProps { + tableApiRouteName?: RouteName; +} + +export function buildSystemColumnDef({ + tableApiRouteName = 'api.game.index', +}: BuildSystemColumnDefProps): ColumnDef { + return { + id: 'system', + accessorKey: 'game', + meta: { label: 'System' }, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + if (!row.original.game?.system) { + return null; + } + + return ; + }, + }; +} diff --git a/resources/js/features/game-list/utils/column-definitions/buildTitleColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildTitleColumnDef.tsx new file mode 100644 index 000000000..d8e0195b8 --- /dev/null +++ b/resources/js/features/game-list/utils/column-definitions/buildTitleColumnDef.tsx @@ -0,0 +1,43 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { RouteName } from 'ziggy-js'; + +import { GameAvatar } from '@/common/components/GameAvatar'; + +import { DataTableColumnHeader } from '../../components/DataTableColumnHeader'; + +interface BuildTitleColumnDefProps { + forUsername?: string; + tableApiRouteName?: RouteName; +} + +export function buildTitleColumnDef({ + forUsername, + tableApiRouteName = 'api.game.index', +}: BuildTitleColumnDefProps): ColumnDef { + return { + id: 'title', + accessorKey: 'game', + meta: { label: 'Title' }, + enableHiding: false, + header: ({ column, table }) => ( + + ), + cell: ({ row }) => { + if (!row.original.game) { + return null; + } + + return ( +
+
+ +
+
+ ); + }, + }; +} diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx index 8aac7a7fb..a599f316e 100644 --- a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx @@ -19,7 +19,7 @@ describe('Component: GameBreadcrumbs', () => { // ASSERT const allGamesLinkEl = screen.getByRole('link', { name: /all games/i }); expect(allGamesLinkEl).toBeVisible(); - expect(allGamesLinkEl).toHaveAttribute('href', '/gameList.php'); + expect(allGamesLinkEl).toHaveAttribute('href', 'game.index'); }); it('given a system, has a link to the system games list', () => { diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx index 3062571a0..bb61e6e43 100644 --- a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx @@ -1,3 +1,4 @@ +import { Link } from '@inertiajs/react'; import type { FC } from 'react'; import { @@ -22,7 +23,9 @@ export const GameBreadcrumbs: FC = ({ currentPageLabel, ga - All Games + + All Games + {system ? ( diff --git a/resources/js/test/setup.tsx b/resources/js/test/setup.tsx index b32122ca8..eb7688d0b 100644 --- a/resources/js/test/setup.tsx +++ b/resources/js/test/setup.tsx @@ -9,10 +9,15 @@ import type { AppGlobalProps } from '@/common/models'; export * from '@testing-library/react'; -vi.mock('@inertiajs/react', () => ({ - __esModule: true, - usePage: vi.fn(), -})); +vi.mock('@inertiajs/react', async (importOriginal) => { + const original = (await importOriginal()) as any; + + return { + ...original, + __esModule: true, + usePage: vi.fn(), + }; +}); /* |-------------------------------------------------------------------------- diff --git a/resources/js/ziggy.d.ts b/resources/js/ziggy.d.ts index 309957428..188cbd66a 100644 --- a/resources/js/ziggy.d.ts +++ b/resources/js/ziggy.d.ts @@ -243,7 +243,7 @@ declare module 'ziggy-js' { "claims.completed": [], "claims.active": [], "pulse": [], - "api.game-list.index": [], + "api.game.index": [], "game.hashes.index": [ { "name": "game", @@ -251,7 +251,7 @@ declare module 'ziggy-js' { "binding": "ID" } ], - "game-list.index": [], + "game.index": [], "game.random": [], "api.user.game.destroy": [ { @@ -372,7 +372,6 @@ declare module 'ziggy-js' { "password.confirmation": [], "password.confirm": [], "download.index": [], - "game.index": [], "user.show": [ { "name": "user", diff --git a/resources/views/components/achievement/breadcrumbs.blade.php b/resources/views/components/achievement/breadcrumbs.blade.php index ecf7272f2..62d6a6254 100644 --- a/resources/views/components/achievement/breadcrumbs.blade.php +++ b/resources/views/components/achievement/breadcrumbs.blade.php @@ -9,7 +9,7 @@ {{-- All Games >> Console Name >> Game Name >> Achievement Name >> Page Name --}}