From 4be70685bab03aa1f4635ac402d4334f3ec809b3 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 30 Aug 2024 20:46:24 -0400 Subject: [PATCH 1/6] feat: migrate Supported Game Files to React --- .eslintrc.yaml | 1 + app/Data/UserPermissionsData.php | 26 +++++ .../Controllers/GameHashController.php | 24 +++- app/Platform/Data/GameData.php | 34 ++++++ app/Platform/Data/GameHashData.php | 48 ++++++++ app/Platform/Data/GameHashLabelData.php | 39 +++++++ app/Platform/Data/GameHashesPagePropsData.php | 23 ++++ app/Platform/Data/SystemData.php | 32 ++++++ app/Platform/RouteServiceProvider.php | 4 + app/Policies/GameHashPolicy.php | 9 +- .../components/+vendor/BaseBreadcrumb.tsx | 4 +- .../common/components/+vendor/BaseButton.tsx | 15 +-- .../js/common/components/Embed/Embed.test.tsx | 21 ++++ .../js/common/components/Embed/Embed.tsx | 15 +++ resources/js/common/components/Embed/index.ts | 1 + .../components/GameAvatar/GameAvatar.test.tsx | 80 +++++++++++++ .../components/GameAvatar/GameAvatar.tsx | 49 ++++++++ .../js/common/components/GameAvatar/index.ts | 1 + .../components/UserAvatar/UserAvatar.tsx | 5 +- .../js/common/layouts/AppLayout/AppLayout.tsx | 2 +- .../js/common/models/avatar-size.model.ts | 3 + resources/js/common/models/index.ts | 1 + .../utils/buildTrackingClassNames.test.ts | 48 ++++++++ .../common/utils/buildTrackingClassNames.ts | 36 ++++++ .../GameBreadcrumbs/GameBreadcrumbs.test.tsx | 50 +++++++++ .../GameBreadcrumbs/GameBreadcrumbs.tsx | 60 ++++++++++ .../games/components/GameBreadcrumbs/index.ts | 1 + .../GameHeading/GameHeading.test.tsx | 38 +++++++ .../components/GameHeading/GameHeading.tsx | 20 ++++ .../games/components/GameHeading/index.ts | 1 + .../HashesList/HashesList.test.tsx | 78 +++++++++++++ .../HashesMainRoot/HashesList/HashesList.tsx | 43 +++++++ .../HashesList/HashesListItem.tsx | 52 +++++++++ .../HashesMainRoot/HashesList/index.ts | 1 + .../HashesMainRoot/HashesMainRoot.test.tsx | 34 ++++++ .../HashesMainRoot/HashesMainRoot.tsx | 74 ++++++++++++ .../games/components/HashesMainRoot/index.ts | 1 + resources/js/pages/game/[game]/hashes.tsx | 26 +++++ resources/js/test/factories/createGame.ts | 12 ++ resources/js/test/factories/createGameHash.ts | 19 ++++ .../js/test/factories/createGameHashLabel.ts | 17 +++ resources/js/test/factories/createSystem.ts | 10 ++ resources/js/test/factories/index.ts | 5 + resources/js/test/setup.tsx | 11 +- resources/js/types/generated.d.ts | 33 ++++++ resources/js/ziggy.d.ts | 14 +-- .../game/link-buttons/index.blade.php | 2 +- .../hash-listing.blade.php | 40 ------- .../pages/game/[game]/hashes/index.blade.php | 105 ------------------ 49 files changed, 1089 insertions(+), 179 deletions(-) create mode 100644 app/Data/UserPermissionsData.php create mode 100644 app/Platform/Data/GameData.php create mode 100644 app/Platform/Data/GameHashData.php create mode 100644 app/Platform/Data/GameHashLabelData.php create mode 100644 app/Platform/Data/GameHashesPagePropsData.php create mode 100644 app/Platform/Data/SystemData.php create mode 100644 resources/js/common/components/Embed/Embed.test.tsx create mode 100644 resources/js/common/components/Embed/Embed.tsx create mode 100644 resources/js/common/components/Embed/index.ts create mode 100644 resources/js/common/components/GameAvatar/GameAvatar.test.tsx create mode 100644 resources/js/common/components/GameAvatar/GameAvatar.tsx create mode 100644 resources/js/common/components/GameAvatar/index.ts create mode 100644 resources/js/common/models/avatar-size.model.ts create mode 100644 resources/js/common/utils/buildTrackingClassNames.test.ts create mode 100644 resources/js/common/utils/buildTrackingClassNames.ts create mode 100644 resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx create mode 100644 resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx create mode 100644 resources/js/features/games/components/GameBreadcrumbs/index.ts create mode 100644 resources/js/features/games/components/GameHeading/GameHeading.test.tsx create mode 100644 resources/js/features/games/components/GameHeading/GameHeading.tsx create mode 100644 resources/js/features/games/components/GameHeading/index.ts create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesList/index.ts create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx create mode 100644 resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx create mode 100644 resources/js/features/games/components/HashesMainRoot/index.ts create mode 100644 resources/js/pages/game/[game]/hashes.tsx create mode 100644 resources/js/test/factories/createGame.ts create mode 100644 resources/js/test/factories/createGameHash.ts create mode 100644 resources/js/test/factories/createGameHashLabel.ts create mode 100644 resources/js/test/factories/createSystem.ts delete mode 100644 resources/views/components/supported-game-files/hash-listing.blade.php delete mode 100644 resources/views/pages/game/[game]/hashes/index.blade.php diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 27ef3600fa..a473793862 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -102,6 +102,7 @@ rules: react/no-unescaped-entities: off react/prop-types: off react/react-in-jsx-scope: off + react/jsx-no-target-blank: off # we don't support the old browsers this rule tries to protect # disable some of the more aggressive unicorn rules unicorn/filename-case: off diff --git a/app/Data/UserPermissionsData.php b/app/Data/UserPermissionsData.php new file mode 100644 index 0000000000..536f600555 --- /dev/null +++ b/app/Data/UserPermissionsData.php @@ -0,0 +1,26 @@ +can('manage', \App\Models\GameHash::class) + ); + } +} diff --git a/app/Platform/Controllers/GameHashController.php b/app/Platform/Controllers/GameHashController.php index 2e0714f0aa..af5996149d 100644 --- a/app/Platform/Controllers/GameHashController.php +++ b/app/Platform/Controllers/GameHashController.php @@ -5,13 +5,19 @@ namespace App\Platform\Controllers; use App\Community\Enums\ArticleType; +use App\Data\UserPermissionsData; use App\Http\Controller; +use App\Models\Game; use App\Models\GameHash; use App\Models\User; -use Illuminate\Contracts\View\View; +use App\Platform\Data\GameData; +use App\Platform\Data\GameHashData; +use App\Platform\Data\GameHashesPagePropsData; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class GameHashController extends Controller { @@ -20,12 +26,17 @@ protected function resourceName(): string return 'game-hash'; } - public function index(Request $request): View + public function index(Request $request, Game $game): InertiaResponse { $this->authorize('viewAny', $this->resourceClass()); - return view('resource.index') - ->with('resource', $this->resourceName()); + $gameData = GameData::fromGame($game)->include('badgeUrl', 'forumTopicId', 'system'); + $hashes = GameHashData::fromCollection($game->hashes); + $can = UserPermissionsData::fromUser($request->user())->include('manageGameHashes'); + + $props = new GameHashesPagePropsData($gameData, $hashes, $can); + + return Inertia::render('game/[game]/hashes', $props); } public function show(GameHash $gameHash): void @@ -70,7 +81,10 @@ public function update(Request $request, GameHash $gameHash): JsonResponse } $gameHash->update($updatedAttributes); - $this->logGameHashUpdate($gameHash, $changedAttributes, Auth::user()); + + /** @var User $user */ + $user = Auth::user(); + $this->logGameHashUpdate($gameHash, $changedAttributes, $user); return response()->json(['message' => __('legacy.success.update')]); } diff --git a/app/Platform/Data/GameData.php b/app/Platform/Data/GameData.php new file mode 100644 index 0000000000..e91934c713 --- /dev/null +++ b/app/Platform/Data/GameData.php @@ -0,0 +1,34 @@ +id, + title: $game->title, + badgeUrl: Lazy::create(fn () => $game->badge_url), + forumTopicId: Lazy::create(fn () => $game->ForumTopicID), + system: Lazy::create(fn () => SystemData::fromSystem($game->system)) + ); + } +} diff --git a/app/Platform/Data/GameHashData.php b/app/Platform/Data/GameHashData.php new file mode 100644 index 0000000000..cb71d763bf --- /dev/null +++ b/app/Platform/Data/GameHashData.php @@ -0,0 +1,48 @@ +id, + md5: $gameHash->md5, + name: $gameHash->name, + labels: GameHashLabelData::fromLabelsString($gameHash->labels), + patchUrl: $gameHash->patch_url, + ); + } + + /** + * @param Collection $gameHashes + * @return GameHashData[] + */ + public static function fromCollection(Collection $gameHashes): array + { + return array_map( + fn ($gameHash) => self::fromGameHash($gameHash), + $gameHashes->all() + ); + } +} diff --git a/app/Platform/Data/GameHashLabelData.php b/app/Platform/Data/GameHashLabelData.php new file mode 100644 index 0000000000..977ea9dae6 --- /dev/null +++ b/app/Platform/Data/GameHashLabelData.php @@ -0,0 +1,39 @@ +id, + name: $system->name, + nameFull: Lazy::create(fn () => $system->name_full), + nameShort: Lazy::create(fn () => $system->name_short), + ); + } +} diff --git a/app/Platform/RouteServiceProvider.php b/app/Platform/RouteServiceProvider.php index d6ebb6313a..0373b85be2 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -41,6 +41,10 @@ public function map(): void protected function mapWebRoutes(): void { Route::middleware(['web', 'csp'])->group(function () { + Route::middleware(['inertia'])->group(function () { + Route::get('game/{game}/hashes', [GameHashController::class, 'index'])->name('game.hashes.index'); + }); + // Route::get('achievement/{achievement}{slug?}', [AchievementController::class, 'show'])->name('achievement.show'); // Route::resource('achievements', AchievementController::class)->only('index')->names(['index' => 'achievement.index']); // Route::get( diff --git a/app/Policies/GameHashPolicy.php b/app/Policies/GameHashPolicy.php index bce9844f9a..fd80f1e776 100644 --- a/app/Policies/GameHashPolicy.php +++ b/app/Policies/GameHashPolicy.php @@ -4,7 +4,6 @@ namespace App\Policies; -use App\Enums\Permissions; use App\Models\GameHash; use App\Models\Role; use App\Models\User; @@ -20,8 +19,7 @@ public function manage(User $user): bool Role::GAME_HASH_MANAGER, Role::DEVELOPER_STAFF, Role::DEVELOPER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Developer; + ]); } public function viewAny(?User $user): bool @@ -47,8 +45,9 @@ public function update(User $user): bool { return $user->hasAnyRole([ Role::GAME_HASH_MANAGER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Developer; + Role::DEVELOPER_STAFF, + Role::DEVELOPER, + ]); } public function delete(User $user, GameHash $gameHash): bool diff --git a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx index 56959d2e72..a760c7b9a5 100644 --- a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx +++ b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx @@ -51,7 +51,7 @@ const BaseBreadcrumbLink = forwardRef< return ( ); @@ -65,7 +65,7 @@ const BaseBreadcrumbPage = forwardRef ), diff --git a/resources/js/common/components/+vendor/BaseButton.tsx b/resources/js/common/components/+vendor/BaseButton.tsx index 9884875d46..1948d227b1 100644 --- a/resources/js/common/components/+vendor/BaseButton.tsx +++ b/resources/js/common/components/+vendor/BaseButton.tsx @@ -8,17 +8,18 @@ import { cn } from '@/utils/cn'; const baseButtonVariants = cva( [ - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium light:ring-offset-white', + 'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium light:ring-offset-white', 'focus-visible:outline-none focus-visible:ring-2 light:focus-visible:ring-neutral-950 focus-visible:ring-offset-2', - 'disabled:pointer-events-none disabled:opacity-50 ring-offset-neutral-950 focus-visible:ring-neutral-300', + 'disabled:pointer-events-none disabled:opacity-50', + 'ring-offset-neutral-950 focus-visible:ring-neutral-300', + 'lg:active:translate-y-[1px] lg:active:scale-[0.98] lg:transition-transform lg:duration-100', ], { variants: { variant: { default: - 'light:bg-neutral-900 light:text-neutral-50 light:hover:bg-neutral-900/90 bg-neutral-50 text-neutral-900 hover:bg-neutral-50/90', - destructive: - 'light:bg-red-500 light:text-neutral-50 light:hover:bg-red-500/90 bg-red-900 text-neutral-50 hover:bg-red-900/90', + 'bg-embed text-link border border-neutral-700 hover:bg-embed-highlight hover:text-link-hover hover:border-menu-link light:bg-white light:border-link light:text-link light:hover:bg-neutral-100', + destructive: 'bg-embed border btn-danger hover:text-link-hover hover:border-menu-link', outline: 'border light:border-neutral-200 light:bg-white light:hover:bg-neutral-100 light:hover:text-neutral-900 border-neutral-800 bg-neutral-950 hover:bg-neutral-800 hover:text-neutral-50', secondary: @@ -28,8 +29,8 @@ const baseButtonVariants = cva( link: 'light:text-neutral-900 underline-offset-4 hover:underline text-neutral-50', }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', + default: 'h-9 px-4 py-2', + sm: 'h-[30px] rounded-md px-3 !text-[13px]', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, diff --git a/resources/js/common/components/Embed/Embed.test.tsx b/resources/js/common/components/Embed/Embed.test.tsx new file mode 100644 index 0000000000..3add999f75 --- /dev/null +++ b/resources/js/common/components/Embed/Embed.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@/test'; + +import { Embed } from './Embed'; + +describe('Component: Embed', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(stuff); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders children', () => { + // ARRANGE + render(stuff); + + // ASSERT + expect(screen.getByText(/stuff/i)).toBeVisible(); + }); +}); diff --git a/resources/js/common/components/Embed/Embed.tsx b/resources/js/common/components/Embed/Embed.tsx new file mode 100644 index 0000000000..d3ad40c7ab --- /dev/null +++ b/resources/js/common/components/Embed/Embed.tsx @@ -0,0 +1,15 @@ +import type { FC, HTMLAttributes, ReactNode } from 'react'; + +interface EmbedProps extends HTMLAttributes { + children: ReactNode; + + className?: string; +} + +export const Embed: FC = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/resources/js/common/components/Embed/index.ts b/resources/js/common/components/Embed/index.ts new file mode 100644 index 0000000000..8113a2f96c --- /dev/null +++ b/resources/js/common/components/Embed/index.ts @@ -0,0 +1 @@ +export * from './Embed'; diff --git a/resources/js/common/components/GameAvatar/GameAvatar.test.tsx b/resources/js/common/components/GameAvatar/GameAvatar.test.tsx new file mode 100644 index 0000000000..d367d29198 --- /dev/null +++ b/resources/js/common/components/GameAvatar/GameAvatar.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { GameAvatar } from './GameAvatar'; + +describe('Component: GameAvatar', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given a game title, shows the game title on the screen', () => { + // ARRANGE + const game = createGame(); + + render(); + + // ASSERT + expect(screen.getByText(game.title)).toBeVisible(); + }); + + it('given there is no title, still renders successfully', () => { + // ARRANGE + const game = createGame({ title: undefined }); + + render(); + + // ASSERT + expect(screen.getByRole('img', { name: /game/i })).toBeVisible(); + }); + + it('applies the correct size to the image', () => { + // ARRANGE + const game = createGame(); + + render(); + + // ASSERT + const imgEl = screen.getByRole('img'); + + expect(imgEl).toHaveAttribute('width', '8'); + expect(imgEl).toHaveAttribute('height', '8'); + }); + + it('adds card tooltip props by default', () => { + // ARRANGE + const game = createGame({ id: 1 }); + + render(); + + // ASSERT + const anchorEl = screen.getByRole('link'); + + expect(anchorEl).toHaveAttribute( + 'x-data', + "tooltipComponent($el, {dynamicType: 'game', dynamicId: '1', dynamicContext: 'undefined'})", + ); + expect(anchorEl).toHaveAttribute('x-on:mouseover', 'showTooltip($event)'); + expect(anchorEl).toHaveAttribute('x-on:mouseleave', 'hideTooltip'); + expect(anchorEl).toHaveAttribute('x-on:mousemove', 'trackMouseMovement($event)'); + }); + + it('does not add card tooltip props when `hasTooltip` is false', () => { + // ARRANGE + const game = createGame({ id: 1 }); + + render(); + + // ASSERT + const anchorEl = screen.getByRole('link'); + + expect(anchorEl).not.toHaveAttribute('x-data'); + expect(anchorEl).not.toHaveAttribute('x-on:mouseover'); + expect(anchorEl).not.toHaveAttribute('x-on:mouseleave'); + expect(anchorEl).not.toHaveAccessibleDescription('x-on:mousemove'); + }); +}); diff --git a/resources/js/common/components/GameAvatar/GameAvatar.tsx b/resources/js/common/components/GameAvatar/GameAvatar.tsx new file mode 100644 index 0000000000..54208125cf --- /dev/null +++ b/resources/js/common/components/GameAvatar/GameAvatar.tsx @@ -0,0 +1,49 @@ +import type { FC } from 'react'; + +import { useCardTooltip } from '@/common/hooks/useCardTooltip'; +import type { AvatarSize } from '@/common/models'; + +interface GameAvatarProps { + id: number; + + badgeUrl?: string; + hasTooltip?: boolean; + showBadge?: boolean; + showTitle?: boolean; + size?: AvatarSize; + title?: string; +} + +export const GameAvatar: FC = ({ + id, + badgeUrl, + showBadge, + showTitle, + title, + size = 32, + hasTooltip = true, +}) => { + const { cardTooltipProps } = useCardTooltip({ dynamicType: 'game', dynamicId: id }); + + return ( + + {showBadge !== false ? ( + + ) : null} + + {title && showTitle !== false ? {title} : null} + + ); +}; diff --git a/resources/js/common/components/GameAvatar/index.ts b/resources/js/common/components/GameAvatar/index.ts new file mode 100644 index 0000000000..e2a42ee035 --- /dev/null +++ b/resources/js/common/components/GameAvatar/index.ts @@ -0,0 +1 @@ +export * from './GameAvatar'; diff --git a/resources/js/common/components/UserAvatar/UserAvatar.tsx b/resources/js/common/components/UserAvatar/UserAvatar.tsx index ffdfac06e4..5821935519 100644 --- a/resources/js/common/components/UserAvatar/UserAvatar.tsx +++ b/resources/js/common/components/UserAvatar/UserAvatar.tsx @@ -1,14 +1,13 @@ import type { FC } from 'react'; import { useCardTooltip } from '@/common/hooks/useCardTooltip'; +import type { AvatarSize } from '@/common/models'; interface UserAvatarProps { displayName: string | null; hasTooltip?: boolean; - // This is strongly typed so we don't wind up with 100 different possible sizes. - // If possible, use one of these sane defaults. Only add another one if necessary. - size?: 8 | 16 | 24 | 32 | 64 | 128; + size?: AvatarSize; } export const UserAvatar: FC = ({ displayName, size = 32, hasTooltip = true }) => { diff --git a/resources/js/common/layouts/AppLayout/AppLayout.tsx b/resources/js/common/layouts/AppLayout/AppLayout.tsx index 8bcbe83ae0..daa3ccd19c 100644 --- a/resources/js/common/layouts/AppLayout/AppLayout.tsx +++ b/resources/js/common/layouts/AppLayout/AppLayout.tsx @@ -20,7 +20,7 @@ interface AppLayoutMainProps { } const AppLayoutMain: FC = ({ children }) => { - return
{children}
; + return
{children}
; }; interface AppLayoutSidebarProps { diff --git a/resources/js/common/models/avatar-size.model.ts b/resources/js/common/models/avatar-size.model.ts new file mode 100644 index 0000000000..232b2fcc05 --- /dev/null +++ b/resources/js/common/models/avatar-size.model.ts @@ -0,0 +1,3 @@ +// This is strongly typed so we don't wind up with 100 different possible sizes. +// If possible, use one of these sane defaults. Only add another one if necessary. +export type AvatarSize = 8 | 16 | 24 | 32 | 48 | 64 | 128; diff --git a/resources/js/common/models/index.ts b/resources/js/common/models/index.ts index a9cac77932..4e2d74273a 100644 --- a/resources/js/common/models/index.ts +++ b/resources/js/common/models/index.ts @@ -1,3 +1,4 @@ export * from './app-global-props.model'; export * from './app-page.model'; +export * from './avatar-size.model'; export * from './paginated-data.model'; diff --git a/resources/js/common/utils/buildTrackingClassNames.test.ts b/resources/js/common/utils/buildTrackingClassNames.test.ts new file mode 100644 index 0000000000..e6c5346a79 --- /dev/null +++ b/resources/js/common/utils/buildTrackingClassNames.test.ts @@ -0,0 +1,48 @@ +import { buildTrackingClassNames } from './buildTrackingClassNames'; + +describe('Util: buildTrackingClassNames', () => { + it('is defined', () => { + // ASSERT + expect(buildTrackingClassNames).toBeDefined(); + }); + + it('given only an event name is provided, returns a single classname', () => { + // ACT + const result = buildTrackingClassNames('Download Patch File Click'); + + // ASSERT + expect(result).toEqual('plausible-event-name=Download+Patch+File+Click'); + }); + + it('given an event name and a property are provided, returns multiple classnames', () => { + // ACT + const result = buildTrackingClassNames('Download Patch File Click', { md5: 'abc123' }); + + // ASSERT + expect(result).toEqual( + 'plausible-event-name=Download+Patch+File+Click plausible-event-md5=abc123', + ); + }); + + it('correctly handles multiple properties with different types', () => { + // ACT + const result = buildTrackingClassNames('Submit Form', { + id: 123, + success: true, + description: 'User Submitted Form', + }); + + // ASSERT + expect(result).toEqual( + 'plausible-event-name=Submit+Form plausible-event-id=123 plausible-event-success=true plausible-event-description=User+Submitted+Form', + ); + }); + + it('returns an empty string when neither event name nor properties are provided', () => { + // ACT + const result = buildTrackingClassNames(''); + + // ASSERT + expect(result).toEqual(''); + }); +}); diff --git a/resources/js/common/utils/buildTrackingClassNames.ts b/resources/js/common/utils/buildTrackingClassNames.ts new file mode 100644 index 0000000000..80354d9eaa --- /dev/null +++ b/resources/js/common/utils/buildTrackingClassNames.ts @@ -0,0 +1,36 @@ +/** + * Track a custom Plausible event on click. Optionally accepts custom properties to decorate + * the event with more specificity (see example). + * + * @example + * className={buildTrackingClassNames('Download Patch File', { md5: hash.md5 })} + * className={`px-3 py-4 ${buildTrackingClassNames('Download Patch File', { md5: hash.md5 })}`} + */ +export function buildTrackingClassNames( + customEventName: string, + customProperties?: Record, +) { + // Something has gone wrong. Bail. + if (customEventName.trim() === '') { + console.warn('buildTrackingClassNames() was called with an empty customEventName.'); + + return ''; + } + + const classNames: string[] = []; + + // Format the custom event name how Plausible expects. + // "My Custom Event" --> "My+Custom+Event" + const formattedEventName = `plausible-event-name=${customEventName.replace(/\s+/g, '+')}`; + classNames.push(formattedEventName); + + // Add each custom property. Spaces here must be replaced with plus signs, too. + if (customProperties) { + for (const [key, value] of Object.entries(customProperties)) { + const formattedValue = `${value}`.replace(/\s+/g, '+'); + classNames.push(`plausible-event-${key}=${formattedValue}`); + } + } + + return classNames.join(' '); +} diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx new file mode 100644 index 0000000000..8aac7a7fb8 --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@/test'; +import { createGame, createSystem } from '@/test/factories'; + +import { GameBreadcrumbs } from './GameBreadcrumbs'; + +describe('Component: GameBreadcrumbs', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('has a link to the All Games list', () => { + // ARRANGE + render(); + + // ASSERT + const allGamesLinkEl = screen.getByRole('link', { name: /all games/i }); + expect(allGamesLinkEl).toBeVisible(); + expect(allGamesLinkEl).toHaveAttribute('href', '/gameList.php'); + }); + + it('given a system, has a link to the system games list', () => { + // ARRANGE + const system = createSystem({ name: 'Nintendo 64' }); + const game = createGame({ system }); + + render(); + + // ASSERT + const systemGamesLinkEl = screen.getByRole('link', { name: /nintendo 64/i }); + expect(systemGamesLinkEl).toBeVisible(); + expect(systemGamesLinkEl).toHaveAttribute('href', `system.game.index,${system.id}`); + }); + + it('given a game, has a link to the game page', () => { + // ARRANGE + const system = createSystem({ name: 'Nintendo 64' }); + const game = createGame({ system }); + + render(); + + // ASSERT + const gameLinkEl = screen.getByRole('link', { name: game.title }); + expect(gameLinkEl).toBeVisible(); + expect(gameLinkEl).toHaveAttribute('href', `game.show,${{ game: game.id }}`); + }); +}); diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx new file mode 100644 index 0000000000..3062571a0b --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx @@ -0,0 +1,60 @@ +import type { FC } from 'react'; + +import { + BaseBreadcrumb, + BaseBreadcrumbItem, + BaseBreadcrumbLink, + BaseBreadcrumbList, + BaseBreadcrumbPage, + BaseBreadcrumbSeparator, +} from '@/common/components/+vendor/BaseBreadcrumb'; + +interface GameBreadcrumbsProps { + currentPageLabel: string; + + game?: App.Platform.Data.Game; + system?: App.Platform.Data.System; +} + +export const GameBreadcrumbs: FC = ({ currentPageLabel, game, system }) => { + return ( +
+ + + + All Games + + + {system ? ( + <> + + + + + {system.name} + + + + ) : null} + + {game ? ( + <> + + + + {game.title} + + + + ) : null} + + + + + {currentPageLabel} + + + +
+ ); +}; diff --git a/resources/js/features/games/components/GameBreadcrumbs/index.ts b/resources/js/features/games/components/GameBreadcrumbs/index.ts new file mode 100644 index 0000000000..b9bde9c324 --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './GameBreadcrumbs'; diff --git a/resources/js/features/games/components/GameHeading/GameHeading.test.tsx b/resources/js/features/games/components/GameHeading/GameHeading.test.tsx new file mode 100644 index 0000000000..aa80ee8429 --- /dev/null +++ b/resources/js/features/games/components/GameHeading/GameHeading.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { GameHeading } from './GameHeading'; + +describe('Component: GameHeading', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(Hello, World); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('displays a clickable avatar of the given game', () => { + // ARRANGE + const game = createGame(); + + render(Hello, World); + + // ASSERT + const linkEl = screen.getByRole('link'); + expect(linkEl).toBeVisible(); + expect(linkEl).toHaveAttribute('href', `game.show,${{ game: game.id }}`); + + expect(screen.getByRole('img', { name: game.title })).toBeVisible(); + }); + + it('displays an accessible header from `children`', () => { + // ARRANGE + const game = createGame(); + + render(Hello, World); + + // ASSERT + expect(screen.getByRole('heading', { name: /hello, world/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/games/components/GameHeading/GameHeading.tsx b/resources/js/features/games/components/GameHeading/GameHeading.tsx new file mode 100644 index 0000000000..5d21f438a9 --- /dev/null +++ b/resources/js/features/games/components/GameHeading/GameHeading.tsx @@ -0,0 +1,20 @@ +import type { FC, ReactNode } from 'react'; + +import { GameAvatar } from '@/common/components/GameAvatar'; + +interface GameHeadingProps { + children: ReactNode; + game: App.Platform.Data.Game; +} + +export const GameHeading: FC = ({ children, game }) => { + return ( +
+
+ +
+ +

{children}

+
+ ); +}; diff --git a/resources/js/features/games/components/GameHeading/index.ts b/resources/js/features/games/components/GameHeading/index.ts new file mode 100644 index 0000000000..9c378538df --- /dev/null +++ b/resources/js/features/games/components/GameHeading/index.ts @@ -0,0 +1 @@ +export * from './GameHeading'; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx new file mode 100644 index 0000000000..9b4736930e --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx @@ -0,0 +1,78 @@ +import { faker } from '@faker-js/faker'; + +import { render, screen } from '@/test'; +import { createGameHash } from '@/test/factories'; + +import { HashesList, hashesListContainerTestId } from './HashesList'; + +describe('Component: HashesList', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + hashes: [createGameHash()], + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given there are no hashes, renders nothing', () => { + // ARRANGE + render(, { + pageProps: { + hashes: [], + }, + }); + + // ASSERT + expect(screen.queryByTestId(hashesListContainerTestId)).not.toBeInTheDocument(); + }); + + it('renders both named and unnamed hashes', () => { + // ARRANGE + const hashes = [ + // Named + createGameHash({ name: faker.word.words(3) }), + createGameHash({ name: faker.word.words(3) }), + + // Unnamed + createGameHash({ name: null }), + ]; + + render(, { + pageProps: { hashes }, + }); + + // ASSERT + expect(screen.getAllByRole('listitem').length).toEqual(3); + }); + + it('displays the hash name and md5', () => { + // ARRANGE + const hash = createGameHash({ name: faker.word.words(3) }); + + render(, { + pageProps: { hashes: [hash] }, + }); + + // ASSERT + expect(screen.getByText(hash.name ?? '')).toBeVisible(); + expect(screen.getByText(hash.md5)).toBeVisible(); + }); + + it('given the hash has a patch URL, adds a link to it', () => { + // ARRANGE + const hash = createGameHash({ patchUrl: faker.internet.url() }); + + render(, { + pageProps: { hashes: [hash] }, + }); + + // ASSERT + const linkEl = screen.getByRole('link', { name: /download patch file/i }); + expect(linkEl).toBeVisible(); + expect(linkEl).toHaveAttribute('href', hash.patchUrl); + }); +}); diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx new file mode 100644 index 0000000000..22ba437543 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx @@ -0,0 +1,43 @@ +import { usePage } from '@inertiajs/react'; +import type { FC } from 'react'; + +import { Embed } from '@/common/components/Embed/Embed'; + +import { HashesListItem } from './HashesListItem'; + +export const hashesListContainerTestId = 'hashes-list'; + +export const HashesList: FC = () => { + const { + props: { hashes }, + } = usePage(); + + if (!hashes.length) { + return null; + } + + const namedHashes = hashes.filter((hash) => !!hash.name?.trim()); + const unnamedHashes = hashes.filter((hash) => !hash.name?.trim()); + + return ( + + {namedHashes.length ? ( +
    + {namedHashes.map((labeledHash) => ( + + ))} +
+ ) : null} + + {namedHashes.length && unnamedHashes.length ?
: null} + + {unnamedHashes.length ? ( +
    + {unnamedHashes.map((unlabeledHash) => ( + + ))} +
+ ) : null} + + ); +}; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx new file mode 100644 index 0000000000..5722526ca8 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react'; + +import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; + +interface HashListingProps { + hash: App.Platform.Data.GameHash; +} + +export const HashesListItem: FC = ({ hash }) => { + return ( +
  • +

    + {hash.name ? {hash.name} : null} + + {hash.labels.length ? ( + <> + {hash.labels.map((hashLabel) => ( + + ))} + + ) : null} +

    + +
    +

    {hash.md5}

    + + {hash.patchUrl ? ( + + Download Patch File + + ) : null} +
    +
  • + ); +}; + +interface HashLabelProps { + hashLabel: App.Platform.Data.GameHashLabel; +} + +export const HashLabel: FC = ({ hashLabel }) => { + const { imgSrc, label } = hashLabel; + + if (!imgSrc) { + return [{label}]; + } + + return ; +}; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts b/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts new file mode 100644 index 0000000000..05ef9e589e --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts @@ -0,0 +1 @@ +export * from './HashesList'; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx new file mode 100644 index 0000000000..6df63fe98e --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { HashesMainRoot } from './HashesMainRoot'; + +describe('Component: HashesMainRoot', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + can: { manageGameHashes: false }, + game: createGame(), + hashes: [], + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user can manage hashes, shows a manage link', () => { + // ARRANGE + render(, { + pageProps: { + can: { manageGameHashes: true }, + game: createGame(), + hashes: [], + }, + }); + + // ASSERT + expect(screen.getByRole('link', { name: /manage hashes/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx new file mode 100644 index 0000000000..59e937299e --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx @@ -0,0 +1,74 @@ +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 { GameBreadcrumbs } from '../GameBreadcrumbs'; +import { GameHeading } from '../GameHeading/GameHeading'; +import { HashesList } from './HashesList'; + +export const HashesMainRoot: FC = () => { + const { + props: { can, game, hashes }, + } = usePage(); + + return ( +
    + + Supported Game Files + +
    + {can.manageGameHashes ? ( + + + Manage Hashes + + ) : null} + + +

    + This page shows you what ROM hashes are compatible with this game's achievements. +

    + +

    + {game.forumTopicId ? ( + <> + Additional information for these hashes may be listed on{' '} + + the game's official forum topic + + . + + ) : null}{' '} + Details on how the hash is generated for each system can be found{' '} + + here + + .{' '} +

    + + +
    +

    + There {hashes.length === 1 ? 'is' : 'are'} currently{' '} + {hashes.length} supported game file{' '} + {hashes.length === 1 ? 'hash' : 'hashes'} registered for this game. +

    + + +
    +
    +
    + ); +}; diff --git a/resources/js/features/games/components/HashesMainRoot/index.ts b/resources/js/features/games/components/HashesMainRoot/index.ts new file mode 100644 index 0000000000..da529098fd --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/index.ts @@ -0,0 +1 @@ +export * from './HashesMainRoot'; diff --git a/resources/js/pages/game/[game]/hashes.tsx b/resources/js/pages/game/[game]/hashes.tsx new file mode 100644 index 0000000000..d3a457a8b6 --- /dev/null +++ b/resources/js/pages/game/[game]/hashes.tsx @@ -0,0 +1,26 @@ +import { Head } from '@inertiajs/react'; + +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { HashesMainRoot } from '@/features/games/components/HashesMainRoot'; + +const Hashes: AppPage = ({ game, hashes }) => { + return ( + <> + + + + + + + + + ); +}; + +Hashes.layout = (page) => {page}; + +export default Hashes; diff --git a/resources/js/test/factories/createGame.ts b/resources/js/test/factories/createGame.ts new file mode 100644 index 0000000000..921e698398 --- /dev/null +++ b/resources/js/test/factories/createGame.ts @@ -0,0 +1,12 @@ +import { createFactory } from '../createFactory'; +import { createSystem } from './createSystem'; + +export const createGame = createFactory((faker) => { + return { + id: faker.number.int({ min: 1, max: 99999 }), + title: faker.word.words(3), + badgeUrl: faker.internet.url(), + forumTopicId: faker.number.int({ min: 1, max: 99999 }), + system: createSystem(), + }; +}); diff --git a/resources/js/test/factories/createGameHash.ts b/resources/js/test/factories/createGameHash.ts new file mode 100644 index 0000000000..dedba16e34 --- /dev/null +++ b/resources/js/test/factories/createGameHash.ts @@ -0,0 +1,19 @@ +import { createFactory } from '../createFactory'; +import { createGameHashLabel } from './createGameHashLabel'; + +export const createGameHash = createFactory((faker) => { + const labelsCount = faker.number.int({ min: 0, max: 2 }); + + const labels: App.Platform.Data.GameHashLabel[] = []; + for (let i = 0; i < labelsCount; i += 1) { + labels.push(createGameHashLabel()); + } + + return { + labels, + id: faker.number.int({ min: 1, max: 99999 }), + md5: faker.string.alphanumeric(32), + name: faker.word.words(3), + patchUrl: faker.internet.url(), + }; +}); diff --git a/resources/js/test/factories/createGameHashLabel.ts b/resources/js/test/factories/createGameHashLabel.ts new file mode 100644 index 0000000000..88239323ad --- /dev/null +++ b/resources/js/test/factories/createGameHashLabel.ts @@ -0,0 +1,17 @@ +import { createFactory } from '../createFactory'; + +export const createGameHashLabel = createFactory((faker) => ({ + imgSrc: faker.internet.url(), + label: faker.helpers.arrayElement([ + 'nointro', + 'rapatches', + 'fbneo', + 'goodtools', + 'redump', + 'mamesl', + 'tosec', + 'itchio', + 'msu1', + 'lostlevel', + ]), +})); diff --git a/resources/js/test/factories/createSystem.ts b/resources/js/test/factories/createSystem.ts new file mode 100644 index 0000000000..849d15477e --- /dev/null +++ b/resources/js/test/factories/createSystem.ts @@ -0,0 +1,10 @@ +import { createFactory } from '../createFactory'; + +export const createSystem = createFactory((faker) => { + return { + id: faker.number.int({ min: 1, max: 150 }), + name: faker.word.words(2), + nameFull: faker.word.words(4), + nameShort: faker.string.alphanumeric(3), + }; +}); diff --git a/resources/js/test/factories/index.ts b/resources/js/test/factories/index.ts index 97c8eec043..d59d275bd8 100644 --- a/resources/js/test/factories/index.ts +++ b/resources/js/test/factories/index.ts @@ -1 +1,6 @@ +export * from './createForumTopicComment'; +export * from './createGame'; +export * from './createGameHash'; +export * from './createGameHashLabel'; +export * from './createSystem'; export * from './createUser'; diff --git a/resources/js/test/setup.tsx b/resources/js/test/setup.tsx index 4ac3a8e289..fa58d0a5fa 100644 --- a/resources/js/test/setup.tsx +++ b/resources/js/test/setup.tsx @@ -24,14 +24,19 @@ vi.mock('@inertiajs/react', () => ({ type DefaultParams = Parameters; type RenderUI = DefaultParams[0]; -type RenderOptions = DefaultParams[1] & { pageProps?: Record }; // augment this as necessary +type RenderOptions> = DefaultParams[1] & { + pageProps?: TPageProps; +}; interface WrapperProps { children: ReactNode; } -export function render(ui: RenderUI, { wrapper, pageProps = {}, ...options }: RenderOptions = {}) { - vi.spyOn(InertiajsReactModule, 'usePage').mockImplementationOnce(() => ({ +export function render>( + ui: RenderUI, + { wrapper, pageProps = {} as TPageProps, ...options }: RenderOptions> = {}, +) { + vi.spyOn(InertiajsReactModule, 'usePage').mockImplementation(() => ({ component: '', props: pageProps as any, rememberedState: {}, diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index e4dd0b8264..7bbe26f472 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -42,6 +42,9 @@ declare namespace App.Data { roles?: App.Models.UserRole[]; unreadMessageCount?: number | null; }; + export type UserPermissions = { + manageGameHashes?: boolean; + }; } declare namespace App.Models { export type UserRole = @@ -70,6 +73,29 @@ declare namespace App.Models { | 'developer-veteran'; } declare namespace App.Platform.Data { + export type Game = { + id: number; + title: string; + badgeUrl?: string; + forumTopicId?: number; + system?: App.Platform.Data.System; + }; + export type GameHash = { + id: number; + md5: string; + name: string | null; + labels: Array; + patchUrl: string | null; + }; + export type GameHashLabel = { + label: string; + imgSrc: string | null; + }; + export type GameHashesPagePropsData = { + game: App.Platform.Data.Game; + hashes: Array; + can: App.Data.UserPermissions; + }; export type PlayerResettableGameAchievement = { id: number; title: string; @@ -83,7 +109,14 @@ declare namespace App.Platform.Data { numAwarded: number; numPossible: number; }; + export type System = { + id: number; + name: string; + nameFull?: string; + nameShort?: string; + }; } declare namespace App.Platform.Enums { + export type GameSetType = 'hub' | 'similar-games'; export type AchievementFlag = 3 | 5; } diff --git a/resources/js/ziggy.d.ts b/resources/js/ziggy.d.ts index 4ad6a4de79..4b59077da1 100644 --- a/resources/js/ziggy.d.ts +++ b/resources/js/ziggy.d.ts @@ -159,13 +159,6 @@ declare module 'ziggy-js' { } ], "game.random": [], - "game.hash": [ - { - "name": "game", - "required": true, - "binding": "ID" - } - ], "game.hash.manage": [ { "name": "game", @@ -263,6 +256,13 @@ declare module 'ziggy-js' { "claims.expiring": [], "claims.completed": [], "claims.active": [], + "game.hashes.index": [ + { + "name": "game", + "required": true, + "binding": "ID" + } + ], "game-hash.update": [ { "name": "gameHash", diff --git a/resources/views/components/game/link-buttons/index.blade.php b/resources/views/components/game/link-buttons/index.blade.php index f9c5c9cc1c..b2267c43d7 100644 --- a/resources/views/components/game/link-buttons/index.blade.php +++ b/resources/views/components/game/link-buttons/index.blade.php @@ -67,7 +67,7 @@ @can('viewAny', App\Models\GameHash::class) Supported Game Files diff --git a/resources/views/components/supported-game-files/hash-listing.blade.php b/resources/views/components/supported-game-files/hash-listing.blade.php deleted file mode 100644 index 99a0dbfe6f..0000000000 --- a/resources/views/components/supported-game-files/hash-listing.blade.php +++ /dev/null @@ -1,40 +0,0 @@ -@props([ - 'hash' => null, // GameHash -]) - -
  • -

    - @if ($hash->name) - {{ $hash->name }} - @endif - - @if (!empty($hash->labels)) - @foreach (explode(',', $hash->labels) as $label) - @if (empty($label)) - @continue; - @endif - - @php - $image = "/assets/images/labels/" . $label . '.png'; - $publicPath = public_path($image); - @endphp - - @if (file_exists($publicPath)) - - @else - [{{ $label }}] - @endif - @endforeach - @endif -

    - -
    -

    - {{ $hash->md5 }} -

    - - @if ($hash->patch_url) - Download Patch File - @endif -
    -
  • diff --git a/resources/views/pages/game/[game]/hashes/index.blade.php b/resources/views/pages/game/[game]/hashes/index.blade.php deleted file mode 100644 index d1fbe5155c..0000000000 --- a/resources/views/pages/game/[game]/hashes/index.blade.php +++ /dev/null @@ -1,105 +0,0 @@ -hashes()->with('user')->orderBy('name')->orderBy('md5')->get(); - $numHashes = $hashes->count(); - - $unlabeledHashes = $hashes->filter(function ($hash) { - return empty($hash->name); - }); - $labeledHashes = $hashes->reject(function ($hash) { - return empty($hash->name); - }); - - return $view->with([ - 'labeledHashes' => $labeledHashes, - 'numHashes' => $numHashes, - 'unlabeledHashes' => $unlabeledHashes, - ]); -}); - -?> - -@props([ - 'labeledHashes' => null, // Collection - 'numHashes' => 0, - 'unlabeledHashes' => null, // Collection -]) - - - - -
    - {!! gameAvatar($game->toArray(), label: false, iconSize: 48, iconClass: 'rounded-sm') !!} -

    Supported Game Files

    -
    - - @can('manage', App\Models\GameHash::class, ['game' => $game]) -
    - -
    - @endcan - -
    -

    - - RetroAchievements requires that the game files you use with your emulator - are the same as, or compatible with, those used to create the game's achievements. - - This page shows you what ROM hashes are compatible with the game's achievements. -

    - -

    - Details on how the hash is generated for each system can be found - here. - - @if ($game->ForumTopicID > 0) - Additional information for these hashes may be listed on the - official forum topic. - @endif -

    -
    - -

    - There {{ $numHashes === 1 ? 'is' : 'are' }} currently {{ $numHashes }} - supported game file {{ strtolower(__res('game-hash', $numHashes)) }} registered for this game. -

    - -
    -
      - @foreach ($labeledHashes as $hash) - - @endforeach -
    - - @if (!$labeledHashes->isEmpty() && !$unlabeledHashes->isEmpty()) -
    - @endif - - @if (!$unlabeledHashes->isEmpty()) -

    Unlabeled Game File Hashes

    - -
      - @foreach ($unlabeledHashes as $hash) - - @endforeach -
    - @endif -
    -
    From a6b204bb2bdd782a8c061a582d36a782f93942a8 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 30 Aug 2024 21:09:39 -0400 Subject: [PATCH 2/6] chore: fix stuff --- app/Data/UserPermissionsData.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Data/UserPermissionsData.php b/app/Data/UserPermissionsData.php index 536f600555..17a8945e13 100644 --- a/app/Data/UserPermissionsData.php +++ b/app/Data/UserPermissionsData.php @@ -17,10 +17,10 @@ public function __construct( ) { } - public static function fromUser(User $user): self + public static function fromUser(?User $user): self { return new self( - manageGameHashes: $user->can('manage', \App\Models\GameHash::class) + manageGameHashes: $user ? $user->can('manage', \App\Models\GameHash::class) : false ); } } From e822d06656d202d1e9ad8039f3a570c74fdb9e96 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 31 Aug 2024 11:14:43 -0400 Subject: [PATCH 3/6] refactor: improve type safety --- .../js/common/utils/buildTrackingClassNames.test.ts | 3 +++ resources/js/test/setup.tsx | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/resources/js/common/utils/buildTrackingClassNames.test.ts b/resources/js/common/utils/buildTrackingClassNames.test.ts index e6c5346a79..639acc6eee 100644 --- a/resources/js/common/utils/buildTrackingClassNames.test.ts +++ b/resources/js/common/utils/buildTrackingClassNames.test.ts @@ -1,5 +1,8 @@ import { buildTrackingClassNames } from './buildTrackingClassNames'; +// It isn't necessary log the warning for an empty customEventName. +global.console.warn = vi.fn(); + describe('Util: buildTrackingClassNames', () => { it('is defined', () => { // ASSERT diff --git a/resources/js/test/setup.tsx b/resources/js/test/setup.tsx index fa58d0a5fa..28273e5d7b 100644 --- a/resources/js/test/setup.tsx +++ b/resources/js/test/setup.tsx @@ -4,6 +4,8 @@ import * as InertiajsReactModule from '@inertiajs/react'; import { render as defaultRender } from '@testing-library/react'; import type { ReactNode } from 'react'; +import type { AppGlobalProps } from '@/common/models'; + export * from '@testing-library/react'; vi.mock('@inertiajs/react', () => ({ @@ -25,7 +27,7 @@ vi.mock('@inertiajs/react', () => ({ type DefaultParams = Parameters; type RenderUI = DefaultParams[0]; type RenderOptions> = DefaultParams[1] & { - pageProps?: TPageProps; + pageProps?: Partial; }; interface WrapperProps { @@ -34,7 +36,11 @@ interface WrapperProps { export function render>( ui: RenderUI, - { wrapper, pageProps = {} as TPageProps, ...options }: RenderOptions> = {}, + { + wrapper, + pageProps = {} as Partial, + ...options + }: RenderOptions = {}, ) { vi.spyOn(InertiajsReactModule, 'usePage').mockImplementation(() => ({ component: '', From d286bd6264bc46f54f5ac1792be36ab01025c323 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 31 Aug 2024 15:22:56 -0400 Subject: [PATCH 4/6] chore: fix pluralization --- resources/js/pages/game/[game]/hashes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/pages/game/[game]/hashes.tsx b/resources/js/pages/game/[game]/hashes.tsx index d3a457a8b6..979260fbac 100644 --- a/resources/js/pages/game/[game]/hashes.tsx +++ b/resources/js/pages/game/[game]/hashes.tsx @@ -10,7 +10,7 @@ const Hashes: AppPage = ({ game, hash From 15cc07e7540b7f0491f41993151dfdfa63fd2106 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 1 Sep 2024 15:50:39 -0400 Subject: [PATCH 5/6] chore: type safety improvements --- app/Data/UserPermissionsData.php | 2 +- app/Platform/Data/GameHashesPagePropsData.php | 2 +- .../HashesMainRoot/HashesList/HashesList.test.tsx | 10 +++++----- .../HashesMainRoot/HashesList/HashesList.tsx | 2 +- .../components/HashesMainRoot/HashesMainRoot.test.tsx | 4 ++-- .../games/components/HashesMainRoot/HashesMainRoot.tsx | 2 +- resources/js/pages/game/[game]/hashes.tsx | 2 +- resources/js/types/generated.d.ts | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/Data/UserPermissionsData.php b/app/Data/UserPermissionsData.php index 17a8945e13..be495428de 100644 --- a/app/Data/UserPermissionsData.php +++ b/app/Data/UserPermissionsData.php @@ -20,7 +20,7 @@ public function __construct( public static function fromUser(?User $user): self { return new self( - manageGameHashes: $user ? $user->can('manage', \App\Models\GameHash::class) : false + manageGameHashes: Lazy::create(fn () => $user ? $user->can('manage', \App\Models\GameHash::class) : false), ); } } diff --git a/app/Platform/Data/GameHashesPagePropsData.php b/app/Platform/Data/GameHashesPagePropsData.php index aa8706be97..91e16bf7ab 100644 --- a/app/Platform/Data/GameHashesPagePropsData.php +++ b/app/Platform/Data/GameHashesPagePropsData.php @@ -8,7 +8,7 @@ use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; -#[TypeScript('GameHashesPagePropsData')] +#[TypeScript('GameHashesPageProps')] class GameHashesPagePropsData extends Data { /** diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx index 9b4736930e..814851f289 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx @@ -8,7 +8,7 @@ import { HashesList, hashesListContainerTestId } from './HashesList'; describe('Component: HashesList', () => { it('renders without crashing', () => { // ARRANGE - const { container } = render(, { + const { container } = render(, { pageProps: { hashes: [createGameHash()], }, @@ -20,7 +20,7 @@ describe('Component: HashesList', () => { it('given there are no hashes, renders nothing', () => { // ARRANGE - render(, { + render(, { pageProps: { hashes: [], }, @@ -41,7 +41,7 @@ describe('Component: HashesList', () => { createGameHash({ name: null }), ]; - render(, { + render(, { pageProps: { hashes }, }); @@ -53,7 +53,7 @@ describe('Component: HashesList', () => { // ARRANGE const hash = createGameHash({ name: faker.word.words(3) }); - render(, { + render(, { pageProps: { hashes: [hash] }, }); @@ -66,7 +66,7 @@ describe('Component: HashesList', () => { // ARRANGE const hash = createGameHash({ patchUrl: faker.internet.url() }); - render(, { + render(, { pageProps: { hashes: [hash] }, }); diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx index 22ba437543..040951bcf1 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx @@ -10,7 +10,7 @@ export const hashesListContainerTestId = 'hashes-list'; export const HashesList: FC = () => { const { props: { hashes }, - } = usePage(); + } = usePage(); if (!hashes.length) { return null; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx index 6df63fe98e..9a3bb322f0 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx @@ -6,7 +6,7 @@ import { HashesMainRoot } from './HashesMainRoot'; describe('Component: HashesMainRoot', () => { it('renders without crashing', () => { // ARRANGE - const { container } = render(, { + const { container } = render(, { pageProps: { can: { manageGameHashes: false }, game: createGame(), @@ -20,7 +20,7 @@ describe('Component: HashesMainRoot', () => { it('given the user can manage hashes, shows a manage link', () => { // ARRANGE - render(, { + render(, { pageProps: { can: { manageGameHashes: true }, game: createGame(), diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx index 59e937299e..d5a150aeea 100644 --- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx @@ -12,7 +12,7 @@ import { HashesList } from './HashesList'; export const HashesMainRoot: FC = () => { const { props: { can, game, hashes }, - } = usePage(); + } = usePage(); return (
    diff --git a/resources/js/pages/game/[game]/hashes.tsx b/resources/js/pages/game/[game]/hashes.tsx index 979260fbac..7d70a674c5 100644 --- a/resources/js/pages/game/[game]/hashes.tsx +++ b/resources/js/pages/game/[game]/hashes.tsx @@ -4,7 +4,7 @@ import { AppLayout } from '@/common/layouts/AppLayout'; import type { AppPage } from '@/common/models'; import { HashesMainRoot } from '@/features/games/components/HashesMainRoot'; -const Hashes: AppPage = ({ game, hashes }) => { +const Hashes: AppPage = ({ game, hashes }) => { return ( <> diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 7bbe26f472..23dd8a5a1d 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -12,12 +12,12 @@ declare namespace App.Data { id: number; title: string; createdAt: string; - user: App.Data.User | null; latestComment?: App.Data.ForumTopicComment; commentCount24h?: number; oldestComment24hId?: number; commentCount7d?: number; oldestComment7dId?: number; + user: App.Data.User | null; }; export type __UNSAFE_PaginatedData = { currentPage: number; @@ -91,7 +91,7 @@ declare namespace App.Platform.Data { label: string; imgSrc: string | null; }; - export type GameHashesPagePropsData = { + export type GameHashesPageProps = { game: App.Platform.Data.Game; hashes: Array; can: App.Data.UserPermissions; From 06e3579a8b08c731ff5e4f2804e6b17099ea5dc9 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 3 Sep 2024 17:05:09 -0400 Subject: [PATCH 6/6] chore: remove invalid prop definition --- resources/js/ssr.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx index 3fa495f139..83b1bdc76c 100644 --- a/resources/js/ssr.tsx +++ b/resources/js/ssr.tsx @@ -7,7 +7,7 @@ import ReactDOMServer from 'react-dom/server'; import type { RouteName, RouteParams } from 'ziggy-js'; import { route } from '../../vendor/tightenco/ziggy'; -import { AppProviders } from './common/components/AppProviders/AppProviders'; +import { AppProviders } from './common/components/AppProviders'; const appName = import.meta.env.APP_NAME || 'RetroAchievements'; @@ -32,7 +32,7 @@ createServer((page) => }); return ( - + );