diff --git a/app/Community/Requests/UserGameListRequest.php b/app/Community/Requests/UserGameListRequest.php index 8facdb6ac7..a1b5bc1d04 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/Helpers/render/game.php b/app/Helpers/render/game.php index f205aa2001..6939b65613 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/Models/Emulator.php b/app/Models/Emulator.php index 06decb5a8d..31d477a1dc 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 82e26a4062..52e7d669b1 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 ec8e299e8a..46db537376 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,15 @@ private function buildBaseQuery(GameListType $listType, ?User $user = null): Bui } switch ($listType) { + case GameListType::AllGames: + // Exclude non game systems, inactive systems, and subsets. + $query + ->whereHas('system', function ($q) { + return $q->gameSystems()->active(); + }) + ->where('GameData.Title', 'not like', "%[Subset -%"); + break; + case GameListType::UserPlay: $query->whereHas('gameListEntries', function ($query) use ($user) { $query->where('user_id', $user->id) @@ -165,7 +175,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/GameApiController.php b/app/Platform/Controllers/Api/GameApiController.php new file mode 100644 index 0000000000..c0c773c8c2 --- /dev/null +++ b/app/Platform/Controllers/Api/GameApiController.php @@ -0,0 +1,52 @@ +authorize('viewAny', Game::class); + + $paginatedData = (new BuildGameListAction())->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 + { + } +} diff --git a/app/Platform/Controllers/GameController.php b/app/Platform/Controllers/GameController.php index 1a7813ff19..c79f4b7493 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/Data/GameListPagePropsData.php b/app/Platform/Data/GameListPagePropsData.php new file mode 100644 index 0000000000..897ca01709 --- /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 0000000000..7f508262f5 --- /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 d86c3b6e49..c821fa69a1 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -6,6 +6,7 @@ use App\Models\GameHash; use App\Platform\Controllers\AchievementController; +use App\Platform\Controllers\Api\GameApiController; use App\Platform\Controllers\GameController; use App\Platform\Controllers\GameHashController; use App\Platform\Controllers\PlayerAchievementController; @@ -44,8 +45,14 @@ public function map(): void protected function mapWebRoutes(): void { Route::middleware(['web', 'csp'])->group(function () { + Route::group(['prefix' => 'internal-api'], function () { + 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('games', [GameController::class, 'index'])->name('game.index'); }); // Route::get('achievement/{achievement}{slug?}', [AchievementController::class, 'show'])->name('achievement.show'); @@ -65,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 90b9de2764..479ea41757 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/BaseBadge.tsx b/resources/js/common/components/+vendor/BaseBadge.tsx index 1268116cb0..b9139c5f9a 100644 --- a/resources/js/common/components/+vendor/BaseBadge.tsx +++ b/resources/js/common/components/+vendor/BaseBadge.tsx @@ -4,13 +4,16 @@ import * as React from 'react'; import { cn } from '@/utils/cn'; const baseBadgeVariants = cva( - 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + cn( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold', + 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + ), { variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', secondary: - 'border-transparent bg-neutral-800 text-secondary-foreground hover:bg-secondary/80', + 'border-transparent bg-neutral-800 light:bg-neutral-200 text-secondary-foreground hover:bg-secondary/80', destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', outline: 'text-foreground', diff --git a/resources/js/common/components/+vendor/BaseCheckbox.tsx b/resources/js/common/components/+vendor/BaseCheckbox.tsx index f5ff49d1ec..9ab7f055a2 100644 --- a/resources/js/common/components/+vendor/BaseCheckbox.tsx +++ b/resources/js/common/components/+vendor/BaseCheckbox.tsx @@ -16,7 +16,7 @@ const BaseCheckbox = React.forwardRef< 'peer h-4 w-4 shrink-0 rounded-sm border light:border-neutral-900', 'focus-visible:outline-none focus-visible:ring-2 light:ring-offset-white light:focus-visible:ring-neutral-950', 'focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', - 'border-neutral-600 light:data-[state=checked]:bg-text light:data-[state=checked]:text-neutral-50', + 'border-neutral-600 light:data-[state=checked]:bg-text', 'ring-offset-neutral-950 focus-visible:ring-neutral-300 data-[state=checked]:bg-neutral-700', 'data-[state=checked]:border-neutral-50 data-[state=checked]:text-neutral-50', className, diff --git a/resources/js/common/components/+vendor/BaseCommand.tsx b/resources/js/common/components/+vendor/BaseCommand.tsx index f8fe098fb6..75e7dfe1ae 100644 --- a/resources/js/common/components/+vendor/BaseCommand.tsx +++ b/resources/js/common/components/+vendor/BaseCommand.tsx @@ -16,7 +16,7 @@ const BaseCommand = React.forwardRef< { expect(progressBarEl).toHaveAttribute('aria-valuenow', '0'); }); + it('given the user has no progress on the game, does not set the progress bar to a hyperlink', () => { + // ARRANGE + const game = createGame({ achievementsPublished: 33 }); + + render(); + + // ASSERT + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + it('given the user has progress on the game, renders a progress bar containing progress', () => { // ARRANGE const system = createSystem({ id: 1 }); @@ -60,6 +70,25 @@ describe('Component: PlayerGameProgressBar', () => { expect(progressBarEl).toHaveAttribute('aria-valuenow', '8'); }); + it('given the user has progress on the game, makes the progress bar a hyperlink', () => { + // ARRANGE + const system = createSystem({ id: 1 }); + const game = createGame({ system, achievementsPublished: 33, title: 'Dragon Quest' }); + const playerGame = createPlayerGame({ + achievementsUnlocked: 8, + achievementsUnlockedHardcore: 8, + achievementsUnlockedSoftcore: 0, + highestAward: null, + }); + + render(); + + // ASSERT + const linkEl = screen.getByRole('link', { name: /navigate to dragon quest/i }); + + expect(linkEl).toHaveAttribute('href', `game.show,${{ game: game.id }}`); + }); + it('given the user has a badge on the game, renders those badge details', () => { // ARRANGE const system = createSystem({ id: 1 }); diff --git a/resources/js/common/components/PlayerGameProgressBar/PlayerGameProgressBar.tsx b/resources/js/common/components/PlayerGameProgressBar/PlayerGameProgressBar.tsx index e892e76dc7..2dfe4ae6a3 100644 --- a/resources/js/common/components/PlayerGameProgressBar/PlayerGameProgressBar.tsx +++ b/resources/js/common/components/PlayerGameProgressBar/PlayerGameProgressBar.tsx @@ -46,6 +46,8 @@ export const PlayerGameProgressBar: FC = ({ game, pl return null; } + const Wrapper = achievementsUnlocked ? 'a' : 'div'; + return ( = ({ game, pl !highestAward ? 'py-2' : '', // increase the hover surface area )} > -
+ = ({ game, pl

) : null} - +
diff --git a/resources/js/common/components/WeightedPointsContainer/WeightedPointsContainer.tsx b/resources/js/common/components/WeightedPointsContainer/WeightedPointsContainer.tsx index bf96a91b13..46506e316b 100644 --- a/resources/js/common/components/WeightedPointsContainer/WeightedPointsContainer.tsx +++ b/resources/js/common/components/WeightedPointsContainer/WeightedPointsContainer.tsx @@ -13,11 +13,11 @@ export const WeightedPointsContainer: FC = ({ chil {children} - -
-

RetroPoints: A measurement of rarity and estimated difficulty.

-

Derived from points, number of achievers, and number of players.

-
+ + + RetroPoints: A measurement of rarity and estimated difficulty. + Derived from points, number of achievers, and number of players. +
); diff --git a/resources/js/common/hooks/useAddToBacklogMutation.ts b/resources/js/common/hooks/useAddToGameListMutation.ts similarity index 90% rename from resources/js/common/hooks/useAddToBacklogMutation.ts rename to resources/js/common/hooks/useAddToGameListMutation.ts index dc6c14c819..810509c172 100644 --- a/resources/js/common/hooks/useAddToBacklogMutation.ts +++ b/resources/js/common/hooks/useAddToGameListMutation.ts @@ -4,7 +4,7 @@ import type { ValueOf } from 'type-fest'; import { UserGameListType } from '../utils/generatedAppConstants'; -export function useAddToBacklogMutation() { +export function useAddToGameListMutation() { return useMutation({ mutationFn: ( gameId: number, diff --git a/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts b/resources/js/common/hooks/useGameListQuery/useGameListQuery.ts index 11c729cb5b..703e272fea 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.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/common/hooks/useRemoveFromBacklogMutation.ts b/resources/js/common/hooks/useRemoveFromGameListMutation.ts similarity index 89% rename from resources/js/common/hooks/useRemoveFromBacklogMutation.ts rename to resources/js/common/hooks/useRemoveFromGameListMutation.ts index 812ad7c12f..2e1788250c 100644 --- a/resources/js/common/hooks/useRemoveFromBacklogMutation.ts +++ b/resources/js/common/hooks/useRemoveFromGameListMutation.ts @@ -4,7 +4,7 @@ import type { ValueOf } from 'type-fest'; import { UserGameListType } from '../utils/generatedAppConstants'; -export function useRemoveFromBacklogMutation() { +export function useRemoveFromGameListMutation() { return useMutation({ mutationFn: ( gameId: number, diff --git a/resources/js/common/hooks/useWantToPlayGamesList.ts b/resources/js/common/hooks/useWantToPlayGamesList.ts new file mode 100644 index 0000000000..c0ba7f0f29 --- /dev/null +++ b/resources/js/common/hooks/useWantToPlayGamesList.ts @@ -0,0 +1,51 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import { useAddToGameListMutation } from '@/common/hooks/useAddToGameListMutation'; +import { useRemoveFromGameListMutation } from '@/common/hooks/useRemoveFromGameListMutation'; + +export function useWantToPlayGamesList() { + const queryClient = useQueryClient(); + + const addToBacklogMutation = useAddToGameListMutation(); + + const removeFromBacklogMutation = useRemoveFromGameListMutation(); + + const isPending = addToBacklogMutation.isPending || removeFromBacklogMutation.isPending; + + const addToWantToPlayGamesList = ( + gameId: number, + gameTitle: string, + 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.', + }); + }; + + const removeFromWantToPlayGamesList = (gameId: number, gameTitle: string) => { + toastMessage.promise(removeFromBacklogMutation.mutateAsync(gameId), { + action: { + label: 'Undo', + onClick: () => addToWantToPlayGamesList(gameId, gameTitle, { 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.', + }); + }; + + return { addToWantToPlayGamesList, isPending, removeFromWantToPlayGamesList }; +} diff --git a/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx b/resources/js/features/achievements/components/AchievementBreadcrumbs/AchievementBreadcrumbs.test.tsx index 5983869341..ba46ddf402 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 edfce5e674..d85f4219c2 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 new file mode 100644 index 0000000000..ee78f098b8 --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTable.tsx @@ -0,0 +1,90 @@ +import type { + ColumnFiltersState, + PaginationState, + SortingState, + VisibilityState, +} from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { type Dispatch, type FC, type SetStateAction, useMemo } from 'react'; + +import { useGameListQuery } from '@/common/hooks/useGameListQuery'; +import { usePageProps } from '@/common/hooks/usePageProps'; + +import { allGamesDefaultFilters } from '../../utils/allGamesDefaultFilters'; +import { DataTablePagination } from '../DataTablePagination'; +import { GameListDataTable } from '../GameListDataTable'; +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 }, + }); + + return ( +
+ + + + + +
+ ); +}; 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 0000000000..6229ec94f3 --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/AllGamesDataTableToolbar.tsx @@ -0,0 +1,78 @@ +import type { ColumnFiltersState, Table } from '@tanstack/react-table'; + +import { usePageProps } from '@/common/hooks/usePageProps'; +import { formatNumber } from '@/common/utils/l10n/formatNumber'; + +import { getAreNonDefaultFiltersSet } from '../../utils/getAreNonDefaultFiltersSet'; +import { DataTableAchievementsPublishedFilter } from '../DataTableAchievementsPublishedFilter'; +import { DataTableFacetedFilter } from '../DataTableFacetedFilter'; +import { DataTableResetFiltersButton } from '../DataTableResetFiltersButton/DataTableResetFiltersButton'; +import { DataTableSearchInput } from '../DataTableSearchInput'; +import { DataTableViewOptions } from '../DataTableViewOptions'; + +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); + + 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 ? ( + + ) : 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 0000000000..497f3e6d7f --- /dev/null +++ b/resources/js/features/game-list/components/AllGamesDataTable/buildColumnDefinitions.tsx @@ -0,0 +1,44 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +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[] = [ + buildTitleColumnDef({ forUsername: options.forUsername }), + buildSystemColumnDef({}), + buildAchievementsPublishedColumnDef({}), + buildPointsTotalColumnDef({}), + buildRetroRatioColumnDef({}), + buildLastUpdatedColumnDef({}), + buildReleasedAtColumnDef({}), + buildPlayersTotalColumnDef({}), + buildNumVisibleLeaderboardsColumnDef({}), + ]; + + if (options.canSeeOpenTicketsColumn) { + columnDefinitions.push(buildNumUnresolvedTicketsColumnDef({})); + } + + columnDefinitions.push( + ...([ + buildPlayerGameProgressColumnDef({}), + buildRowActionsColumnDef(), + ] 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 0000000000..ffa9a1fdf6 --- /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.test.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.test.tsx new file mode 100644 index 0000000000..112040346a --- /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: 'title', + }, + ]); + }); + }); + + 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: 'title', + }, + ]); + }); + }); + + 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: 'title', + }, + ]); + }); + }); + + 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: 'title', + }, + ]); + }); + }); + + 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/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx b/resources/js/features/game-list/components/AllGamesMainRoot/AllGamesMainRoot.tsx new file mode 100644 index 0000000000..89ad061c25 --- /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'; +import { DataTablePaginationScrollTarget } from '../DataTablePaginationScrollTarget'; + +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 ( +
+ +
+

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 0000000000..a7a0a7eb6e --- /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/DataTableAchievementsPublishedFilter/DataTableAchievementsPublishedFilter.tsx b/resources/js/features/game-list/components/DataTableAchievementsPublishedFilter/DataTableAchievementsPublishedFilter.tsx new file mode 100644 index 0000000000..337abecbaa --- /dev/null +++ b/resources/js/features/game-list/components/DataTableAchievementsPublishedFilter/DataTableAchievementsPublishedFilter.tsx @@ -0,0 +1,26 @@ +import type { Table } from '@tanstack/react-table'; + +import { DataTableFacetedFilter } from '../DataTableFacetedFilter'; + +interface DataTableAchievementsPublishedFilterProps { + table: Table; +} + +export function DataTableAchievementsPublishedFilter({ + table, +}: DataTableAchievementsPublishedFilterProps) { + return ( + + ); +} diff --git a/resources/js/features/game-list/components/DataTableAchievementsPublishedFilter/index.ts b/resources/js/features/game-list/components/DataTableAchievementsPublishedFilter/index.ts new file mode 100644 index 0000000000..a21855e41f --- /dev/null +++ b/resources/js/features/game-list/components/DataTableAchievementsPublishedFilter/index.ts @@ -0,0 +1 @@ +export * from './DataTableAchievementsPublishedFilter'; diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx b/resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx similarity index 92% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx rename to resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx index 8634586d78..f527d2037b 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableColumnHeader.tsx +++ b/resources/js/features/game-list/components/DataTableColumnHeader/DataTableColumnHeader.tsx @@ -2,6 +2,7 @@ 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 { @@ -13,7 +14,7 @@ import { } from '@/common/components/+vendor/BaseDropdownMenu'; import { cn } from '@/utils/cn'; -import { usePrefetchSort } from '../../hooks/usePrefetchSort'; +import { useDataTablePrefetchSort } from '../../hooks/useDataTablePrefetchSort'; 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.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/features/game-list/components/DataTableColumnHeader/index.ts b/resources/js/features/game-list/components/DataTableColumnHeader/index.ts new file mode 100644 index 0000000000..6aa469ad2b --- /dev/null +++ b/resources/js/features/game-list/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/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx similarity index 89% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableFacetedFilter.tsx rename to resources/js/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx index 040d2b1ac4..3291b4d235 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableFacetedFilter.tsx +++ b/resources/js/features/game-list/components/DataTableFacetedFilter/DataTableFacetedFilter.tsx @@ -1,6 +1,7 @@ import type { Column } from '@tanstack/react-table'; import type { FC } from 'react'; -import { RxCheck, RxPlusCircled } from 'react-icons/rx'; +import { HiOutlineCheck } from 'react-icons/hi'; +import { RxPlusCircled } from 'react-icons/rx'; import { BaseBadge } from '@/common/components/+vendor/BaseBadge'; import { BaseButton } from '@/common/components/+vendor/BaseButton'; @@ -138,24 +139,25 @@ export function DataTableFacetedFilter({ >
- {isSelected ? : null} + {isSelected ? : null}
{option.icon ? ( ) : null} - {option.label} + {option.label} {facets?.get(option.value) && ( @@ -183,12 +185,12 @@ interface ClearFiltersButtonProps { const ClearFiltersButton: FC = ({ onClear }) => { return ( -
+
Clear filters diff --git a/resources/js/features/game-list/components/DataTableFacetedFilter/index.ts b/resources/js/features/game-list/components/DataTableFacetedFilter/index.ts new file mode 100644 index 0000000000..ff3e45f9ca --- /dev/null +++ b/resources/js/features/game-list/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/features/game-list/components/DataTablePagination/DataTablePagination.tsx similarity index 89% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTablePagination.tsx rename to resources/js/features/game-list/components/DataTablePagination/DataTablePagination.tsx index 377cf63d88..b5f5f82dd4 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTablePagination.tsx +++ b/resources/js/features/game-list/components/DataTablePagination/DataTablePagination.tsx @@ -1,22 +1,27 @@ 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'; interface DataTablePaginationProps { table: Table; + tableApiRouteName?: RouteName; } -export function DataTablePagination({ table }: DataTablePaginationProps): ReactNode { +export function DataTablePagination({ + table, + tableApiRouteName = 'api.game.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/features/game-list/components/DataTablePagination/index.ts b/resources/js/features/game-list/components/DataTablePagination/index.ts new file mode 100644 index 0000000000..374ba7bfc9 --- /dev/null +++ b/resources/js/features/game-list/components/DataTablePagination/index.ts @@ -0,0 +1 @@ +export * from './DataTablePagination'; diff --git a/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.test.tsx b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.test.tsx new file mode 100644 index 0000000000..c30a49a195 --- /dev/null +++ b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@/test'; + +import { DataTablePaginationScrollTarget } from './DataTablePaginationScrollTarget'; + +describe('Component: DataTablePaginationScrollTarget', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + Hello, world, + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders children', () => { + // ARRANGE + render(Hello, world); + + // ASSERT + expect(screen.getByText(/hello, world/i)).toBeVisible(); + }); +}); diff --git a/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.tsx b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.tsx new file mode 100644 index 0000000000..0008426119 --- /dev/null +++ b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/DataTablePaginationScrollTarget.tsx @@ -0,0 +1,15 @@ +import type { FC, ReactNode } from 'react'; + +interface DataTablePaginationScrollTargetProps { + children: ReactNode; +} + +export const DataTablePaginationScrollTarget: FC = ({ + children, +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/resources/js/features/game-list/components/DataTablePaginationScrollTarget/index.ts b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/index.ts new file mode 100644 index 0000000000..95c5f6c62e --- /dev/null +++ b/resources/js/features/game-list/components/DataTablePaginationScrollTarget/index.ts @@ -0,0 +1 @@ +export * from './DataTablePaginationScrollTarget'; diff --git a/resources/js/features/game-list/components/DataTableResetFiltersButton/DataTableResetFiltersButton.tsx b/resources/js/features/game-list/components/DataTableResetFiltersButton/DataTableResetFiltersButton.tsx new file mode 100644 index 0000000000..db55c756e1 --- /dev/null +++ b/resources/js/features/game-list/components/DataTableResetFiltersButton/DataTableResetFiltersButton.tsx @@ -0,0 +1,47 @@ +import type { ColumnFiltersState, Table } from '@tanstack/react-table'; +import { RxCross2 } from 'react-icons/rx'; +import type { RouteName } from 'ziggy-js'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; + +import { useDataTablePrefetchResetFilters } from '../../hooks/useDataTablePrefetchResetFilters'; + +interface DataTableResetFiltersButtonProps { + table: Table; + + defaultColumnFilters?: ColumnFiltersState; + /** The controller route name where client-side calls for this datatable are made. */ + tableApiRouteName?: RouteName; +} + +export function DataTableResetFiltersButton({ + table, + defaultColumnFilters = [], + tableApiRouteName = 'api.game.index', +}: DataTableResetFiltersButtonProps) { + const { prefetchResetFilters } = useDataTablePrefetchResetFilters( + table, + defaultColumnFilters, + tableApiRouteName, + ); + + const resetFiltersToDefault = () => { + if (defaultColumnFilters) { + table.setColumnFilters(defaultColumnFilters); + } else { + table.resetColumnFilters(); + } + }; + + return ( + prefetchResetFilters()} + className="px-2 text-link lg:px-3" + > + Reset + + ); +} diff --git a/resources/js/features/game-list/components/DataTableResetFiltersButton/index.ts b/resources/js/features/game-list/components/DataTableResetFiltersButton/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx b/resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx new file mode 100644 index 0000000000..444ea38767 --- /dev/null +++ b/resources/js/features/game-list/components/DataTableRowActions/DataTableRowActions.tsx @@ -0,0 +1,86 @@ +import type { Row } from '@tanstack/react-table'; +import { LuMinus, LuPlus } from 'react-icons/lu'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { + BaseTooltip, + BaseTooltipContent, + BaseTooltipTrigger, +} from '@/common/components/+vendor/BaseTooltip'; +import { usePageProps } from '@/common/hooks/usePageProps'; +import { useWantToPlayGamesList } from '@/common/hooks/useWantToPlayGamesList'; +import { cn } from '@/utils/cn'; + +/** + * 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 { addToWantToPlayGamesList, isPending, removeFromWantToPlayGamesList } = + useWantToPlayGamesList(); + + const rowData = row.original as Partial; + const gameId = rowData?.game?.id ?? 0; + const gameTitle = rowData?.game?.title ?? ''; + const isInBacklog = rowData?.isInBacklog ?? false; + + const handleToggleFromBacklogClick = () => { + // This should never happen. + if (!gameId) { + throw new Error('No game ID.'); + } + + if (!auth?.user && typeof window !== 'undefined') { + window.location.href = route('login'); + + return; + } + + if (isInBacklog) { + removeFromWantToPlayGamesList(gameId, gameTitle); + } else { + addToWantToPlayGamesList(gameId, gameTitle); + } + }; + + const BacklogIcon = isInBacklog ? LuMinus : LuPlus; + + 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/features/game-list/components/DataTableRowActions/index.ts b/resources/js/features/game-list/components/DataTableRowActions/index.ts new file mode 100644 index 0000000000..f54451b0c7 --- /dev/null +++ b/resources/js/features/game-list/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/features/game-list/components/DataTableSearchInput/DataTableSearchInput.tsx similarity index 98% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/DataTableSearchInput.tsx rename to resources/js/features/game-list/components/DataTableSearchInput/DataTableSearchInput.tsx index f9c90d0358..bbceb71a0f 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/DataTableSearchInput.tsx +++ b/resources/js/features/game-list/components/DataTableSearchInput/DataTableSearchInput.tsx @@ -83,7 +83,7 @@ export function DataTableSearchInput({ table }: DataTableSearchInputProps id="search-shortcut" className={cn( 'absolute right-2 hidden rounded-md border border-transparent bg-neutral-800/60 px-1.5 font-mono text-xs', - 'text-neutral-400 peer-focus:opacity-0 light:bg-gray-200 light:text-gray-800', + 'text-neutral-400 peer-focus:opacity-0 light:bg-neutral-200 light:text-neutral-800', 'cursor-default lg:block', )} > diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/index.ts b/resources/js/features/game-list/components/DataTableSearchInput/index.ts similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/index.ts rename to resources/js/features/game-list/components/DataTableSearchInput/index.ts diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/useSearchInputHotkey.ts b/resources/js/features/game-list/components/DataTableSearchInput/useSearchInputHotkey.ts similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableSearchInput/useSearchInputHotkey.ts rename to resources/js/features/game-list/components/DataTableSearchInput/useSearchInputHotkey.ts diff --git a/resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableViewOptions.tsx b/resources/js/features/game-list/components/DataTableViewOptions/DataTableViewOptions.tsx similarity index 100% rename from resources/js/features/game-list/components/WantToPlayGamesDataTable/DataTableViewOptions.tsx rename to resources/js/features/game-list/components/DataTableViewOptions/DataTableViewOptions.tsx diff --git a/resources/js/features/game-list/components/DataTableViewOptions/index.ts b/resources/js/features/game-list/components/DataTableViewOptions/index.ts new file mode 100644 index 0000000000..122358e026 --- /dev/null +++ b/resources/js/features/game-list/components/DataTableViewOptions/index.ts @@ -0,0 +1 @@ +export * from './DataTableViewOptions'; 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 0000000000..b960567f09 --- /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 0000000000..5ae7a92332 --- /dev/null +++ b/resources/js/features/game-list/components/GameListDataTable/index.ts @@ -0,0 +1 @@ +export * from './GameListDataTable'; 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 5ffe97b1b9..0000000000 --- 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 4ee026846b..03150a61a1 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTable.tsx @@ -4,25 +4,16 @@ import type { SortingState, 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 { 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 { useGameListQuery } from '@/common/hooks/useGameListQuery'; import { usePageProps } from '@/common/hooks/usePageProps'; -import { cn } from '@/utils/cn'; import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; +import { DataTablePagination } from '../DataTablePagination'; +import { GameListDataTable } from '../GameListDataTable'; import { buildColumnDefinitions } from './buildColumnDefinitions'; -import { DataTablePagination } from './DataTablePagination'; import { WantToPlayGamesDataTableToolbar } from './WantToPlayGamesDataTableToolbar'; // These values are all generated from `useGameListState`. @@ -49,7 +40,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( @@ -83,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 15309be3d6..f0a852d324 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/WantToPlayGamesDataTableToolbar.tsx @@ -1,13 +1,14 @@ import type { ColumnFiltersState, Table } from '@tanstack/react-table'; -import { RxCross2 } from 'react-icons/rx'; -import { BaseButton } from '@/common/components/+vendor/BaseButton'; 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'; +import { DataTableAchievementsPublishedFilter } from '../DataTableAchievementsPublishedFilter'; +import { DataTableFacetedFilter } from '../DataTableFacetedFilter'; +import { DataTableResetFiltersButton } from '../DataTableResetFiltersButton/DataTableResetFiltersButton'; +import { DataTableSearchInput } from '../DataTableSearchInput'; +import { DataTableViewOptions } from '../DataTableViewOptions'; interface WantToPlayGamesDataTableToolbarProps { table: Table; @@ -24,15 +25,7 @@ export function WantToPlayGamesDataTableToolbar({ const { filterableSystemOptions } = usePageProps(); const currentFilters = table.getState().columnFilters; - const isFiltered = getHasNonDefaultFilters(currentFilters, defaultColumnFilters); - - const resetFiltersToDefault = () => { - if (defaultColumnFilters) { - table.setColumnFilters(defaultColumnFilters); - } else { - table.resetColumnFilters(); - } - }; + const isFiltered = getAreNonDefaultFiltersSet(currentFilters, defaultColumnFilters); return (
@@ -55,29 +48,15 @@ export function WantToPlayGamesDataTableToolbar({ ) : null} {table.getColumn('achievementsPublished') ? ( - + ) : null} {isFiltered ? ( - - Reset - + ) : null}
@@ -101,19 +80,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 75da7854cb..307cc4a44c 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesDataTable/buildColumnDefinitions.tsx @@ -1,249 +1,45 @@ import type { ColumnDef } from '@tanstack/react-table'; -import dayjs from 'dayjs'; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import utc from 'dayjs/plugin/utc'; - -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'; - -import { DataTableColumnHeader } from './DataTableColumnHeader'; -import { DataTableRowActions } from './DataTableRowActions'; - -dayjs.extend(utc); -dayjs.extend(localizedFormat); +import type { RouteName } from 'ziggy-js'; + +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'; 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 }) => { - 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 ( -

- {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 }) => , - }, + buildPlayerGameProgressColumnDef({ tableApiRouteName }), + buildRowActionsColumnDef(), ] 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 95% 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 ce953a2bda..38471a4285 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,11 +114,13 @@ describe('Component: WantToPlayGamesRoot', () => { releasedAtGranularity: 'day', }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], - paginatedGameListEntries: createPaginatedData([createGameListEntry({ game: mockGame })]), + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), can: { develop: false }, ziggy: createZiggyProps(), }, @@ -155,11 +157,13 @@ describe('Component: WantToPlayGamesRoot', () => { releasedAtGranularity: 'day', }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], - paginatedGameListEntries: createPaginatedData([createGameListEntry({ game: mockGame })]), + paginatedGameListEntries: createPaginatedData([ + createGameListEntry({ game: mockGame, isInBacklog: true }), + ]), can: { develop: false }, ziggy: createZiggyProps(), }, @@ -183,7 +187,7 @@ describe('Component: WantToPlayGamesRoot', () => { it('allows users to toggle column visibility', async () => { // ARRANGE - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -203,7 +207,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 +228,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 +265,7 @@ describe('Component: WantToPlayGamesRoot', () => { numUnresolvedTickets: 2, }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -283,7 +287,7 @@ describe('Component: WantToPlayGamesRoot', () => { // ARRANGE const getSpy = vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: createPaginatedData([]) }); - render(, { + render(, { pageProps: { auth: { user: createAuthenticatedUser() }, filterableSystemOptions: [], @@ -304,7 +308,7 @@ describe('Component: WantToPlayGamesRoot', () => { 'filter[achievementsPublished]': 'has', 'filter[title]': 'dragon quest', 'page[number]': 1, - sort: null, + sort: 'title', }, ]); }); @@ -312,7 +316,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 +335,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: [ @@ -358,7 +362,7 @@ describe('Component: WantToPlayGamesRoot', () => { 'filter[achievementsPublished]': 'has', 'filter[system]': '1', 'page[number]': 1, - sort: null, + sort: 'title', }, ]); }); @@ -371,7 +375,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 +408,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 +434,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' })], @@ -451,7 +455,7 @@ describe('Component: WantToPlayGamesRoot', () => { { 'filter[achievementsPublished]': 'none', 'page[number]': 1, - sort: null, + sort: 'title', }, ]); }); @@ -462,7 +466,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 +498,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 +530,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 +559,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 +579,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 +598,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' })], @@ -618,7 +622,7 @@ describe('Component: WantToPlayGamesRoot', () => { { 'filter[achievementsPublished]': 'has', 'page[number]': 2, - sort: null, + sort: 'title', }, ]); }); @@ -626,7 +630,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 89% rename from resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.tsx rename to resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx index 34be217ad6..7f5840cb5e 100644 --- a/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesRoot.tsx +++ b/resources/js/features/game-list/components/WantToPlayGamesMainRoot/WantToPlayGamesMainRoot.tsx @@ -8,9 +8,10 @@ import { useAutoUpdatingQueryParams } from '../../hooks/useAutoUpdatingQueryPara import { useGameListState } from '../../hooks/useGameListState'; import { usePreloadedTableDataQueryClient } from '../../hooks/usePreloadedTableDataQueryClient'; import { wantToPlayGamesDefaultFilters } from '../../utils/wantToPlayGamesDefaultFilters'; +import { DataTablePaginationScrollTarget } from '../DataTablePaginationScrollTarget'; import { WantToPlayGamesDataTable } from '../WantToPlayGamesDataTable'; -export const WantToPlayGamesRoot: FC = () => { +export const WantToPlayGamesMainRoot: FC = () => { const { auth, paginatedGameListEntries } = usePageProps(); @@ -42,9 +43,9 @@ export const WantToPlayGamesRoot: FC = () => { return (
-
+ Want to Play Games -
+ (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/useDataTablePrefetchResetFilters.ts b/resources/js/features/game-list/hooks/useDataTablePrefetchResetFilters.ts new file mode 100644 index 0000000000..f3cb3184c6 --- /dev/null +++ b/resources/js/features/game-list/hooks/useDataTablePrefetchResetFilters.ts @@ -0,0 +1,43 @@ +import { useQueryClient } from '@tanstack/react-query'; +import type { ColumnFiltersState, 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'; + +/** + * Given the user hovers over the Reset button, it is very likely they will + * wind up clicking the button. Queries are cheap, so prefetch the destination. + */ + +export function useDataTablePrefetchResetFilters( + table: Table, + defaultColumnFilters: ColumnFiltersState, + tableApiRouteName: RouteName, +) { + const { pagination, sorting } = table.getState(); + + const queryClient = useQueryClient(); + + const prefetchResetFilters = () => { + queryClient.prefetchQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- tableApiRouteName is not part of the key + queryKey: ['data', pagination, sorting, defaultColumnFilters], + staleTime: 1 * 60 * 1000, // 1 minute + queryFn: async () => { + const response = await axios.get>( + route(tableApiRouteName, { + 'page[number]': pagination.pageIndex + 1, + sort: buildGameListQuerySortParam(sorting), + ...buildGameListQueryFilterParams(defaultColumnFilters), + }), + ); + + return response.data; + }, + }); + }; + + return { prefetchResetFilters }; +} diff --git a/resources/js/features/game-list/hooks/usePrefetchSort.ts b/resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts similarity index 81% rename from resources/js/features/game-list/hooks/usePrefetchSort.ts rename to resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts index 2e40077bf5..7880309dcf 100644 --- a/resources/js/features/game-list/hooks/usePrefetchSort.ts +++ b/resources/js/features/game-list/hooks/useDataTablePrefetchSort.ts @@ -1,6 +1,7 @@ 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'; @@ -10,18 +11,19 @@ import { buildGameListQuerySortParam } from '@/common/utils/buildGameListQuerySo * 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/features/game-list/hooks/useGameListState.test.ts b/resources/js/features/game-list/hooks/useGameListState.test.ts index f78dd4cca6..1f302d347f 100644 --- a/resources/js/features/game-list/hooks/useGameListState.test.ts +++ b/resources/js/features/game-list/hooks/useGameListState.test.ts @@ -47,7 +47,7 @@ describe('Hook: useGameListState', () => { // ASSERT const currentValue = result.current as ReturnType; - expect(currentValue.sorting).toEqual([]); + expect(currentValue.sorting).toEqual([{ id: 'title', desc: false }]); }); it('given a sort param, correctly sets the initial sorting state', () => { diff --git a/resources/js/features/game-list/hooks/useGameListState.ts b/resources/js/features/game-list/hooks/useGameListState.ts index 3952424141..6e4bda3438 100644 --- a/resources/js/features/game-list/hooks/useGameListState.ts +++ b/resources/js/features/game-list/hooks/useGameListState.ts @@ -66,7 +66,7 @@ function mapQueryParamsToSorting(query: AppGlobalProps['ziggy']['query']): Sorti // `sort` is actually part of `query`'s prototype, so we have to be // extra explicit in how we check for the presence of the param. - if (typeof query.sort === 'function') { + if (typeof query.sort === 'function' || typeof query.sort === 'undefined') { sorting.push({ id: 'title', desc: false }); return sorting; 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 0000000000..8b7b54278a --- /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/column-definitions/buildAchievementsPublishedColumnDef.tsx b/resources/js/features/game-list/utils/column-definitions/buildAchievementsPublishedColumnDef.tsx new file mode 100644 index 0000000000..66fef4c8dc --- /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 0000000000..2fd6346116 --- /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 0000000000..256e860bdb --- /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 0000000000..bd2f93ae71 --- /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 0000000000..c69ae7cab9 --- /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 0000000000..0c913f61b8 --- /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 0000000000..66a3ec494d --- /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 0000000000..93b963795f --- /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 0000000000..54f740674f --- /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 0000000000..ab2d1eb01b --- /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 0000000000..f651a733f8 --- /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 0000000000..b530fc7da9 --- /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/game-list/utils/getAreNonDefaultFiltersSet.ts b/resources/js/features/game-list/utils/getAreNonDefaultFiltersSet.ts new file mode 100644 index 0000000000..f32253bcac --- /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/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx index 8aac7a7fb8..a599f316e8 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 3062571a0b..bb61e6e43a 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/features/games/components/HashesMainRoot/HashesList/HashesList.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx index 040951bcf1..be3fcf6dda 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx @@ -1,16 +1,14 @@ -import { usePage } from '@inertiajs/react'; import type { FC } from 'react'; import { Embed } from '@/common/components/Embed/Embed'; +import { usePageProps } from '@/common/hooks/usePageProps'; import { HashesListItem } from './HashesListItem'; export const hashesListContainerTestId = 'hashes-list'; export const HashesList: FC = () => { - const { - props: { hashes }, - } = usePage(); + const { hashes } = usePageProps(); if (!hashes.length) { return null; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx index d5a150aeea..1bc46efd6a 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx @@ -1,18 +1,16 @@ -import { usePage } from '@inertiajs/react'; import type { FC } from 'react'; import { LuSave } from 'react-icons/lu'; import { baseButtonVariants } from '@/common/components/+vendor/BaseButton'; import { Embed } from '@/common/components/Embed/Embed'; +import { usePageProps } from '@/common/hooks/usePageProps'; import { GameBreadcrumbs } from '../GameBreadcrumbs'; import { GameHeading } from '../GameHeading/GameHeading'; import { HashesList } from './HashesList'; export const HashesMainRoot: FC = () => { - const { - props: { can, game, hashes }, - } = usePage(); + const { can, game, hashes } = usePageProps(); return (
diff --git a/resources/js/features/settings/hooks/useResetNavbarUserPic.ts b/resources/js/features/settings/hooks/useResetNavbarUserPic.ts index 29c084d39f..c64298d869 100644 --- a/resources/js/features/settings/hooks/useResetNavbarUserPic.ts +++ b/resources/js/features/settings/hooks/useResetNavbarUserPic.ts @@ -1,12 +1,8 @@ -import { usePage } from '@inertiajs/react'; - -import type { AppGlobalProps } from '@/common/models'; +import { usePageProps } from '@/common/hooks/usePageProps'; import { asset } from '@/utils/helpers'; export function useResetNavbarUserPic() { - const { - props: { auth }, - } = usePage(); + const { auth } = usePageProps(); const resetNavbarUserPic = () => { // Using document functions to mutate the DOM is very bad. diff --git a/resources/js/pages/game-list/index.tsx b/resources/js/pages/game-list/index.tsx new file mode 100644 index 0000000000..2fd76b1ed7 --- /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 6c7a376699..cc6401a202 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/test/setup.tsx b/resources/js/test/setup.tsx index b32122ca8f..eb7688d0b1 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/types/generated.d.ts b/resources/js/types/generated.d.ts index 5727382a63..25c6d43331 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 e3364ecee7..188cbd66af 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.index": [], "game.hashes.index": [ { "name": "game", @@ -250,6 +251,7 @@ declare module 'ziggy-js' { "binding": "ID" } ], + "game.index": [], "game.random": [], "api.user.game.destroy": [ { @@ -370,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 ecf7272f28..62d6a62541 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 --}}