Skip to content

Commit

Permalink
feat: improve 'Claimed' column UX in datatable (#2986)
Browse files Browse the repository at this point in the history
  • Loading branch information
wescopeland authored Jan 1, 2025
1 parent 6ec695d commit 3b4a86a
Show file tree
Hide file tree
Showing 20 changed files with 317 additions and 34 deletions.
1 change: 1 addition & 0 deletions .eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ rules:
"baseCommandListClassName",
"containerClassName",
"gameTitleClassName",
"imgClassName",
"wrapperClassName",
],
"plugins": ["prettier-plugin-tailwindcss"]
Expand Down
1 change: 1 addition & 0 deletions app/Platform/Actions/BuildGameListAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function execute(
game: GameData::from($game)->include(
'achievementsPublished',
'badgeUrl',
'claimants',
'hasActiveOrInReviewClaims',
'lastUpdated',
'numVisibleLeaderboards',
Expand Down
11 changes: 10 additions & 1 deletion app/Platform/Concerns/BuildsGameListQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,16 @@ private function buildBaseQuery(
?User $user = null,
?int $targetId = null,
): Builder {
$query = Game::with(['system'])
$query = Game::query()
->with([
'system',
'achievementSetClaims' => function ($query) {
$query->activeOrInReview()->with(['user' => function ($query) {
// Only select the fields we need for the UserData DTO.
$query->select(['ID', 'User', 'display_name', 'Permissions']);
}]);
},
])
->withLastAchievementUpdate()
->addSelect(['GameData.*'])
->addSelect([
Expand Down
28 changes: 28 additions & 0 deletions app/Platform/Data/GameClaimantData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Platform\Data;

use App\Data\UserData;
use App\Models\User;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript('GameClaimant')]
class GameClaimantData extends Data
{
public function __construct(
public UserData $user,
public string $claimType, // "primary" or "collaboration"
) {
}

public static function fromUser(User $user, string $claimType): self
{
return new self(
user: UserData::from($user),
claimType: $claimType,
);
}
}
9 changes: 9 additions & 0 deletions app/Platform/Data/GameData.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Platform\Data;

use App\Community\Enums\ClaimType;
use App\Models\Game;
use Illuminate\Support\Carbon;
use Spatie\LaravelData\Data;
Expand All @@ -29,6 +30,8 @@ public function __construct(
public Lazy|int $numVisibleLeaderboards,
public Lazy|int $numUnresolvedTickets,
public Lazy|bool $hasActiveOrInReviewClaims,
/** @var Lazy|array<GameClaimantData> */
public Lazy|array $claimants,
) {
}

Expand All @@ -50,6 +53,12 @@ public static function fromGame(Game $game): self
numVisibleLeaderboards: Lazy::create(fn () => $game->num_visible_leaderboards ?? 0),
numUnresolvedTickets: Lazy::create(fn () => $game->num_unresolved_tickets ?? 0),
hasActiveOrInReviewClaims: Lazy::create(fn () => $game->has_active_or_in_review_claims ?? false),
claimants: Lazy::create(fn () => $game->achievementSetClaims->map(
fn ($claim) => GameClaimantData::fromUser(
$claim->user,
$claim->ClaimType === ClaimType::Primary ? 'primary' : 'collaboration'
)
)->all()),
);
}
}
1 change: 0 additions & 1 deletion lang/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@
"Notification type": "Notification type",
"Notifications": "Notifications",
"Notify me on the site": "Notify me on the site",
"One or more developers are currently working on this game.": "One or more developers are currently working on this game.",
"Only people I follow can message me or post on my wall": "Only people I follow can message me or post on my wall",
"Only png, jpeg, and gif files are supported.": "Only png, jpeg, and gif files are supported.",
"Open Game": "Open Game",
Expand Down
3 changes: 2 additions & 1 deletion resources/js/common/components/UserAvatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type UserAvatarProps = BaseAvatarProps & App.Data.User;
export const UserAvatar: FC<UserAvatarProps> = ({
displayName,
deletedAt,
imgClassName,
hasTooltip = true,
showImage = true,
showLabel = true,
Expand All @@ -33,7 +34,7 @@ export const UserAvatar: FC<UserAvatarProps> = ({
height={size}
src={`http://media.retroachievements.org/UserPic/${displayName}.png`}
alt={displayName ?? 'Deleted User'}
className="rounded-sm"
className={cn('rounded-sm', imgClassName)}
/>
) : null}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import userEvent from '@testing-library/user-event';

import { createAuthenticatedUser } from '@/common/models';
import { render, screen, waitFor } from '@/test';
import { createUser } from '@/test/factories';

import { UserAvatarStack } from './UserAvatarStack';

describe('Component: UserAvatarStack', () => {
it('renders without crashing', () => {
// ARRANGE
const { container } = render(<UserAvatarStack users={[]} />);

// ASSERT
expect(container).toBeTruthy();
});

it('given no users are provided, renders nothing', () => {
// ARRANGE
render(<UserAvatarStack users={[]} />);

// ASSERT
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});

it('given a list of users is provided under the max visible limit, shows all users', () => {
// ARRANGE
const users = [createUser(), createUser(), createUser()];

render(<UserAvatarStack users={users} maxVisible={5} />);

// ASSERT
const avatarList = screen.getByRole('list');
// eslint-disable-next-line testing-library/no-node-access -- this is fine here, the count of nodes is relevant
expect(avatarList.children).toHaveLength(3);
});

it('given more users than the max visible limit, shows the overflow indicator', () => {
// ARRANGE
const users = [
createUser(),
createUser(),
createUser(),
createUser(),
createUser(),
createUser(), // !! 6th user
];

render(<UserAvatarStack users={users} maxVisible={5} />);

// ASSERT
const avatarList = screen.getByRole('list');
// eslint-disable-next-line testing-library/no-node-access -- this is fine here, the count of nodes is relevant
expect(avatarList.children).toHaveLength(5); // !! 4 avatars + overflow
expect(screen.getByText(/\+2/i)).toBeVisible();
});

it('given a size prop of 24, applies the correct size classes', () => {
// ARRANGE
const users = [
createUser(),
createUser(),
createUser(),
createUser(),
createUser(),
createUser(), // !! 6th user
];

render(<UserAvatarStack users={users} maxVisible={5} size={24} />);

// ASSERT
const overflowIndicator = screen.getByTestId('overflow-indicator');
expect(overflowIndicator).toHaveClass('size-6');
});

it('given a size prop of 28, applies the correct size classes', () => {
// ARRANGE
const users = [
createUser(),
createUser(),
createUser(),
createUser(),
createUser(),
createUser(), // !! 6th user
];

render(<UserAvatarStack users={users} maxVisible={5} size={28} />);

// ASSERT
const overflowIndicator = screen.getByTestId('overflow-indicator');
expect(overflowIndicator).toHaveClass('size-7');
});

it('given the user is using a non-English locale, formats the overflow list correctly', async () => {
// ARRANGE
const users = [
createUser(),
createUser(),
createUser(),
createUser(),
createUser({ displayName: 'Scott' }),
createUser({ displayName: 'TheMysticalOne' }),
createUser({ displayName: 'Jamiras' }),
];

render(<UserAvatarStack users={users} maxVisible={5} size={28} />, {
pageProps: { auth: { user: createAuthenticatedUser({ locale: 'pt_BR' }) } }, // !!
});

// ACT
await userEvent.hover(screen.getByText(/\+3/i));

// ASSERT
await waitFor(() => {
expect(screen.getAllByText(/Jamiras, Scott e TheMysticalOne/i)[0]).toBeVisible();
});
});
});
92 changes: 92 additions & 0 deletions resources/js/common/components/UserAvatarStack/UserAvatarStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { type FC, useId } from 'react';

import { usePageProps } from '@/common/hooks/usePageProps';
import type { AuthenticatedUser, AvatarSize } from '@/common/models';
import { cn } from '@/common/utils/cn';

import { BaseTooltip, BaseTooltipContent, BaseTooltipTrigger } from '../+vendor/BaseTooltip';
import { UserAvatar } from '../UserAvatar';

interface UserAvatarStackProps {
users: App.Data.User[];

size?: AvatarSize;

/**
* Maximum number of avatars to display. If there are more than this many
* users, we'll show (maxVisible - 1) avatars and a "+N" indicator.
*
* In other words, if maxVisible is 5 and we have 5 users, we'll show all
* 5 avatars. If we have 6 users, we'll show 4 avatars and a "+2" label.
*/
maxVisible?: number;
}

export const UserAvatarStack: FC<UserAvatarStackProps> = ({ users, maxVisible = 5, size = 32 }) => {
const { auth } = usePageProps();

const id = useId();

if (!users.length) {
return null;
}

const userIntlLocale = getUserIntlLocale(auth?.user);

const visibleCount = users.length > maxVisible ? maxVisible - 1 : maxVisible;
const visibleUsers = users.slice(0, visibleCount);
const remainingUsers = users.slice(visibleCount);
const remainingCount = remainingUsers.length;

const formatter = new Intl.ListFormat(userIntlLocale, { style: 'long', type: 'conjunction' });
const remainingNames = formatter.format(remainingUsers.map((user) => user.displayName).sort());

const numberFormatter = new Intl.NumberFormat(userIntlLocale, { signDisplay: 'always' });
const formattedCount = numberFormatter.format(remainingCount);

return (
<div className="flex -space-x-2.5" role="list">
{visibleUsers.map((user) => (
<UserAvatar
key={`user-avatar-stack-${id}-${user.displayName}`}
{...user}
size={size}
showLabel={false}
imgClassName="rounded-full ring-2 ring-neutral-800 light:ring-neutral-300"
/>
))}

{remainingCount > 0 ? (
<BaseTooltip>
<BaseTooltipTrigger>
<div
data-testid="overflow-indicator"
className={cn(
'flex items-center justify-center rounded-full text-[10px]',
'font-mono tracking-tight ring-2',

'bg-neutral-800 text-neutral-300 ring-neutral-700',
'light:bg-neutral-200 light:text-neutral-700 light:ring-neutral-300',

// TODO reusable avatar size helper
size === 24 ? 'size-6' : null,
size === 28 ? 'size-7' : null,
size === 32 ? 'size-8' : null,
)}
>
{formattedCount}
</div>
</BaseTooltipTrigger>

<BaseTooltipContent className="max-w-[300px] text-pretty">
<p>{remainingNames}</p>
</BaseTooltipContent>
</BaseTooltip>
) : null}
</div>
);
};

function getUserIntlLocale(user: AuthenticatedUser | undefined): string {
return user?.locale?.replace('_', '-') ?? 'en-us';
}
1 change: 1 addition & 0 deletions resources/js/common/components/UserAvatarStack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './UserAvatarStack';
10 changes: 7 additions & 3 deletions resources/js/common/models/base-avatar-props.model.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
// 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 | 40 | 48 | 64 | 96 | 128;
/**
* This is strongly-typed so we don't wind up with 100 different possible sizes.
* If possible, use one of these sane defaults.
* When adding another one, try to ensure it corresponds to a Tailwind size-* class.
*/
export type AvatarSize = 8 | 16 | 24 | 28 | 32 | 40 | 48 | 64 | 96 | 128;

export interface BaseAvatarProps {
hasTooltip?: boolean;
imgClassName?: string;
shouldLink?: boolean;
showImage?: boolean;
showLabel?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function useColumnDefinitions(options: {
buildHasActiveOrInReviewClaimsColumnDef({
t_label: t('Claimed'),
strings: {
t_description: t('One or more developers are currently working on this game.'),
t_no: t('No'),
t_yes: t('Yes'),
},
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function useColumnDefinitions(options: {
tableApiRouteParams,
t_label: t('Claimed'),
strings: {
t_description: t('One or more developers are currently working on this game.'),
t_no: t('No'),
t_yes: t('Yes'),
},
}),
Expand Down
Loading

0 comments on commit 3b4a86a

Please sign in to comment.