Skip to content
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

Grafana/ui: Add UserIcon and UsersIndicator components #66906

Merged
merged 20 commits into from May 25, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx
@@ -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);
});
});
171 changes: 171 additions & 0 deletions packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx
@@ -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();
*/
Comment on lines +33 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this!

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: 50%;
& > * {
border-radius: 50%;
Clarity-89 marked this conversation as resolved.
Show resolved Hide resolved
}
`,
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: 50%;
Clarity-89 marked this conversation as resolved.
Show resolved Hide resolved
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;
}
```