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
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
cb8ab00
UserIcon: Move to grafana/ui
Clarity-89 970ffe0
UserIcon: Add story and docs
Clarity-89 f7e57d5
UserIcon: Add multiple use case
Clarity-89 d68e9b9
UserIcon: Use theme2
Clarity-89 2e5dbc9
UserIcon: Update props
Clarity-89 e68f2db
UserIcon: Export components
Clarity-89 fcb8dec
UserIcon: Update story
Clarity-89 7c59c55
UserIcon: Allow children
Clarity-89 4faa9da
UserIcon: Simplify the rendering logic
Clarity-89 be08372
UserIcon: Use button
Clarity-89 01e63d2
UserIcon: Add UsersIndicator component
Clarity-89 6e0d2f6
UserIcon: Add tests
Clarity-89 ec1ba29
UserIcon: Rename folder
Clarity-89 8af1328
UserIcon: More examples
Clarity-89 5b9ba38
UserIcon: Display UserView type
Clarity-89 9c5f8a9
UserIcon: Update tests
Clarity-89 c96c136
UserIcon: Expand example
Clarity-89 d77644b
UserIcon: Export UserView type
Clarity-89 1fb15c5
Merge branch 'main' into grafana-ui/user-icon
Clarity-89 0f6d8b6
Fixes after review
Clarity-89 File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
69 changes: 69 additions & 0 deletions
69
packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
``` |
47 changes: 47 additions & 0 deletions
47
packages/grafana-ui/src/components/UsersIndicator/UserIcon.story.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
40 changes: 40 additions & 0 deletions
40
packages/grafana-ui/src/components/UsersIndicator/UserIcon.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
171
packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
`, | ||
}; | ||
}; |
63 changes: 63 additions & 0 deletions
63
packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
``` |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love this!