diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx new file mode 100644 index 00000000000..5e8ae855dc0 --- /dev/null +++ b/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 ( +
+ +
+ ); +}; +``` + +### 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 ( +
+ + +10 + +
+ ); +}; +``` + + + +## 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; +} +``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.story.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.story.tsx new file mode 100644 index 00000000000..368bffb07f0 --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.story.tsx @@ -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 = { + 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 = (args) => { + const userView = { + user: { + name: 'John Smith', + avatarUrl: 'https://picsum.photos/id/1/200/200', + }, + lastActiveAt: '2023-04-18T15:00:00.000Z', + }; + + return ; +}; +Basic.args = { + showTooltip: true, + onClick: undefined, +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.test.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.test.tsx new file mode 100644 index 00000000000..436925ee16a --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.test.tsx @@ -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(); + expect(screen.getByLabelText('John Smith icon')).toHaveTextContent('JS'); + }); + + it('renders avatar when URL is provided', () => { + render(); + 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(); + await user.click(screen.getByLabelText('John Smith icon')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx new file mode 100644 index 00000000000..13e03a3303a --- /dev/null +++ b/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(); + */ +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) => { + 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 = ( + + ); + + if (showTooltip) { + const tooltip = ( +
+
{user.name}
+
+ {isActive ? ( +
+ Active last 15m + +
+ ) : ( + formatViewed(lastActiveAt) + )} +
+
+ ); + + return {content}; + } 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; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx new file mode 100644 index 00000000000..aa604d919ab --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx @@ -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 ( +
+ +
+ ); +}; +``` + + + +## 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; +} +``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.story.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.story.tsx new file mode 100644 index 00000000000..1f5dbacea6f --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.story.tsx @@ -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 = { + 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 = (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 ({ user, lastActiveAt: new Date().toDateString() }))} />; +}; + +Basic.args = { + limit: 4, +}; + +export const WithManyUsers: ComponentStory = (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 ({ user, lastActiveAt: new Date().toDateString() }))} />; +}; + +WithManyUsers.args = { + limit: 4, +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.test.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.test.tsx new file mode 100644 index 00000000000..5b7112a3232 --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.test.tsx @@ -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(); + 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(); + 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(); + 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(); + const moreUsersIcon = screen.getByRole('button', { name: 'Extra users icon' }); + expect(moreUsersIcon).toHaveTextContent('+1'); + moreUsersIcon.click(); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx new file mode 100644 index 00000000000..a526a2a2b8c --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -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 ( +
+ {limitReached && ( + + {tooManyUsers ? '...' : `+${extraUsers}`} + + )} + {users + .slice(0, limitReached ? limit : limit + 1) + .reverse() + .map((userView) => ( + + ))} +
+ ); +}; + +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; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/UsersIndicator/types.ts b/packages/grafana-ui/src/components/UsersIndicator/types.ts new file mode 100644 index 00000000000..1ba9f2d4e5b --- /dev/null +++ b/packages/grafana-ui/src/components/UsersIndicator/types.ts @@ -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; +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 7c44fc22d49..32f9fdeabf4 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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';