mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 05:17:28 -08:00
feat(editor): Implement the UserStack
design system component (#7559)
Adds [`UserStack` component](https://www.figma.com/file/5MfPO6Eg2YVfXETAzoGahf/Debt-days-August2023?type=design&node-id=10-13056&mode=design&t=tuJAgCdzkN506SlE-0) to our design system
This commit is contained in:
parent
b94b8b2eb0
commit
ce14f6266b
|
@ -0,0 +1,191 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { StoryFn } from '@storybook/vue3';
|
||||
import UserStack from './UserStack.vue';
|
||||
|
||||
export default {
|
||||
title: 'Modules/UserStack',
|
||||
component: UserStack,
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args) => ({
|
||||
setup: () => ({ args }),
|
||||
props: args,
|
||||
components: {
|
||||
UserStack,
|
||||
},
|
||||
template: '<n8n-user-stack v-bind="args" />',
|
||||
});
|
||||
|
||||
export const WithGroups = Template.bind({});
|
||||
WithGroups.args = {
|
||||
currentUserEmail: 'sunny@n8n.io',
|
||||
users: {
|
||||
Owners: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
fullName: 'Sunny Side',
|
||||
email: 'sunny@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
fullName: 'Kobi Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
'Other users': [
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
fullName: 'John Doe',
|
||||
email: 'john@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
fullName: 'Jane Doe',
|
||||
email: 'jane@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
fullName: 'Test User',
|
||||
email: 'test@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: true,
|
||||
isOwner: false,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
'Empty Group': [],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleGroup = Template.bind({});
|
||||
SingleGroup.args = {
|
||||
currentUserEmail: 'sunny@n8n.io',
|
||||
users: {
|
||||
Owners: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
fullName: 'Sunny Side',
|
||||
email: 'sunny@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
fullName: 'Kobi Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
fullName: 'Jane Doe',
|
||||
email: 'jane@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
fullName: 'Test User',
|
||||
email: 'test@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: true,
|
||||
isOwner: false,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoCutoff = Template.bind({});
|
||||
NoCutoff.args = {
|
||||
currentUserEmail: 'sunny@n8n.io',
|
||||
users: {
|
||||
Owners: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
fullName: 'Sunny Side',
|
||||
email: 'sunny@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
fullName: 'Kobi Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
fullName: 'John Doe',
|
||||
email: 'john@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
181
packages/design-system/src/components/N8nUserStack/UserStack.vue
Normal file
181
packages/design-system/src/components/N8nUserStack/UserStack.vue
Normal file
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts" setup>
|
||||
import type { IUser, UserStackGroups } from '@/types';
|
||||
import N8nAvatar from '../N8nAvatar';
|
||||
import N8nUserInfo from '../N8nUserInfo';
|
||||
import type { PropType } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Object as PropType<UserStackGroups>,
|
||||
required: true,
|
||||
},
|
||||
currentUserEmail: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
maxAvatars: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
validator: (value: number) => value > 0,
|
||||
},
|
||||
dropdownTrigger: {
|
||||
type: String,
|
||||
default: 'hover',
|
||||
validator: (value: string) => ['hover', 'click'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const nonEmptyGroups = computed(() => {
|
||||
const users: UserStackGroups = {};
|
||||
|
||||
for (const groupName in props.users) {
|
||||
if (props.users[groupName].length > 0) {
|
||||
users[groupName] = props.users[groupName];
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
});
|
||||
|
||||
const groupCount = computed(() => {
|
||||
return Object.keys(nonEmptyGroups.value).length;
|
||||
});
|
||||
|
||||
const flatUserList = computed(() => {
|
||||
const users: IUser[] = [];
|
||||
|
||||
for (const groupName in props.users) {
|
||||
users.push(...props.users[groupName]);
|
||||
}
|
||||
|
||||
return users;
|
||||
});
|
||||
|
||||
const visibleAvatarCount = computed(() => {
|
||||
return flatUserList.value.length >= props.maxAvatars
|
||||
? props.maxAvatars
|
||||
: flatUserList.value.length;
|
||||
});
|
||||
|
||||
const hiddenUsersCount = computed(() => {
|
||||
return flatUserList.value.length - visibleAvatarCount.value;
|
||||
});
|
||||
|
||||
const menuHeight = computed(() => {
|
||||
return groupCount.value > 1 ? 220 : 190;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-stack" data-test-id="user-stack-container">
|
||||
<el-dropdown
|
||||
:trigger="$props.dropdownTrigger"
|
||||
:max-height="menuHeight"
|
||||
popper-class="user-stack-popper"
|
||||
>
|
||||
<div :class="$style.avatars">
|
||||
<n8n-avatar
|
||||
v-for="user in flatUserList.slice(0, visibleAvatarCount)"
|
||||
:key="user.id"
|
||||
:firstName="user.firstName"
|
||||
:lastName="user.lastName"
|
||||
:class="$style.avatar"
|
||||
size="small"
|
||||
/>
|
||||
<div v-if="hiddenUsersCount > 0" :class="$style.hiddenBadge">+{{ hiddenUsersCount }}</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="user-stack-list">
|
||||
<div v-for="(groupUsers, index) in nonEmptyGroups" :key="index">
|
||||
<div :class="$style.groupContainer">
|
||||
<el-dropdown-item>
|
||||
<header v-if="groupCount > 1" :class="$style.groupName">{{ index }}</header>
|
||||
</el-dropdown-item>
|
||||
<div :class="$style.groupUsers">
|
||||
<el-dropdown-item v-for="user in groupUsers" :key="user.id">
|
||||
<n8n-user-info
|
||||
v-bind="user"
|
||||
:isCurrentUser="user.email === props.currentUserEmail"
|
||||
/>
|
||||
</el-dropdown-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.avatars {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.avatar {
|
||||
margin-right: calc(-1 * var(--spacing-3xs));
|
||||
user-select: none;
|
||||
}
|
||||
.hiddenBadge {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--color-text-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-3xs);
|
||||
z-index: 999;
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.groupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& + & {
|
||||
margin-top: var(--spacing-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.groupName {
|
||||
font-size: var(--font-size-3xs);
|
||||
color: var(--color-text-light);
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
}
|
||||
.groupUsers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.user-stack-list {
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
line-height: var(--font-line-height-regular);
|
||||
}
|
||||
|
||||
li:hover {
|
||||
color: currentColor !important;
|
||||
}
|
||||
}
|
||||
|
||||
.user-stack-popper {
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-5xs) 0;
|
||||
box-shadow: 0px 2px 8px 0px #441c171a;
|
||||
background-color: var(--color-background-xlight);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,118 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
import UserStack from '../UserStack.vue';
|
||||
import { N8nAvatar, N8nUserInfo } from '@/main';
|
||||
|
||||
describe('UserStack', () => {
|
||||
it('should render flat user list', () => {
|
||||
const { container } = render(UserStack, {
|
||||
props: {
|
||||
currentUserEmail: 'hello@n8n.io',
|
||||
users: {
|
||||
Owners: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
fullName: 'Sunny Side',
|
||||
email: 'hello@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
fullName: 'Kobi Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
'n8n-avatar': N8nAvatar,
|
||||
'n8n-user-info': N8nUserInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(container.querySelector('.user-stack')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not show all avatars', async () => {
|
||||
const { container } = render(UserStack, {
|
||||
props: {
|
||||
currentUserEmail: 'hello@n8n.io',
|
||||
users: {
|
||||
Owners: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
fullName: 'Sunny Side',
|
||||
email: 'hello@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
fullName: 'Kobi Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
fullName: 'John Doe',
|
||||
email: 'john@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
fullName: 'Jane Doe',
|
||||
email: 'jane@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
'n8n-avatar': N8nAvatar,
|
||||
'n8n-user-info': N8nUserInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(container.querySelector('.user-stack')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||
expect(container.querySelector('.hiddenBadge')).toHaveTextContent('+2');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import N8nUserStack from './UserStack.vue';
|
||||
|
||||
export default N8nUserStack;
|
|
@ -46,6 +46,7 @@ export { default as N8nTags } from './N8nTags';
|
|||
export { default as N8nText } from './N8nText';
|
||||
export { default as N8nTooltip } from './N8nTooltip';
|
||||
export { default as N8nTree } from './N8nTree';
|
||||
export { default as N8nUserStack } from './N8nUserStack';
|
||||
export { default as N8nUserInfo } from './N8nUserInfo';
|
||||
export { default as N8nUserSelect } from './N8nUserSelect';
|
||||
export { default as N8nUsersList } from './N8nUsersList';
|
||||
|
|
|
@ -51,6 +51,7 @@ import {
|
|||
N8nUserInfo,
|
||||
N8nUserSelect,
|
||||
N8nUsersList,
|
||||
N8nUserStack,
|
||||
} from './components';
|
||||
|
||||
export const N8nPlugin: Plugin<{}> = {
|
||||
|
@ -103,6 +104,7 @@ export const N8nPlugin: Plugin<{}> = {
|
|||
app.component('n8n-text', N8nText);
|
||||
app.component('n8n-tooltip', N8nTooltip);
|
||||
app.component('n8n-tree', N8nTree);
|
||||
app.component('n8n-user-stack', N8nUserStack);
|
||||
app.component('n8n-user-info', N8nUserInfo);
|
||||
app.component('n8n-users-list', N8nUsersList);
|
||||
app.component('n8n-user-select', N8nUserSelect);
|
||||
|
|
|
@ -18,3 +18,5 @@ export interface UserAction {
|
|||
type?: 'external-link';
|
||||
guard?: (user: IUser) => boolean;
|
||||
}
|
||||
|
||||
export type UserStackGroups = { [groupName: string]: IUser[] };
|
||||
|
|
Loading…
Reference in a new issue