Skip to content

Commit

Permalink
feat: [M3-8158] - Begin to sunset Gravatar (linode#10859)
Browse files Browse the repository at this point in the history
* Swap out gravatar for colored avatar on Profile > Display

* Add utils to determine letter color

* Bring back checkForGravatar event and fix hasGravatar boolean

* Hide tooltip if user unless user hasGravatar

* Add GravatarSunsetBanner.tsx

* Use util for banner and profile page

* Add Akamai wave icon for Akamai-generated user events

* Clean up of theme colors and conditional rendering

* Style ColorPicker

* Try to handle constrast ratio

* Address UX feedback: show avatar preview in dialog

* Add new avatar to EventRow on Event Landing page

* Conditionally render Gravatar in EventRow until sunset

* Replace gravatar conditionally in UserRow of Users Landing

* Conditionally render styled Avatar in UserSSHKeyPanel

* Conditionally render Avatar in TopMenu; rename GravatarForProxy

* Conditionally render styled Avatar in NotificationCenterEvent

* Fix sunset date

* Use MUI theme function to get contrasting text color

* Clean up; change color default to darker color

* Clean up Avatar, ColorPicker; add stories

* Clean up and add unit tests

* Clean up; fix test

* Forgot to push last changes for Support; default color fix

* Add changesets

* Fix an accidentally skipped test

* Address UX feedback: use 'Avatar' over 'Profile photo'

* Address feedback: avoid regex

* Fix bug: NotificationCenterEvent missing Linode system avatar

* Use hook throughout gravatar replacement

* improve useGravatar hook

* Fix: showing new system/support avatars when user has Gravatar enabled

* Experiment with component for loading/gravatar/avatar

* Handle loading state and fade per-component to fix flickering

* Fix username for support tickets; clean up

* Fix avatar color for additional account users

* Switch over to single GravatarOrAvatar component for rendering

* Clean up commented code

* Use const for default avatar size

* Address feedback: use Map

* Does not using the deprecated function fix the unit tests in CI?

* Revert "Does not using the deprecated function fix the unit tests in CI?"

This reverts commit c373a53.

* Skip failing test with JSDom issues for now

* Improve fading behavior

* Revert "Improve fading behavior" because it causes other issues

This reverts commit 04c0b6b.

* Add GravatarByUsername loading placeholder; adjust other fades

---------

Co-authored-by: Alban Bailly <abailly@akamai.com>
  • Loading branch information
mjac0bs and abailly-akamai authored Sep 11, 2024
1 parent 8944441 commit 3bc6e76
Show file tree
Hide file tree
Showing 30 changed files with 758 additions and 48 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10859-added-1725550540714.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Gravatar sunset banner for existing Gravatar users ([#10859](https://github.com/linode/manager/pull/10859))
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10859-changed-1725550568902.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Avatars for users without Gravatars ([#10859](https://github.com/linode/manager/pull/10859))
3 changes: 3 additions & 0 deletions packages/manager/src/assets/logo/akamai-wave.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 33 additions & 5 deletions packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';

Expand All @@ -18,10 +18,14 @@ import { useAccountUsers } from 'src/queries/account/users';
import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile';
import { truncateAndJoinList } from 'src/utilities/stringUtils';

import { Avatar } from '../Avatar/Avatar';
import { GravatarByEmail } from '../GravatarByEmail';
import { GravatarOrAvatar } from '../GravatarOrAvatar';
import { PaginationFooter } from '../PaginationFooter/PaginationFooter';
import { TableRowLoading } from '../TableRowLoading/TableRowLoading';

import type { Theme } from '@mui/material/styles';

export const MAX_SSH_KEYS_DISPLAY = 25;

const useStyles = makeStyles()((theme: Theme) => ({
Expand Down Expand Up @@ -57,6 +61,7 @@ interface Props {

const UserSSHKeyPanel = (props: Props) => {
const { classes } = useStyles();
const theme = useTheme();
const { authorizedUsers, disabled, setAuthorizedUsers } = props;

const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState<boolean>(
Expand Down Expand Up @@ -145,9 +150,14 @@ const UserSSHKeyPanel = (props: Props) => {
</TableCell>
<TableCell className={classes.cellUser}>
<div className={classes.userWrapper}>
<GravatarByEmail
className={classes.gravatar}
email={profile.email}
<GravatarOrAvatar
gravatar={
<GravatarByEmail
className={classes.gravatar}
email={profile.email}
/>
}
avatar={<Avatar sx={{ borderRadius: '50%', marginRight: 1 }} />}
/>
{profile.username}
</div>
Expand Down Expand Up @@ -177,7 +187,25 @@ const UserSSHKeyPanel = (props: Props) => {
</TableCell>
<TableCell className={classes.cellUser}>
<div className={classes.userWrapper}>
<GravatarByEmail className={classes.gravatar} email={user.email} />
<GravatarOrAvatar
avatar={
<Avatar
color={
user.username !== profile?.username
? theme.palette.primary.dark
: undefined
}
sx={{ borderRadius: '50%', marginRight: 1 }}
username={user.username}
/>
}
gravatar={
<GravatarByEmail
className={classes.gravatar}
email={user.email}
/>
}
/>
{user.username}
</div>
</TableCell>
Expand Down
27 changes: 27 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';

import { Avatar } from 'src/components/Avatar/Avatar';

import type { Meta, StoryObj } from '@storybook/react';
import type { AvatarProps } from 'src/components/Avatar/Avatar';

export const Default: StoryObj<AvatarProps> = {
render: (args) => <Avatar {...args} />,
};

export const System: StoryObj<AvatarProps> = {
render: (args) => <Avatar {...args} username="Linode" />,
};

const meta: Meta<AvatarProps> = {
args: {
color: '#0174bc',
height: 88,
sx: {},
username: 'MyUsername',
width: 88,
},
component: Avatar,
title: 'Components/Avatar',
};
export default meta;
74 changes: 74 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as React from 'react';

import { profileFactory } from 'src/factories/profile';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { Avatar } from './Avatar';

import type { AvatarProps } from './Avatar';

const mockProps: AvatarProps = {};

const queryMocks = vi.hoisted(() => ({
useProfile: vi.fn().mockReturnValue({}),
}));

vi.mock('src/queries/profile/profile', async () => {
const actual = await vi.importActual('src/queries/profile/profile');
return {
...actual,
useProfile: queryMocks.useProfile,
};
});

describe('Avatar', () => {
it('should render the first letter of a username from /profile with default background color', () => {
queryMocks.useProfile.mockReturnValue({
data: profileFactory.build({ username: 'my-user' }),
});
const { getByTestId } = renderWithTheme(<Avatar {...mockProps} />);
const avatar = getByTestId('avatar');
const avatarStyles = getComputedStyle(avatar);

expect(getByTestId('avatar-letter')).toHaveTextContent('M');
expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc)
});

it('should render a background color from props', () => {
queryMocks.useProfile.mockReturnValue({
data: profileFactory.build({ username: 'my-user' }),
});

const { getByTestId } = renderWithTheme(
<Avatar {...mockProps} color="#000000" />
);
const avatar = getByTestId('avatar');
const avatarText = getByTestId('avatar-letter');
const avatarStyles = getComputedStyle(avatar);
const avatarTextStyles = getComputedStyle(avatarText);

// Confirm background color contrasts with text color.
expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black
expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white
});

it('should render the first letter of username from props', async () => {
const { getByTestId } = renderWithTheme(
<Avatar {...mockProps} username="test" />
);

expect(getByTestId('avatar-letter')).toHaveTextContent('T');
});

it('should render an svg instead of first letter for system users', async () => {
const systemUsernames = ['Linode', 'lke-service-account-123'];

systemUsernames.forEach((username, i) => {
const { getAllByRole, queryByTestId } = renderWithTheme(
<Avatar {...mockProps} username={username} />
);
expect(getAllByRole('img')[i]).toBeVisible();
expect(queryByTestId('avatar-letter')).toBe(null);
});
});
});
96 changes: 96 additions & 0 deletions packages/manager/src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Typography, useTheme } from '@mui/material';
import { default as _Avatar } from '@mui/material/Avatar';
import * as React from 'react';

import AkamaiWave from 'src/assets/logo/akamai-wave.svg';
import { usePreferences } from 'src/queries/profile/preferences';
import { useProfile } from 'src/queries/profile/profile';

import type { SxProps } from '@mui/material';

export const DEFAULT_AVATAR_SIZE = 28;

export interface AvatarProps {
/**
* Optional background color to override the color set in user preferences
* */
color?: string;
/**
* Optional height
* @default 28px
* */
height?: number;
/**
* Optional styles
* */
sx?: SxProps;
/**
* Optional username to override the profile username; will display the first letter
* */
username?: string;
/**
* Optional width
* @default 28px
* */
width?: number;
}

/**
* The Avatar component displays the first letter of a username on a solid background color.
* For system avatars associated with Akamai-generated events, an Akamai logo is displayed in place of a letter.
*/
export const Avatar = (props: AvatarProps) => {
const {
color,
height = DEFAULT_AVATAR_SIZE,
sx,
username,
width = DEFAULT_AVATAR_SIZE,
} = props;

const theme = useTheme();

const { data: preferences } = usePreferences();
const { data: profile } = useProfile();

const _username = username ?? profile?.username ?? '';
const isAkamai =
_username === 'Linode' || _username.startsWith('lke-service-account');

const savedAvatarColor =
isAkamai || !preferences?.avatarColor
? theme.palette.primary.dark
: preferences.avatarColor;
const avatarLetter = _username[0]?.toUpperCase() ?? '';

return (
<_Avatar
sx={{
'& svg': {
height: width / 2,
width: width / 2,
},
bgcolor: color ?? savedAvatarColor,
height,
width,
...sx,
}}
alt={`Avatar for user ${username ?? profile?.email ?? ''}`}
data-testid="avatar"
>
{isAkamai ? (
<AkamaiWave />
) : (
<Typography
sx={{
color: theme.palette.getContrastText(color ?? savedAvatarColor),
fontSize: width / 2,
}}
data-testid="avatar-letter"
>
{avatarLetter}
</Typography>
)}
</_Avatar>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface Props {
width?: number;
}

export const GravatarForProxy = ({ height = 34, width = 34 }: Props) => {
export const AvatarForProxy = ({ height = 34, width = 34 }: Props) => {
return (
<Box
sx={(theme) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

import { ColorPicker } from 'src/components/ColorPicker/ColorPicker';

import type { ColorPickerProps } from './ColorPicker';
import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta<ColorPickerProps> = {
args: {
defaultColor: '#0174bc',
label: 'Label for color picker',
onChange: () => undefined,
},
component: ColorPicker,
title: 'Components/ColorPicker',
};

export const Default: StoryObj<ColorPickerProps> = {
render: (args) => {
return <ColorPicker {...args} />;
},
};

export default meta;
54 changes: 54 additions & 0 deletions packages/manager/src/components/ColorPicker/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useTheme } from '@mui/material';
import React, { useState } from 'react';

import type { CSSProperties } from 'react';

export interface ColorPickerProps {
/**
* Optional color to specify as a default
* */
defaultColor?: string;
/**
* Optional styles for the input element
* */
inputStyles?: CSSProperties;
/**
* Visually hidden label to semantically describe the color picker for accessibility
* */
label: string;
/**
* Function to update the color based on user selection
* */
onChange: (color: string) => void;
}

/**
* The ColorPicker component serves as a wrapper for the native HTML input color picker.
*/
export const ColorPicker = (props: ColorPickerProps) => {
const { defaultColor, inputStyles, label, onChange } = props;

const theme = useTheme();
const [color, setColor] = useState<string>(
defaultColor ?? theme.palette.primary.dark
);

return (
<>
<label className="visually-hidden" htmlFor="color-picker">
{label}
</label>
<input
onChange={(e) => {
setColor(e.target.value);
onChange(e.target.value);
}}
color={color}
id="color-picker"
style={inputStyles}
type="color"
value={color}
/>
</>
);
};
4 changes: 2 additions & 2 deletions packages/manager/src/components/GravatarByEmail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import { getGravatarUrl } from 'src/utilities/gravatar';

export const DEFAULT_AVATAR_SIZE = 28;

interface Props {
export interface GravatarByEmailProps {
className?: string;
email: string;
height?: number;
width?: number;
}

export const GravatarByEmail = (props: Props) => {
export const GravatarByEmail = (props: GravatarByEmailProps) => {
const {
className,
email,
Expand Down
Loading

0 comments on commit 3bc6e76

Please sign in to comment.