mirror of https://github.com/grafana/grafana
Grafana/ui: Add UserIcon and UsersIndicator components (#66906)
* UserIcon: Move to grafana/ui * UserIcon: Add story and docs * UserIcon: Add multiple use case * UserIcon: Use theme2 * UserIcon: Update props * UserIcon: Export components * UserIcon: Update story * UserIcon: Allow children * UserIcon: Simplify the rendering logic * UserIcon: Use button * UserIcon: Add UsersIndicator component * UserIcon: Add tests * UserIcon: Rename folder * UserIcon: More examples * UserIcon: Display UserView type * UserIcon: Update tests * UserIcon: Expand example * UserIcon: Export UserView type * Fixes after reviewpull/69076/head
parent
56f33e8b9c
commit
41b609de14
@ -0,0 +1,69 @@ |
||||
import { Props } from '@storybook/addon-docs/blocks'; |
||||
import { UserIcon } from './UserIcon'; |
||||
|
||||
# UserIcon |
||||
|
||||
`UserIcon` a component that takes in the `UserIconProps` interface as a prop. It renders a user icon and displays the user's name or initials along with the user's active status or last viewed date. |
||||
|
||||
## Usage |
||||
|
||||
To use the `UserIcon` component, import it and pass in the required `UserIconProps`. The component can be used as follows: |
||||
|
||||
```jsx |
||||
import { UserIcon } from '@grafana/ui'; |
||||
|
||||
const ExampleComponent = () => { |
||||
const userView = { |
||||
user: { id: 1, name: 'John Smith', avatarUrl: 'https://example.com/avatar.png' }, |
||||
lastActiveAt: '2023-04-18T15:00:00.000Z', |
||||
}; |
||||
|
||||
return ( |
||||
<div> |
||||
<UserIcon userView={userView} showTooltip={true} className={styles.custom} /> |
||||
</div> |
||||
); |
||||
}; |
||||
``` |
||||
|
||||
### With custom `children` |
||||
|
||||
`children` prop can be used to display a custom content inside `UserIcon`. This is useful to show the data about extra users. |
||||
|
||||
```jsx |
||||
import { UserIcon } from '@grafana/ui'; |
||||
|
||||
const ExampleComponent = () => { |
||||
const userView = { |
||||
user: { id: 1, name: 'John Smith', avatarUrl: 'https://example.com/avatar.png' }, |
||||
lastActiveAt: '2023-04-18T15:00:00.000Z', |
||||
}; |
||||
|
||||
return ( |
||||
<div> |
||||
<UserIcon userView={userView} showTooltip={false}> |
||||
+10 |
||||
</UserIcon> |
||||
</div> |
||||
); |
||||
}; |
||||
``` |
||||
|
||||
<Props of={UserIcon} /> |
||||
|
||||
## UserView type |
||||
|
||||
```tsx |
||||
import { DateTimeInput } from '@grafana/data'; |
||||
|
||||
export interface UserView { |
||||
user: { |
||||
/** User's name, containing first + last name */ |
||||
name: string; |
||||
/** URL to the user's avatar */ |
||||
avatarUrl?: string; |
||||
}; |
||||
/** Datetime string when the user was last active */ |
||||
lastActiveAt: DateTimeInput; |
||||
} |
||||
``` |
@ -0,0 +1,47 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React from 'react'; |
||||
|
||||
import { UserIcon } from './UserIcon'; |
||||
import mdx from './UserIcon.mdx'; |
||||
|
||||
const meta: ComponentMeta<typeof UserIcon> = { |
||||
title: 'General/UsersIndicator/UserIcon', |
||||
component: UserIcon, |
||||
argTypes: {}, |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
knobs: { |
||||
disabled: true, |
||||
}, |
||||
controls: { |
||||
exclude: ['className', 'onClick'], |
||||
}, |
||||
actions: { |
||||
disabled: true, |
||||
}, |
||||
}, |
||||
args: { |
||||
showTooltip: false, |
||||
onClick: undefined, |
||||
}, |
||||
}; |
||||
|
||||
export const Basic: ComponentStory<typeof UserIcon> = (args) => { |
||||
const userView = { |
||||
user: { |
||||
name: 'John Smith', |
||||
avatarUrl: 'https://picsum.photos/id/1/200/200', |
||||
}, |
||||
lastActiveAt: '2023-04-18T15:00:00.000Z', |
||||
}; |
||||
|
||||
return <UserIcon {...args} userView={userView} />; |
||||
}; |
||||
Basic.args = { |
||||
showTooltip: true, |
||||
onClick: undefined, |
||||
}; |
||||
|
||||
export default meta; |
@ -0,0 +1,40 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
|
||||
import { UserIcon } from './UserIcon'; |
||||
|
||||
// setup userEvent
|
||||
function setup(jsx: React.ReactElement) { |
||||
return { |
||||
user: userEvent.setup(), |
||||
...render(jsx), |
||||
}; |
||||
} |
||||
|
||||
const testUserView = { |
||||
user: { |
||||
name: 'John Smith', |
||||
avatarUrl: 'https://example.com/avatar.png', |
||||
}, |
||||
lastActiveAt: new Date().toISOString(), |
||||
}; |
||||
|
||||
describe('UserIcon', () => { |
||||
it('renders user initials when no avatar URL is provided', () => { |
||||
render(<UserIcon userView={{ ...testUserView, user: { name: 'John Smith' } }} />); |
||||
expect(screen.getByLabelText('John Smith icon')).toHaveTextContent('JS'); |
||||
}); |
||||
|
||||
it('renders avatar when URL is provided', () => { |
||||
render(<UserIcon userView={testUserView} />); |
||||
expect(screen.getByAltText('John Smith avatar')).toHaveAttribute('src', 'https://example.com/avatar.png'); |
||||
}); |
||||
|
||||
it('calls onClick handler when clicked', async () => { |
||||
const handleClick = jest.fn(); |
||||
const { user } = setup(<UserIcon userView={testUserView} onClick={handleClick} />); |
||||
await user.click(screen.getByLabelText('John Smith icon')); |
||||
expect(handleClick).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
@ -0,0 +1,171 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React, { useMemo, PropsWithChildren } from 'react'; |
||||
|
||||
import { dateTime, DateTimeInput, GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { useTheme2 } from '../../themes'; |
||||
import { Tooltip } from '../Tooltip'; |
||||
|
||||
import { UserView } from './types'; |
||||
|
||||
export interface UserIconProps { |
||||
/** An object that contains the user's details and 'lastActiveAt' status */ |
||||
userView: UserView; |
||||
/** A boolean value that determines whether the tooltip should be shown or not */ |
||||
showTooltip?: boolean; |
||||
/** An optional class name to be added to the icon element */ |
||||
className?: string; |
||||
/** onClick handler to be called when the icon is clicked */ |
||||
onClick?: () => void; |
||||
} |
||||
|
||||
/** |
||||
* A helper function that takes in a dateString parameter |
||||
* and returns the user's last viewed date in a specific format. |
||||
*/ |
||||
const formatViewed = (dateString: DateTimeInput): string => { |
||||
const date = dateTime(dateString); |
||||
const diffHours = date.diff(dateTime(), 'hours', false); |
||||
return `Active last ${(Math.floor(-diffHours / 24) + 1) * 24}h`; |
||||
}; |
||||
|
||||
/** |
||||
* Output the initials of the first and last name (if given), capitalized and concatenated together. |
||||
* If name is not provided, an empty string is returned. |
||||
* @param {string} [name] The name to extract initials from. |
||||
* @returns {string} The uppercase initials of the first and last name. |
||||
* @example |
||||
* // Returns 'JD'
|
||||
* getUserInitials('John Doe'); |
||||
* // Returns 'A'
|
||||
* getUserInitials('Alice'); |
||||
* // Returns ''
|
||||
* getUserInitials(); |
||||
*/ |
||||
const getUserInitials = (name?: string) => { |
||||
if (!name) { |
||||
return ''; |
||||
} |
||||
const [first, last] = name.split(' '); |
||||
return `${first?.[0] ?? ''}${last?.[0] ?? ''}`.toUpperCase(); |
||||
}; |
||||
|
||||
export const UserIcon = ({ |
||||
userView, |
||||
className, |
||||
children, |
||||
onClick, |
||||
showTooltip = true, |
||||
}: PropsWithChildren<UserIconProps>) => { |
||||
const { user, lastActiveAt } = userView; |
||||
const isActive = dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15; |
||||
const theme = useTheme2(); |
||||
const styles = useMemo(() => getStyles(theme, isActive), [theme, isActive]); |
||||
const content = ( |
||||
<button |
||||
type={'button'} |
||||
onClick={onClick} |
||||
className={cx(styles.container, onClick && styles.pointer, className)} |
||||
aria-label={`${user.name} icon`} |
||||
> |
||||
{children ? ( |
||||
<div className={cx(styles.content, styles.textContent)}>{children}</div> |
||||
) : user.avatarUrl ? ( |
||||
<img className={styles.content} src={user.avatarUrl} alt={`${user.name} avatar`} /> |
||||
) : ( |
||||
<div className={cx(styles.content, styles.textContent)}>{getUserInitials(user.name)}</div> |
||||
)} |
||||
</button> |
||||
); |
||||
|
||||
if (showTooltip) { |
||||
const tooltip = ( |
||||
<div className={styles.tooltipContainer}> |
||||
<div className={styles.tooltipName}>{user.name}</div> |
||||
<div className={styles.tooltipDate}> |
||||
{isActive ? ( |
||||
<div className={styles.dotContainer}> |
||||
<span>Active last 15m</span> |
||||
<span className={styles.dot}></span> |
||||
</div> |
||||
) : ( |
||||
formatViewed(lastActiveAt) |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
|
||||
return <Tooltip content={tooltip}>{content}</Tooltip>; |
||||
} else { |
||||
return content; |
||||
} |
||||
}; |
||||
|
||||
const getIconBorder = (color: string): string => { |
||||
return `0 0 0 1px ${color}`; |
||||
}; |
||||
|
||||
export const getStyles = (theme: GrafanaTheme2, isActive: boolean) => { |
||||
const shadowColor = isActive ? theme.colors.primary.main : theme.colors.border.medium; |
||||
const shadowHoverColor = isActive ? theme.colors.primary.text : theme.colors.border.strong; |
||||
|
||||
return { |
||||
container: css` |
||||
padding: 0; |
||||
width: 30px; |
||||
height: 30px; |
||||
background: none; |
||||
border: none; |
||||
border-radius: ${theme.shape.radius.circle}; |
||||
& > * { |
||||
border-radius: ${theme.shape.radius.circle}; |
||||
} |
||||
`,
|
||||
content: css` |
||||
line-height: 24px; |
||||
max-width: 100%; |
||||
border: 3px ${theme.colors.background.primary} solid; |
||||
box-shadow: ${getIconBorder(shadowColor)}; |
||||
background-clip: padding-box; |
||||
&:hover { |
||||
box-shadow: ${getIconBorder(shadowHoverColor)}; |
||||
} |
||||
`,
|
||||
textContent: css` |
||||
background: ${theme.colors.background.primary}; |
||||
padding: 0; |
||||
color: ${theme.colors.text.secondary}; |
||||
text-align: center; |
||||
font-size: ${theme.typography.size.sm}; |
||||
background: ${theme.colors.background.primary}; |
||||
&:focus { |
||||
box-shadow: ${getIconBorder(shadowColor)}; |
||||
} |
||||
`,
|
||||
tooltipContainer: css` |
||||
text-align: center; |
||||
padding: ${theme.spacing(0, 1)}; |
||||
`,
|
||||
tooltipName: css` |
||||
font-weight: ${theme.typography.fontWeightBold}; |
||||
`,
|
||||
tooltipDate: css` |
||||
font-weight: ${theme.typography.fontWeightRegular}; |
||||
`,
|
||||
dotContainer: css` |
||||
display: flex; |
||||
align-items: center; |
||||
`,
|
||||
dot: css` |
||||
height: 6px; |
||||
width: 6px; |
||||
background-color: ${theme.colors.primary.main}; |
||||
border-radius: ${theme.shape.radius.circle}; |
||||
display: inline-block; |
||||
margin-left: ${theme.spacing(1)}; |
||||
`,
|
||||
pointer: css` |
||||
cursor: pointer; |
||||
`,
|
||||
}; |
||||
}; |
@ -0,0 +1,63 @@ |
||||
import { Props } from '@storybook/addon-docs/blocks'; |
||||
import { UsersIndicator } from './UsersIndicator'; |
||||
|
||||
# UsersIndicator |
||||
|
||||
A component that displays a set of user icons indicating which users are currently active. If there are too many users to display all the icons, it will collapse the icons into a single icon with a number indicating the number of additional users. |
||||
|
||||
## Usage |
||||
|
||||
```tsx |
||||
import { UsersIndicator } from '@grafana/ui'; |
||||
|
||||
const users = [ |
||||
{ |
||||
user: { |
||||
name: 'John Smith', |
||||
avatarUrl: 'https://example.com/avatar.png', |
||||
}, |
||||
lastActiveAt: '2023-04-18T15:00:00.000Z', |
||||
}, |
||||
{ |
||||
user: { |
||||
name: 'Jane Doe', |
||||
avatarUrl: 'https://example.com/avatar.png', |
||||
}, |
||||
lastActiveAt: '2023-04-17T10:00:00.000Z', |
||||
}, |
||||
{ |
||||
user: { |
||||
name: 'Bob Johnson', |
||||
avatarUrl: 'https://example.com/avatar.png', |
||||
}, |
||||
lastActiveAt: '2023-04-16T08:00:00.000Z', |
||||
}, |
||||
]; |
||||
|
||||
const ExampleComponent = () => { |
||||
return ( |
||||
<div> |
||||
<UsersIndicator users={users} limit={2} /> |
||||
</div> |
||||
); |
||||
}; |
||||
``` |
||||
|
||||
<Props of={UsersIndicator} /> |
||||
|
||||
## UserView type |
||||
|
||||
```tsx |
||||
import { DateTimeInput } from '@grafana/data'; |
||||
|
||||
export interface UserView { |
||||
user: { |
||||
/** User's name, containing first + last name */ |
||||
name: string; |
||||
/** URL to the user's avatar */ |
||||
avatarUrl?: string; |
||||
}; |
||||
/** Datetime string when the user was last active */ |
||||
lastActiveAt: DateTimeInput; |
||||
} |
||||
``` |
@ -0,0 +1,84 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React from 'react'; |
||||
|
||||
import { UsersIndicator } from './UsersIndicator'; |
||||
import mdx from './UsersIndicator.mdx'; |
||||
|
||||
const meta: ComponentMeta<typeof UsersIndicator> = { |
||||
title: 'General/UsersIndicator', |
||||
component: UsersIndicator, |
||||
argTypes: { limit: { control: { type: 'number', min: 1 } } }, |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
knobs: { |
||||
disabled: true, |
||||
}, |
||||
controls: { |
||||
exclude: ['className', 'onClick'], |
||||
}, |
||||
actions: { |
||||
disabled: true, |
||||
}, |
||||
}, |
||||
args: { |
||||
onClick: undefined, |
||||
}, |
||||
}; |
||||
|
||||
export const Basic: ComponentStory<typeof UsersIndicator> = (args) => { |
||||
const users = [ |
||||
{ |
||||
name: 'John Doe', |
||||
avatarUrl: 'https://picsum.photos/id/1/200/200', |
||||
}, |
||||
{ |
||||
name: 'Jane Smith', |
||||
avatarUrl: '', |
||||
}, |
||||
{ |
||||
name: 'Bob Johnson', |
||||
avatarUrl: 'https://picsum.photos/id/3/200/200', |
||||
}, |
||||
]; |
||||
|
||||
return <UsersIndicator {...args} users={users.map((user) => ({ user, lastActiveAt: new Date().toDateString() }))} />; |
||||
}; |
||||
|
||||
Basic.args = { |
||||
limit: 4, |
||||
}; |
||||
|
||||
export const WithManyUsers: ComponentStory<typeof UsersIndicator> = (args) => { |
||||
const users = [ |
||||
{ |
||||
name: 'John Doe', |
||||
avatarUrl: 'https://picsum.photos/id/1/200/200', |
||||
}, |
||||
{ |
||||
name: 'Jane Smith', |
||||
avatarUrl: '', |
||||
}, |
||||
{ |
||||
name: 'Bob Johnson', |
||||
avatarUrl: 'https://picsum.photos/id/3/200/200', |
||||
}, |
||||
{ |
||||
name: 'John Smith', |
||||
avatarUrl: 'https://picsum.photos/id/1/200/200', |
||||
}, |
||||
{ |
||||
name: 'Jane Johnson', |
||||
avatarUrl: '', |
||||
}, |
||||
]; |
||||
|
||||
return <UsersIndicator {...args} users={users.map((user) => ({ user, lastActiveAt: new Date().toDateString() }))} />; |
||||
}; |
||||
|
||||
WithManyUsers.args = { |
||||
limit: 4, |
||||
}; |
||||
|
||||
export default meta; |
@ -0,0 +1,49 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { UsersIndicator } from './UsersIndicator'; |
||||
|
||||
describe('UsersIndicator', () => { |
||||
const users = [ |
||||
{ user: { name: 'John Doe' }, lastActiveAt: '2022-04-19T10:30:00.000Z' }, |
||||
{ user: { name: 'Jane Johnson' }, lastActiveAt: '2022-04-19T11:00:00.000Z' }, |
||||
{ user: { name: 'Bob Doe' }, lastActiveAt: '2022-04-19T12:00:00.000Z' }, |
||||
]; |
||||
|
||||
it('renders the user icons correctly', () => { |
||||
render(<UsersIndicator users={users.slice(0, 2)} limit={2} />); |
||||
const johnUserIcon = screen.getByRole('button', { name: 'John Doe icon' }); |
||||
const janeUserIcon = screen.getByRole('button', { name: 'Jane Johnson icon' }); |
||||
expect(johnUserIcon).toBeInTheDocument(); |
||||
expect(janeUserIcon).toBeInTheDocument(); |
||||
expect(screen.queryByRole('button', { name: 'Extra users icon' })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('collapses the user icons when the limit is reached', () => { |
||||
render(<UsersIndicator users={users} limit={2} />); |
||||
const johnUserIcon = screen.getByRole('button', { name: 'John Doe icon' }); |
||||
const janeUserIcon = screen.getByRole('button', { name: 'Jane Johnson icon' }); |
||||
const moreUsersIcon = screen.getByRole('button', { name: 'Extra users icon' }); |
||||
expect(johnUserIcon).toBeInTheDocument(); |
||||
expect(janeUserIcon).toBeInTheDocument(); |
||||
expect(moreUsersIcon).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it("shows the '+' when there are too many users to display", () => { |
||||
render(<UsersIndicator users={users} limit={1} />); |
||||
const johnUserIcon = screen.getByRole('button', { name: 'John Doe icon' }); |
||||
const moreUsersIcon = screen.getByRole('button', { name: 'Extra users icon' }); |
||||
expect(moreUsersIcon).toHaveTextContent('+2'); |
||||
expect(johnUserIcon).toBeInTheDocument(); |
||||
expect(moreUsersIcon).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('calls the onClick function when the user number indicator is clicked', () => { |
||||
const handleClick = jest.fn(); |
||||
render(<UsersIndicator users={users} onClick={handleClick} limit={2} />); |
||||
const moreUsersIcon = screen.getByRole('button', { name: 'Extra users icon' }); |
||||
expect(moreUsersIcon).toHaveTextContent('+1'); |
||||
moreUsersIcon.click(); |
||||
expect(handleClick).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
@ -0,0 +1,64 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { useStyles2 } from '../../themes'; |
||||
|
||||
import { UserIcon } from './UserIcon'; |
||||
import { UserView } from './types'; |
||||
|
||||
export interface UsersIndicatorProps { |
||||
/** An object that contains the user's details and 'lastActiveAt' status */ |
||||
users: UserView[]; |
||||
/** A limit of how many user icons to show before collapsing them and showing a number of users instead */ |
||||
limit?: number; |
||||
/** onClick handler for the user number indicator */ |
||||
onClick?: () => void; |
||||
} |
||||
export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => { |
||||
const styles = useStyles2(getStyles); |
||||
if (!users.length) { |
||||
return null; |
||||
} |
||||
// Make sure limit is never negative
|
||||
limit = limit > 0 ? limit : 4; |
||||
const limitReached = users.length > limit; |
||||
const extraUsers = users.length - limit; |
||||
// Prevent breaking the layout when there's more than 99 users
|
||||
const tooManyUsers = extraUsers > 99; |
||||
|
||||
return ( |
||||
<div className={styles.container} aria-label="Users indicator container"> |
||||
{limitReached && ( |
||||
<UserIcon onClick={onClick} userView={{ user: { name: 'Extra users' }, lastActiveAt: '' }} showTooltip={false}> |
||||
{tooManyUsers ? '...' : `+${extraUsers}`} |
||||
</UserIcon> |
||||
)} |
||||
{users |
||||
.slice(0, limitReached ? limit : limit + 1) |
||||
.reverse() |
||||
.map((userView) => ( |
||||
<UserIcon key={userView.user.name} userView={userView} /> |
||||
))} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
container: css` |
||||
display: flex; |
||||
justify-content: center; |
||||
flex-direction: row-reverse; |
||||
margin-left: ${theme.spacing(1)}; |
||||
|
||||
& > button { |
||||
margin-left: -${theme.spacing(1)}; // Overlay the elements a bit on top of each other
|
||||
} |
||||
`,
|
||||
dots: css` |
||||
margin-bottom: 3px; |
||||
`,
|
||||
}; |
||||
}; |
@ -0,0 +1,12 @@ |
||||
import { DateTimeInput } from '@grafana/data'; |
||||
|
||||
export interface UserView { |
||||
user: { |
||||
/** User's name, containing first + last name */ |
||||
name: string; |
||||
/** URL to the user's avatar */ |
||||
avatarUrl?: string; |
||||
}; |
||||
/** Datetime string when the user was last active */ |
||||
lastActiveAt: DateTimeInput; |
||||
} |
Loading…
Reference in new issue