-
-
Notifications
You must be signed in to change notification settings - Fork 100
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: improve 'Claimed' column UX in datatable (#2986)
- Loading branch information
1 parent
6ec695d
commit 3b4a86a
Showing
20 changed files
with
317 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
resources/js/common/components/UserAvatarStack/UserAvatarStack.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
92
resources/js/common/components/UserAvatarStack/UserAvatarStack.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './UserAvatarStack'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.