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 review
pull/69076/head
Alex Khomenko 2 years ago committed by GitHub
parent 56f33e8b9c
commit 41b609de14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 69
      packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx
  2. 47
      packages/grafana-ui/src/components/UsersIndicator/UserIcon.story.tsx
  3. 40
      packages/grafana-ui/src/components/UsersIndicator/UserIcon.test.tsx
  4. 171
      packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx
  5. 63
      packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx
  6. 84
      packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.story.tsx
  7. 49
      packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.test.tsx
  8. 64
      packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx
  9. 12
      packages/grafana-ui/src/components/UsersIndicator/types.ts
  10. 3
      packages/grafana-ui/src/components/index.ts

@ -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;
}

@ -249,7 +249,8 @@ export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDis
export { ButtonSelect } from './Dropdown/ButtonSelect';
export { Dropdown } from './Dropdown/Dropdown';
export { PluginSignatureBadge, type PluginSignatureBadgeProps } from './PluginSignatureBadge/PluginSignatureBadge';
export { UserIcon, type UserIconProps } from './UsersIndicator/UserIcon';
export { type UserView } from './UsersIndicator/types';
// Export this until we've figured out a good approach to inline form styles.
export { InlineFormLabel } from './FormLabel/FormLabel';

Loading…
Cancel
Save