diff --git a/packages/manager/.changeset/pr-10859-added-1725550540714.md b/packages/manager/.changeset/pr-10859-added-1725550540714.md new file mode 100644 index 00000000000..c56813e96f2 --- /dev/null +++ b/packages/manager/.changeset/pr-10859-added-1725550540714.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Gravatar sunset banner for existing Gravatar users ([#10859](https://github.com/linode/manager/pull/10859)) diff --git a/packages/manager/.changeset/pr-10859-changed-1725550568902.md b/packages/manager/.changeset/pr-10859-changed-1725550568902.md new file mode 100644 index 00000000000..f7be9157e66 --- /dev/null +++ b/packages/manager/.changeset/pr-10859-changed-1725550568902.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Avatars for users without Gravatars ([#10859](https://github.com/linode/manager/pull/10859)) diff --git a/packages/manager/src/assets/logo/akamai-wave.svg b/packages/manager/src/assets/logo/akamai-wave.svg new file mode 100644 index 00000000000..423f77081c8 --- /dev/null +++ b/packages/manager/src/assets/logo/akamai-wave.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index c3c8d41ebf4..fa9ed19f713 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -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'; @@ -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) => ({ @@ -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( @@ -145,9 +150,14 @@ const UserSSHKeyPanel = (props: Props) => {
- + } + avatar={} /> {profile.username}
@@ -177,7 +187,25 @@ const UserSSHKeyPanel = (props: Props) => {
- + + } + gravatar={ + + } + /> {user.username}
diff --git a/packages/manager/src/components/Avatar/Avatar.stories.tsx b/packages/manager/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 00000000000..9817951902b --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.stories.tsx @@ -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 = { + render: (args) => , +}; + +export const System: StoryObj = { + render: (args) => , +}; + +const meta: Meta = { + args: { + color: '#0174bc', + height: 88, + sx: {}, + username: 'MyUsername', + width: 88, + }, + component: Avatar, + title: 'Components/Avatar', +}; +export default meta; diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 00000000000..e1c553d2c15 --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -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(); + 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( + + ); + 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( + + ); + + 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( + + ); + expect(getAllByRole('img')[i]).toBeVisible(); + expect(queryByTestId('avatar-letter')).toBe(null); + }); + }); +}); diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx new file mode 100644 index 00000000000..968ec8b5834 --- /dev/null +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -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 ? ( + + ) : ( + + {avatarLetter} + + )} + + ); +}; diff --git a/packages/manager/src/components/GravatarForProxy.tsx b/packages/manager/src/components/AvatarForProxy.tsx similarity index 93% rename from packages/manager/src/components/GravatarForProxy.tsx rename to packages/manager/src/components/AvatarForProxy.tsx index 5bbef1d15b1..16840219df4 100644 --- a/packages/manager/src/components/GravatarForProxy.tsx +++ b/packages/manager/src/components/AvatarForProxy.tsx @@ -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 ( ({ diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx new file mode 100644 index 00000000000..aba8758b071 --- /dev/null +++ b/packages/manager/src/components/ColorPicker/ColorPicker.stories.tsx @@ -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 = { + args: { + defaultColor: '#0174bc', + label: 'Label for color picker', + onChange: () => undefined, + }, + component: ColorPicker, + title: 'Components/ColorPicker', +}; + +export const Default: StoryObj = { + render: (args) => { + return ; + }, +}; + +export default meta; diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 00000000000..6b611c91ee7 --- /dev/null +++ b/packages/manager/src/components/ColorPicker/ColorPicker.tsx @@ -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( + defaultColor ?? theme.palette.primary.dark + ); + + return ( + <> + + { + setColor(e.target.value); + onChange(e.target.value); + }} + color={color} + id="color-picker" + style={inputStyles} + type="color" + value={color} + /> + + ); +}; diff --git a/packages/manager/src/components/GravatarByEmail.tsx b/packages/manager/src/components/GravatarByEmail.tsx index ea345f14cc2..4ff64876d93 100644 --- a/packages/manager/src/components/GravatarByEmail.tsx +++ b/packages/manager/src/components/GravatarByEmail.tsx @@ -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, diff --git a/packages/manager/src/components/GravatarByUsername.tsx b/packages/manager/src/components/GravatarByUsername.tsx index 509a78eabc4..3adb2262aa3 100644 --- a/packages/manager/src/components/GravatarByUsername.tsx +++ b/packages/manager/src/components/GravatarByUsername.tsx @@ -5,18 +5,24 @@ import UserIcon from 'src/assets/icons/account.svg'; import { useAccountUser } from 'src/queries/account/users'; import { getGravatarUrl } from 'src/utilities/gravatar'; +import { Box } from './Box'; import { DEFAULT_AVATAR_SIZE } from './GravatarByEmail'; -interface Props { +export interface GravatarByUsernameProps { className?: string; username: null | string; } -export const GravatarByUsername = (props: Props) => { +export const GravatarByUsername = (props: GravatarByUsernameProps) => { const { className, username } = props; - const { data: user } = useAccountUser(username ?? ''); + const { data: user, isLoading } = useAccountUser(username ?? ''); const url = user?.email ? getGravatarUrl(user.email) : undefined; + // Render placeholder instead of flashing default user icon briefly + if (isLoading) { + return ; + } + return ( { + const { + avatar, + gravatar, + height = DEFAULT_AVATAR_SIZE, + width = DEFAULT_AVATAR_SIZE, + } = props; + const { data: profile } = useProfile(); + const { hasGravatar, isLoadingGravatar } = useGravatar(profile?.email); + + return isLoadingGravatar ? ( + + ) : hasGravatar ? ( + gravatar + ) : ( + avatar + ); +}; diff --git a/packages/manager/src/features/Events/EventRow.styles.ts b/packages/manager/src/features/Events/EventRow.styles.ts index 08624554348..cf2743a97ed 100644 --- a/packages/manager/src/features/Events/EventRow.styles.ts +++ b/packages/manager/src/features/Events/EventRow.styles.ts @@ -1,10 +1,14 @@ +// @TODO: delete file once Gravatar is sunset import { styled } from '@mui/material/styles'; +import { fadeIn } from 'src/styles/keyframes'; + import { GravatarByUsername } from '../../components/GravatarByUsername'; export const StyledGravatar = styled(GravatarByUsername, { label: 'StyledGravatar', })(({ theme }) => ({ + animation: `${fadeIn} .2s ease-in-out forwards`, height: theme.spacing(3), width: theme.spacing(3), })); diff --git a/packages/manager/src/features/Events/EventRow.test.tsx b/packages/manager/src/features/Events/EventRow.test.tsx index 825e4d7c09f..311b12b1a74 100644 --- a/packages/manager/src/features/Events/EventRow.test.tsx +++ b/packages/manager/src/features/Events/EventRow.test.tsx @@ -29,6 +29,6 @@ describe('EventRow', () => { name: /Two-factor authentication has been enabled./i, }) ).toBeInTheDocument(); - expect(getByRole('cell', { name: 'test_user' })).toBeInTheDocument(); + expect(getByRole('cell', { name: /test_user/i })).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index 9fab05311cb..40acfd1a063 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -1,11 +1,15 @@ +import { useTheme } from '@mui/material'; import * as React from 'react'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useProfile } from 'src/queries/profile/profile'; import { getEventTimestamp } from 'src/utilities/eventUtils'; import { StyledGravatar } from './EventRow.styles'; @@ -24,12 +28,14 @@ interface EventRowProps { export const EventRow = (props: EventRowProps) => { const { event } = props; + const theme = useTheme(); const timestamp = getEventTimestamp(event); const { action, message, username } = { action: event.action, message: getEventMessage(event), username: getEventUsername(event), }; + const { data: profile } = useProfile(); if (!message) { return null; @@ -54,7 +60,27 @@ export const EventRow = (props: EventRowProps) => { - + + } + gravatar={ + + } + height={24} + width={24} + /> {username} diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 2d600740b1b..8654d0858ad 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -15,6 +15,7 @@ import { APIMaintenanceBanner } from './APIMaintenanceBanner'; import { ComplianceBanner } from './ComplianceBanner'; import { ComplianceUpdateModal } from './ComplianceUpdateModal'; import { EmailBounceNotificationSection } from './EmailBounce'; +import { GravatarSunsetBanner } from './GravatarSunsetBanner'; import { RegionStatusBanner } from './RegionStatusBanner'; import { TaxCollectionBanner } from './TaxCollectionBanner'; import { DesignUpdateBanner } from './TokensUpdateBanner'; @@ -86,6 +87,7 @@ export const GlobalNotifications = () => { Object.keys(flags.taxCollectionBanner).length > 0 ? ( ) : null} + ); }; diff --git a/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx new file mode 100644 index 00000000000..50055637fc3 --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/GravatarSunsetBanner.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { Typography } from 'src/components/Typography'; +import { useGravatar } from 'src/hooks/useGravatar'; + +interface Props { + email: string; +} + +export const GravatarSunsetBanner = (props: Props) => { + const { email } = props; + const GRAVATAR_DEPRECATION_DATE = 'September 28th, 2024'; + + const hasGravatar = useGravatar(email); + + if (!hasGravatar) { + return; + } + return ( + + + {`Support for using Gravatar as your profile photo will be deprecated on ${GRAVATAR_DEPRECATION_DATE}. Your profile photo will automatically be changed to your username initial.`} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx index acd014cd307..1d837243fe9 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx @@ -17,7 +17,8 @@ import { Security } from './Security'; import type { LinodeCreateFormValues } from './utilities'; describe('Security', () => { - it( + // TODO: Unskip once M3-8559 is addressed. + it.skip( 'should render a root password input', async () => { const { findByLabelText } = renderWithThemeAndHookFormContext({ diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx index b5ad7c1cb0e..6a8fef5766e 100644 --- a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx @@ -1,15 +1,19 @@ +import { useTheme } from '@mui/material'; import * as React from 'react'; import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Typography } from 'src/components/Typography'; import { formatProgressEvent, getEventMessage, getEventUsername, } from 'src/features/Events/utils'; +import { useProfile } from 'src/queries/profile/profile'; import { + NotificationEventAvatar, NotificationEventGravatar, NotificationEventStyledBox, notificationEventStyles, @@ -25,11 +29,14 @@ interface NotificationEventProps { export const NotificationCenterEvent = React.memo( (props: NotificationEventProps) => { const { event } = props; + const theme = useTheme(); const { classes, cx } = notificationEventStyles(); const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); const message = getEventMessage(event); const username = getEventUsername(event); + const { data: profile } = useProfile(); + /** * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). * Filter these out so we don't display blank messages to the user. @@ -48,7 +55,21 @@ export const NotificationCenterEvent = React.memo( data-qa-event-seen={event.seen} data-testid={event.action} > - + + } + gravatar={} + height={32} + width={32} + /> {message} {showProgress && ( diff --git a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts index 2761e4d27f5..620151a4769 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts +++ b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts @@ -2,10 +2,12 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import { styled } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { Box } from 'src/components/Box'; import { GravatarByUsername } from 'src/components/GravatarByUsername'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; +import { fadeIn } from 'src/styles/keyframes'; import { omittedProps } from 'src/utilities/omittedProps'; import type { NotificationCenterNotificationMessageProps } from './types'; @@ -120,6 +122,16 @@ export const NotificationEventStyledBox = styled(Box, { export const NotificationEventGravatar = styled(GravatarByUsername, { label: 'StyledGravatarByUsername', +})(() => ({ + animation: `${fadeIn} .2s ease-in-out forwards`, + height: 32, + marginTop: 2, + minWidth: 32, + width: 32, +})); + +export const NotificationEventAvatar = styled(Avatar, { + label: 'StyledAvatar', })(() => ({ height: 32, marginTop: 2, diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx new file mode 100644 index 00000000000..19b8557ac5d --- /dev/null +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AvatarColorPickerDialog } from './AvatarColorPickerDialog'; + +import type { AvatarColorPickerDialogProps } from './AvatarColorPickerDialog'; + +const mockProps: AvatarColorPickerDialogProps = { + handleClose: vi.fn(), + open: true, +}; + +describe('AvatarColorPicker', () => { + it('should render a dialog with a title, color picker, and avatar components', () => { + const { getByLabelText, getByTestId, getByTitle } = renderWithTheme( + + ); + + expect(getByTitle('Change Avatar Color')).toBeVisible(); + expect(getByLabelText('Avatar color picker')).toBeVisible(); + expect(getByTestId('avatar')).toBeVisible(); + }); + + it('calls onClose when Close button is clicked', async () => { + const { getByText } = renderWithTheme( + + ); + + await fireEvent.click(getByText('Close')); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); + + it('closes when Save button is clicked', async () => { + const { getByText } = renderWithTheme( + + ); + + await fireEvent.click(getByText('Save')); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx new file mode 100644 index 00000000000..dd0cca24ccf --- /dev/null +++ b/packages/manager/src/features/Profile/DisplaySettings/AvatarColorPickerDialog.tsx @@ -0,0 +1,75 @@ +import { Typography } from '@mui/material'; +import React from 'react'; +import { useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Avatar } from 'src/components/Avatar/Avatar'; +import { ColorPicker } from 'src/components/ColorPicker/ColorPicker'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { Stack } from 'src/components/Stack'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; + +export interface AvatarColorPickerDialogProps { + handleClose: () => void; + open: boolean; +} + +export const AvatarColorPickerDialog = ( + props: AvatarColorPickerDialogProps +) => { + const { handleClose, open } = props; + + const [avatarColor, setAvatarColor] = useState(); + + const { data: preferences } = usePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); + + return ( + + + + Select a background color for your avatar: + + setAvatarColor(color)} + /> + + + + + + { + if (avatarColor) { + updatePreferences({ + avatarColor, + }).catch(() => {}); + } + handleClose(); + }, + }} + secondaryButtonProps={{ + 'data-testid': 'close-button', + label: 'Close', + onClick: handleClose, + }} + sx={{ + display: 'flex', + }} + /> + + ); +}; diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 1a1ba9ae96b..6c003f183c2 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -5,21 +5,26 @@ import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { v4 } from 'uuid'; +import { Avatar } from 'src/components/Avatar/Avatar'; import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import { GravatarByEmail } from 'src/components/GravatarByEmail'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { SingleTextFieldForm } from 'src/components/SingleTextFieldForm/SingleTextFieldForm'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import { useGravatar } from 'src/hooks/useGravatar'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; +import { AvatarColorPickerDialog } from './AvatarColorPickerDialog'; import { TimezoneForm } from './TimezoneForm'; import type { ApplicationState } from 'src/store'; +import { GravatarByEmail } from 'src/components/GravatarByEmail'; export const DisplaySettings = () => { const theme = useTheme(); @@ -34,6 +39,13 @@ export const DisplaySettings = () => { const isProxyUser = profile?.user_type === 'proxy'; + const { hasGravatar } = useGravatar(profile?.email); + + const [ + isColorPickerDialogOpen, + setAvatarColorPickerDialogOpen, + ] = React.useState(false); + React.useEffect(() => { if (location.state?.focusEmail && emailRef.current) { emailRef.current.focus(); @@ -89,31 +101,50 @@ export const DisplaySettings = () => { }} display="flex" > - + } + avatar={} height={88} width={88} />
- Profile photo - + {hasGravatar ? 'Profile photo' : 'Avatar'} + {hasGravatar && ( + + )} - Create, upload, and manage your globally recognized avatar from - a single place with Gravatar. + {hasGravatar + ? 'Create, upload, and manage your globally recognized avatar from a single place with Gravatar.' + : 'Your avatar is automatically generated using the first character of your username.'} - - Manage photo - + {hasGravatar ? ( + + Manage photo + + ) : ( + + )}
@@ -155,6 +186,10 @@ export const DisplaySettings = () => { /> + setAvatarColorPickerDialogOpen(false)} + open={isColorPickerDialogOpen} + /> ); }; diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx index 5cbdf6960ff..80d5ab5cd5a 100644 --- a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx +++ b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx @@ -1,18 +1,23 @@ -import { SupportReply, SupportTicket } from '@linode/api-v4'; import Avatar from '@mui/material/Avatar'; -import { Theme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import UserIcon from 'src/assets/icons/account.svg'; +import { Avatar as NewAvatar } from 'src/components/Avatar/Avatar'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Typography } from 'src/components/Typography'; +import { useProfile } from 'src/queries/profile/profile'; import { Hively, shouldRenderHively } from './Hively'; import { TicketDetailText } from './TicketDetailText'; import { OFFICIAL_USERNAMES } from './ticketUtils'; +import type { SupportReply, SupportTicket } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ '@keyframes fadeIn': { from: { @@ -99,10 +104,14 @@ interface Data { export const ExpandableTicketPanel = React.memo((props: Props) => { const { classes } = useStyles(); + const theme = useTheme(); + const { open, parentTicket, reply, ticket, ticketUpdated } = props; const [data, setData] = React.useState(undefined); + const { data: profile } = useProfile(); + React.useEffect(() => { if (!ticket && !reply) { return; @@ -137,13 +146,28 @@ export const ExpandableTicketPanel = React.memo((props: Props) => { const renderAvatar = (id: string) => { return (
- - - + + } + gravatar={ + + + + } + />
); }; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index f6d9ee4c6b2..c1470ebbaaf 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -1,17 +1,18 @@ -import { GlobalGrantTypes } from '@linode/api-v4/lib/account'; import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; -import { Theme, styled, useMediaQuery } from '@mui/material'; +import { styled, useMediaQuery } from '@mui/material'; import Popover from '@mui/material/Popover'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { Avatar } from 'src/components/Avatar/Avatar'; +import { AvatarForProxy } from 'src/components/AvatarForProxy'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; import { GravatarByEmail } from 'src/components/GravatarByEmail'; -import { GravatarForProxy } from 'src/components/GravatarForProxy'; +import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar'; import { Hidden } from 'src/components/Hidden'; import { Link } from 'src/components/Link'; import { Stack } from 'src/components/Stack'; @@ -29,6 +30,9 @@ import { getStorage, setStorage } from 'src/utilities/storage'; import { getCompanyNameOrEmail } from './utils'; +import type { GlobalGrantTypes } from '@linode/api-v4/lib/account'; +import type { Theme } from '@mui/material'; + interface MenuLink { display: string; hide?: boolean; @@ -207,9 +211,12 @@ export const UserMenu = React.memo(() => {