-
Notifications
You must be signed in to change notification settings - Fork 354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: [M3-8158] - Begin to sunset Gravatar #10859
Changes from 40 commits
0115e51
36cc621
24296ca
c2bd8c7
e99f755
c904496
c76b89b
86bf9fc
242f570
0885902
1136b91
2b9df00
9c520d4
5d65177
a708fb6
33a9b94
9e7ed75
357bd7e
b3c0fe7
d460d59
b26814d
f093384
ccea1bd
edb2fb1
65f9271
a1c35ce
bef6088
bb98f35
10f0742
95e2a76
702cab4
a62cc2d
148c471
665915c
0d3f0ef
b0f7a7d
10399db
130aec7
d48352b
f4d32f3
6a3eca7
e8a6afa
c373a53
2e56cb8
b9d597b
04c0b6b
1e589a8
5835140
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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)) |
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)) |
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; |
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); | ||
}); | ||
}); | ||
}); |
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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice π₯ π¨ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yeah, I forgot to update you - I was very excited by this line! ππΌ |
||
fontSize: width / 2, | ||
}} | ||
data-testid="avatar-letter" | ||
> | ||
{avatarLetter} | ||
</Typography> | ||
)} | ||
</_Avatar> | ||
); | ||
}; |
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; |
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Native color picker π₯ |
||
value={color} | ||
/> | ||
</> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avatars from system-generated events have a default color and use the Akamai wave logo. If the user has not set a background color, we default to the same color and their initial. Otherwise, we'll use their saved preference.