mirror of
https://github.com/grafana/grafana.git
synced 2026-08-04 10:33:21 -05:00
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
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user