refactor(editor): Update project settings to use new table component and role selector (#19152)

This commit is contained in:
Csaba Tuncsik
2025-09-16 14:43:10 +02:00
committed by GitHub
parent f0388aae7e
commit b9af51afbd
12 changed files with 2231 additions and 210 deletions

View File

@@ -20,7 +20,13 @@
"user": "User",
"enabled": "Enabled",
"disabled": "Disabled",
"type": "Type"
"type": "Type",
"role": "Role",
"roles": {
"admin": "Admin",
"editor": "Editor",
"viewer": "Viewer"
}
},
"_reusableDynamicText": {
"readMore": "Read more",
@@ -169,7 +175,7 @@
"auth.role": "Role",
"auth.roles.default": "Default",
"auth.roles.member": "Member",
"auth.roles.admin": "Admin",
"auth.roles.admin": "@:_reusableBaseText.roles.admin",
"auth.roles.owner": "Owner",
"auth.agreement.label": "I want to receive security and product updates",
"auth.setup.next": "Next",
@@ -2741,8 +2747,8 @@
"workflows.shareModal.info.sharee.fallback": "the owner",
"workflows.shareModal.info.members": "This workflow is owned by the {projectName} project which currently has {members} with access to this workflow.",
"workflows.shareModal.info.members.number": "{number} member | {number} members",
"workflows.shareModal.role.editor": "Editor",
"workflows.roles.editor": "Editor",
"workflows.shareModal.role.editor": "@:_reusableBaseText.roles.editor",
"workflows.roles.editor": "@:_reusableBaseText.roles.editor",
"workflows.concurrentChanges.confirmMessage.title": "Workflow was changed by someone else",
"workflows.concurrentChanges.confirmMessage.message": "Someone saved this workflow while you were editing it. You can <a href=\"{url}\" target=\"_blank\">view their version</a> (in new tab).<br/><br/>Overwrite their changes with yours?",
"workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel",
@@ -3050,9 +3056,9 @@
"projects.settings.button.save": "@:_reusableBaseText.save",
"projects.settings.button.cancel": "@:_reusableBaseText.cancel",
"projects.settings.button.deleteProject": "Delete project",
"projects.settings.role.admin": "Admin",
"projects.settings.role.editor": "Editor",
"projects.settings.role.viewer": "Viewer",
"projects.settings.role.admin": "@:_reusableBaseText.roles.admin",
"projects.settings.role.editor": "@:_reusableBaseText.roles.editor",
"projects.settings.role.viewer": "@:_reusableBaseText.roles.viewer",
"projects.settings.delete.title": "Delete \"{projectName}\" Project?",
"projects.settings.delete.message": "What should we do with the project data?",
"projects.settings.delete.message.empty": "There are no workflows or credentials in this project.",
@@ -3070,6 +3076,18 @@
"projects.settings.save.error.title": "An error occurred while saving the project",
"projects.settings.role.upgrade.title": "Upgrade to unlock additional roles",
"projects.settings.role.upgrade.message": "You're currently limited to {limit} on the {planName} plan and can only assign the admin role to users within this project. To create more projects and unlock additional roles, upgrade your plan.",
"projects.settings.table.header.user": "@:_reusableBaseText.user",
"projects.settings.table.header.role": "@:_reusableBaseText.role",
"projects.settings.table.row.removeUser": "Remove user",
"projects.settings.role.admin.description": "Can edit workflows, credentials, and project settings",
"projects.settings.role.editor.description": "Can edit workflows and credentials",
"projects.settings.role.viewer.description": "Can view workflows and executions",
"projects.settings.role.personalOwner": "Owner",
"projects.settings.members.search.placeholder": "Search members...",
"projects.settings.memberRole.updated.title": "Member role updated successfully",
"projects.settings.memberRole.update.error.title": "An error occurred while updating member role",
"projects.settings.member.removed.title": "Member removed successfully",
"projects.settings.member.remove.error.title": "An error occurred while removing member",
"projects.sharing.noMatchingProjects": "There are no available projects",
"projects.sharing.noMatchingUsers": "No matching users or projects",
"projects.sharing.select.placeholder": "Select project or user",

View File

@@ -0,0 +1,399 @@
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import type { ProjectRole } from '@n8n/permissions';
import type { ActionDropdownItem } from '@n8n/design-system';
import ProjectMembersRoleCell from '@/components/Projects/ProjectMembersRoleCell.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { ProjectMemberData } from '@/types/projects.types';
// Mock N8nActionDropdown and other design system components
vi.mock('@n8n/design-system', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
N8nActionDropdown: {
name: 'N8nActionDropdown',
props: {
items: { type: Array, required: true },
placement: { type: String },
},
emits: ['select'],
template: `
<div data-test-id="action-dropdown">
<div data-test-id="activator">
<slot name="activator" />
</div>
<ul data-test-id="dropdown-menu">
<li v-for="item in items" :key="item.id">
<button
:data-test-id="'action-' + item.id"
:disabled="item.disabled"
@click="$emit('select', item.id)"
>
<slot name="menuItem" v-bind="item" />
</button>
</li>
</ul>
</div>
`,
},
N8nText: {
name: 'N8nText',
props: {
color: { type: String },
size: { type: String },
},
template: '<span><slot /></span>',
},
N8nIcon: {
name: 'N8nIcon',
props: {
icon: { type: String, required: true },
size: { type: String },
color: { type: String },
},
template: '<i :data-icon="icon"></i>',
},
};
});
// Mock element-plus components
vi.mock('element-plus', async (importOriginal) => {
const actual = await importOriginal<object>();
return {
...actual,
ElRadio: {
name: 'ElRadio',
props: {
modelValue: {},
label: {},
disabled: { type: Boolean },
},
emits: ['update:model-value'],
template: `
<label>
<input
type="radio"
:value="label"
:checked="modelValue === label"
:disabled="disabled"
@change="$emit('update:model-value', label)"
/>
<slot />
</label>
`,
},
ElTooltip: {
name: 'ElTooltip',
template: '<div><slot /></div>',
},
};
});
const mockMemberData: ProjectMemberData = {
id: '123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
role: 'project:editor',
};
const mockPersonalOwnerData: ProjectMemberData = {
id: '456',
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
role: 'project:personalOwner',
};
const mockRoles: Record<ProjectRole, { label: string; desc: string }> = {
'project:admin': {
label: 'Admin',
desc: 'Can manage project settings and members',
},
'project:editor': {
label: 'Editor',
desc: 'Can edit workflows and credentials',
},
'project:viewer': {
label: 'Viewer',
desc: 'Can view workflows and executions',
},
'project:personalOwner': {
label: 'Personal Owner',
desc: '',
},
};
const mockActions: Array<ActionDropdownItem<string>> = [
{ id: 'project:admin', label: 'Admin' },
{ id: 'project:editor', label: 'Editor' },
{ id: 'project:viewer', label: 'Viewer', disabled: true },
{ id: 'remove', label: 'Remove User', divided: true },
];
let renderComponent: ReturnType<typeof createComponentRenderer>;
describe('ProjectMembersRoleCell', () => {
beforeEach(() => {
renderComponent = createComponentRenderer(ProjectMembersRoleCell, {
props: {
data: mockMemberData,
roles: mockRoles,
actions: mockActions,
},
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render dropdown for editable roles', () => {
renderComponent();
expect(screen.getByTestId('project-member-role-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('activator')).toBeInTheDocument();
expect(screen.getAllByText('Editor')).toHaveLength(2); // One in activator, one in dropdown
});
it('should render static text for non-editable roles (personalOwner)', () => {
renderComponent({
props: {
data: mockPersonalOwnerData,
},
});
expect(screen.queryByTestId('project-member-role-dropdown')).not.toBeInTheDocument();
expect(screen.getByText('Personal Owner')).toBeInTheDocument();
});
it('should display the correct role label', () => {
renderComponent();
expect(screen.getAllByText('Editor')).toHaveLength(2); // Shown in activator and dropdown option
});
it('should render chevron down icon in activator button', () => {
renderComponent();
const activatorButton = screen.getByTestId('activator').querySelector('button');
expect(activatorButton).toBeInTheDocument();
const icon = document.querySelector('i[data-icon="chevron-down"]');
expect(icon).toBeInTheDocument();
});
});
describe('Props Handling', () => {
it('should handle data prop correctly', () => {
const customData = {
id: '789',
firstName: 'Alice',
lastName: 'Johnson',
email: 'alice@example.com',
role: 'project:admin' as ProjectRole,
};
renderComponent({
props: {
data: customData,
},
});
expect(screen.getAllByText('Admin')).toHaveLength(2); // Shown in activator and dropdown option
});
it('should handle roles prop correctly', () => {
const customRoles: Record<ProjectRole, { label: string; desc: string }> = {
...mockRoles,
'project:admin': { label: 'Super Admin', desc: 'Ultimate power' },
};
renderComponent({
props: {
data: { ...mockMemberData, role: 'project:admin' },
roles: customRoles,
},
});
expect(screen.getAllByText('Super Admin')).toHaveLength(1); // Only shown in activator, not in dropdown (different role)
});
it('should pass actions to dropdown component', () => {
renderComponent();
// Check that all action items are rendered
expect(screen.getByTestId('action-project:admin')).toBeInTheDocument();
expect(screen.getByTestId('action-project:editor')).toBeInTheDocument();
expect(screen.getByTestId('action-project:viewer')).toBeInTheDocument();
expect(screen.getByTestId('action-remove')).toBeInTheDocument();
});
});
describe('Event Emissions', () => {
it('should emit update:role when role is selected', async () => {
const { emitted } = renderComponent();
const user = userEvent.setup();
await user.click(screen.getByTestId('action-project:admin'));
expect(emitted()).toHaveProperty('update:role');
expect(emitted()['update:role'][0]).toEqual([
{
role: 'project:admin',
userId: '123',
},
]);
});
it('should emit update:role when remove action is selected', async () => {
const { emitted } = renderComponent();
const user = userEvent.setup();
await user.click(screen.getByTestId('action-remove'));
expect(emitted()).toHaveProperty('update:role');
expect(emitted()['update:role'][0]).toEqual([
{
role: 'remove',
userId: '123',
},
]);
});
it('should emit with correct userId from data prop', async () => {
const customData = { ...mockMemberData, id: 'custom-user-id' };
const { emitted } = renderComponent({
props: {
data: customData,
},
});
const user = userEvent.setup();
await user.click(screen.getByTestId('action-project:admin'));
expect(emitted()['update:role'][0]).toEqual([
{
role: 'project:admin',
userId: 'custom-user-id',
},
]);
});
});
describe('Role Restrictions', () => {
it('should compute isEditable as false for personalOwner role', () => {
renderComponent({
props: {
data: mockPersonalOwnerData,
},
});
// Should render static text instead of dropdown
expect(screen.queryByTestId('project-member-role-dropdown')).not.toBeInTheDocument();
expect(screen.getByText('Personal Owner')).toBeInTheDocument();
});
it('should compute isEditable as true for non-personalOwner roles', () => {
const roles: ProjectRole[] = ['project:admin', 'project:editor', 'project:viewer'];
roles.forEach((role) => {
const { unmount } = renderComponent({
props: {
data: { ...mockMemberData, role },
},
});
expect(screen.getByTestId('project-member-role-dropdown')).toBeInTheDocument();
unmount();
});
});
});
describe('Role Selection UI', () => {
it('should render radio buttons for role selection', () => {
renderComponent();
// Radio buttons should be present for role actions (not remove)
const radioInputs = screen.getAllByRole('radio');
expect(radioInputs).toHaveLength(3); // admin, editor, viewer (remove is not a radio)
});
it('should render remove option without radio button', () => {
renderComponent();
expect(screen.getByTestId('action-remove')).toBeInTheDocument();
expect(screen.getByText('Remove User')).toBeInTheDocument();
});
it('should handle disabled actions correctly', () => {
renderComponent();
const viewerButton = screen.getByTestId('action-project:viewer');
expect(viewerButton).toBeDisabled();
});
it('should show role descriptions in radio labels', () => {
renderComponent();
expect(screen.getByText('Can manage project settings and members')).toBeInTheDocument();
expect(screen.getByText('Can edit workflows and credentials')).toBeInTheDocument();
});
it('should update selectedRole when radio button is changed', async () => {
renderComponent();
const user = userEvent.setup();
const adminRadio = screen.getByRole('radio', { name: /Admin/i });
await user.click(adminRadio);
expect(adminRadio).toBeChecked();
});
});
describe('Computed Properties', () => {
it('should compute roleLabel correctly for known roles', () => {
renderComponent();
expect(screen.getAllByText('Editor')).toHaveLength(2); // Shown in activator and dropdown option
});
it('should fallback to role string for unknown roles', () => {
const customRoles = { ...mockRoles };
delete (customRoles as Record<string, unknown>)['project:editor'];
renderComponent({
props: {
roles: customRoles,
},
});
expect(screen.getAllByText('project:editor')).toHaveLength(1); // Only shown in activator when role not found
});
});
describe('Accessibility', () => {
it('should have proper test-id attribute', () => {
renderComponent();
expect(screen.getByTestId('project-member-role-dropdown')).toBeInTheDocument();
});
it('should render activator button as a button element', () => {
renderComponent();
const activatorButton = screen.getByTestId('activator').querySelector('button');
expect(activatorButton).toBeInTheDocument();
expect(activatorButton).toHaveAttribute('type', 'button');
});
it('should provide proper radio button labels', () => {
renderComponent();
expect(screen.getByLabelText(/Admin/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Editor/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import type { ProjectRole } from '@n8n/permissions';
import { type ActionDropdownItem, N8nActionDropdown, N8nIcon, N8nText } from '@n8n/design-system';
import { ElRadio } from 'element-plus';
import { isProjectRole } from '@/utils/typeGuards';
import type { ProjectMemberData } from '@/types/projects.types';
const props = defineProps<{
data: ProjectMemberData;
roles: Record<ProjectRole, { label: string; desc: string }>;
actions: Array<ActionDropdownItem<ProjectRole | 'remove'>>;
}>();
const emit = defineEmits<{
'update:role': [payload: { role: ProjectRole | 'remove'; userId: string }];
}>();
const selectedRole = ref<string>(props.data.role);
const isEditable = computed(() => props.data.role !== 'project:personalOwner');
watch(
() => props.data.role,
(newRole) => {
selectedRole.value = newRole;
},
);
const roleLabel = computed(() =>
isProjectRole(selectedRole.value)
? props.roles[selectedRole.value]?.label || selectedRole.value
: selectedRole.value,
);
const onActionSelect = (role: ProjectRole | 'remove') => {
emit('update:role', {
role,
userId: props.data.id,
});
};
</script>
<template>
<N8nActionDropdown
v-if="isEditable"
placement="bottom-start"
:items="props.actions"
data-test-id="project-member-role-dropdown"
@select="onActionSelect"
>
<template #activator>
<button :class="$style.roleLabel" type="button">
<N8nText color="text-dark">{{ roleLabel }}</N8nText>
<N8nIcon color="text-dark" icon="chevron-down" size="large" />
</button>
</template>
<template #menuItem="item">
<N8nText v-if="item.id === 'remove'" color="text-dark" :class="$style.removeUser">{{
item.label
}}</N8nText>
<ElRadio
v-else
:model-value="selectedRole"
:label="item.id"
:disabled="item.disabled"
@update:model-value="selectedRole = item.id"
>
<span :class="$style.radioLabel">
<N8nText color="text-dark" class="pb-3xs">{{ item.label }}</N8nText>
<N8nText color="text-dark" size="small">{{
isProjectRole(item.id) ? props.roles[item.id]?.desc || '' : ''
}}</N8nText>
</span>
</ElRadio>
</template>
</N8nActionDropdown>
<span v-else>{{ roleLabel }}</span>
</template>
<style lang="scss" module>
.roleLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-3xs);
background: transparent;
padding: 0;
border: none;
cursor: pointer;
}
.radioLabel {
max-width: 268px;
display: inline-flex;
flex-direction: column;
padding: var(--spacing-2xs) 0;
span {
white-space: normal;
}
}
.removeUser {
display: block;
padding: var(--spacing-2xs) var(--spacing-l);
}
</style>

View File

@@ -0,0 +1,524 @@
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import type { ProjectRole } from '@n8n/permissions';
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
import ProjectMembersTable from '@/components/Projects/ProjectMembersTable.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { ProjectMemberData } from '@/types/projects.types';
// Mock design system components
vi.mock('@n8n/design-system', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
N8nDataTableServer: {
name: 'N8nDataTableServer',
props: {
headers: { type: Array, required: true },
items: { type: Array, required: true },
itemsLength: { type: Number, required: true },
loading: { type: Boolean },
sortBy: { type: Array },
page: { type: Number },
itemsPerPage: { type: Number },
},
emits: ['update:sort-by', 'update:page', 'update:items-per-page', 'update:options'],
template: `
<div data-test-id="data-table">
<table>
<thead>
<tr>
<th v-for="header in headers" :key="header.key" :data-test-id="'header-' + header.key">
{{ header.title }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="index" :data-test-id="'row-' + index">
<td v-for="header in headers" :key="header.key" :data-test-id="'cell-' + header.key + '-' + index">
<slot :name="'item.' + header.key" :item="item" :value="header.value ? header.value(item) : item[header.key]" />
</td>
</tr>
</tbody>
</table>
<div v-if="loading" data-test-id="loading-indicator">Loading...</div>
</div>
`,
},
N8nUserInfo: {
name: 'N8nUserInfo',
props: {
firstName: { type: String },
lastName: { type: String },
email: { type: String },
isPendingUser: { type: Boolean },
},
template: `
<div data-test-id="user-info">
<span data-test-id="user-name">{{ firstName }} {{ lastName }}</span>
<span data-test-id="user-email">{{ email }}</span>
</div>
`,
},
N8nText: {
name: 'N8nText',
props: {
color: { type: String },
},
template: '<span><slot /></span>',
},
};
});
// Mock ProjectMembersRoleCell component
vi.mock('@/components/Projects/ProjectMembersRoleCell.vue', () => ({
default: {
name: 'ProjectMembersRoleCell',
props: {
data: { type: Object, required: true },
roles: { type: Object, required: true },
actions: { type: Array, required: true },
},
emits: ['update:role'],
template: `
<div data-test-id="role-cell">
<button
:data-test-id="'role-dropdown-' + data.id"
@click="$emit('update:role', { role: 'project:admin', userId: data.id })"
>
{{ roles[data.role]?.label || data.role }}
</button>
</div>
`,
},
}));
const mockMembers: ProjectMemberData[] = [
{
id: '1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
role: 'project:admin',
},
{
id: '2',
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
role: 'project:editor',
},
{
id: '3',
firstName: 'Bob',
lastName: 'Wilson',
email: 'bob@example.com',
role: 'project:viewer',
},
];
// mockCurrentUser removed as it's not used
const mockProjectRoles = [
{ slug: 'project:admin', displayName: 'Admin', licensed: true },
{ slug: 'project:editor', displayName: 'Editor', licensed: true },
{ slug: 'project:viewer', displayName: 'Viewer', licensed: false },
];
const mockData = {
items: mockMembers,
count: mockMembers.length,
};
const mockTableOptions: TableOptions = {
page: 0,
itemsPerPage: 10,
sortBy: [
{ id: 'firstName', desc: false },
{ id: 'lastName', desc: false },
{ id: 'email', desc: false },
],
};
let renderComponent: ReturnType<typeof createComponentRenderer>;
describe('ProjectMembersTable', () => {
beforeEach(() => {
renderComponent = createComponentRenderer(ProjectMembersTable, {
props: {
data: mockData,
currentUserId: '2',
projectRoles: mockProjectRoles,
tableOptions: mockTableOptions,
},
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render table with correct headers', () => {
renderComponent();
expect(screen.getByTestId('data-table')).toBeInTheDocument();
expect(screen.getByTestId('header-name')).toBeInTheDocument();
expect(screen.getByTestId('header-role')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('should render all member rows', () => {
renderComponent();
mockMembers.forEach((_, index) => {
expect(screen.getByTestId(`row-${index}`)).toBeInTheDocument();
});
});
it('should render user information correctly', () => {
renderComponent();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('Bob Wilson')).toBeInTheDocument();
});
it('should show loading indicator when loading prop is true', () => {
renderComponent({
props: {
loading: true,
},
});
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should not show loading indicator when loading prop is false', () => {
renderComponent({
props: {
loading: false,
},
});
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
});
});
describe('Props Handling', () => {
it('should handle data prop correctly', () => {
const customData = {
items: [mockMembers[0]],
count: 1,
};
renderComponent({
props: {
data: customData,
},
});
expect(screen.getByTestId('row-0')).toBeInTheDocument();
expect(screen.queryByTestId('row-1')).not.toBeInTheDocument();
});
it('should handle currentUserId prop correctly', () => {
renderComponent();
// Current user (Jane Smith, id: '2') should not have editable role
expect(screen.queryByTestId('role-dropdown-2')).not.toBeInTheDocument();
// Other users should have editable roles
expect(screen.getByTestId('role-dropdown-1')).toBeInTheDocument();
expect(screen.getByTestId('role-dropdown-3')).toBeInTheDocument();
});
it('should handle projectRoles prop correctly', () => {
renderComponent();
// The projectRoles should be transformed into roleActions
// This is tested indirectly through role cell rendering
expect(screen.getAllByTestId('role-cell')).toHaveLength(2); // For users 1 and 3 (user 2 is current user)
});
});
describe('Event Emissions', () => {
it('should emit update:options when table options change', async () => {
const { emitted } = renderComponent();
// This test verifies that the table component properly binds the update:options event
// The actual emission would happen when the N8nDataTableServer component emits it
expect(screen.getByTestId('data-table')).toBeInTheDocument();
// Since we're testing the integration, we can verify that the handler exists
// by checking that no error is thrown when the table renders
expect(emitted()).toBeDefined();
});
it('should emit update:role when role change is triggered', async () => {
const { emitted } = renderComponent();
const user = userEvent.setup();
// Click on a role dropdown to trigger role change
await user.click(screen.getByTestId('role-dropdown-1'));
expect(emitted()).toHaveProperty('update:role');
expect(emitted()['update:role'][0]).toEqual([
{
role: 'project:admin',
userId: '1',
},
]);
});
});
describe('Computed Properties', () => {
it('should compute roles mapping correctly', () => {
renderComponent();
// Roles should be displayed with correct labels
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
expect(screen.getByText('Viewer')).toBeInTheDocument();
});
it('should compute roleActions correctly', () => {
renderComponent();
// Verify that role actions are working by checking that all expected
// roles are displayed in the role cells
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Viewer')).toBeInTheDocument();
});
it('should compute rows from data items', () => {
renderComponent();
// Verify that all member rows are rendered
mockMembers.forEach((_, index) => {
expect(screen.getByTestId(`row-${index}`)).toBeInTheDocument();
});
});
});
describe('User Permissions', () => {
it('should not allow current user to edit their own role', () => {
renderComponent();
// Current user (id: '2') should not have editable role cell
expect(screen.queryByTestId('role-dropdown-2')).not.toBeInTheDocument();
// Should show static text instead
const currentUserRow = screen.getByTestId('row-1');
expect(currentUserRow).toBeInTheDocument();
});
it("should allow editing other users' roles", () => {
renderComponent();
// Other users should have editable role cells
expect(screen.getByTestId('role-dropdown-1')).toBeInTheDocument();
expect(screen.getByTestId('role-dropdown-3')).toBeInTheDocument();
});
it('should show static role text for current user', () => {
renderComponent();
// For current user, should show static N8nText with role
const currentUserCell = screen.getByTestId('cell-role-1');
expect(currentUserCell).toBeInTheDocument();
// The text should be rendered by N8nText component
expect(screen.getByText('Editor')).toBeInTheDocument();
});
});
describe('Integration with ProjectMembersRoleCell', () => {
it('should pass correct props to ProjectMembersRoleCell', () => {
renderComponent();
// Role cells should be rendered with proper test ids
expect(screen.getByTestId('role-dropdown-1')).toBeInTheDocument();
expect(screen.getByTestId('role-dropdown-3')).toBeInTheDocument();
});
it('should handle role update from ProjectMembersRoleCell', async () => {
const { emitted } = renderComponent();
const user = userEvent.setup();
// Trigger role change through role cell
await user.click(screen.getByTestId('role-dropdown-1'));
expect(emitted()).toHaveProperty('update:role');
});
});
describe('Table Headers Configuration', () => {
it('should configure user header correctly', () => {
renderComponent();
// Verify that the user header is rendered
expect(screen.getByTestId('header-name')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
});
it('should configure role header correctly', () => {
renderComponent();
// Verify that the role header is rendered
expect(screen.getByTestId('header-role')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('should format user data for N8nUserInfo component', () => {
renderComponent();
// Verify that user info is properly formatted and displayed
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('Bob Wilson')).toBeInTheDocument();
});
});
describe('Role Change Handling', () => {
it('should handle normal role updates correctly', () => {
renderComponent();
// Verify that role change handling is set up correctly by checking
// that role cells are rendered for editable users
expect(screen.getByTestId('role-dropdown-1')).toBeInTheDocument();
expect(screen.getByTestId('role-dropdown-3')).toBeInTheDocument();
});
it('should handle remove action correctly', () => {
renderComponent();
// Verify that the table properly handles different role scenarios
// Current user (id: 2) should not have editable role
expect(screen.queryByTestId('role-dropdown-2')).not.toBeInTheDocument();
// Other users should have editable roles
expect(screen.getByTestId('role-dropdown-1')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty data items', () => {
renderComponent({
props: {
data: { items: [], count: 0 },
},
});
expect(screen.getByTestId('data-table')).toBeInTheDocument();
expect(screen.queryByTestId('row-0')).not.toBeInTheDocument();
});
it('should handle missing currentUserId', () => {
renderComponent({
props: {
currentUserId: undefined,
},
});
// All users should have editable roles when no currentUserId
mockMembers.forEach((member) => {
expect(screen.getByTestId(`role-dropdown-${member.id}`)).toBeInTheDocument();
});
});
it('should handle member without firstName/lastName', () => {
const membersWithoutNames = [
{
id: '4',
firstName: null,
lastName: null,
email: 'noname@example.com',
role: 'project:viewer' as ProjectRole,
},
];
renderComponent({
props: {
data: { items: membersWithoutNames, count: 1 },
},
});
expect(screen.getByText('noname@example.com')).toBeInTheDocument();
});
});
describe('Table Options Model', () => {
it('should use default tableOptions when not provided', () => {
renderComponent({
props: {
tableOptions: undefined,
},
});
// Component should still render with default options
expect(screen.getByTestId('data-table')).toBeInTheDocument();
});
it('should handle custom tableOptions', () => {
const customOptions: TableOptions = {
page: 2,
itemsPerPage: 5,
sortBy: [{ id: 'email', desc: true }],
};
renderComponent({
props: {
tableOptions: customOptions,
},
});
expect(screen.getByTestId('data-table')).toBeInTheDocument();
});
});
describe('Pagination Configuration', () => {
it('should configure page-sizes to hide pagination controls', () => {
renderComponent();
// Find the N8nDataTableServer component mock
const tableElement = screen.getByTestId('data-table');
expect(tableElement).toBeInTheDocument();
// Verify that pagination is effectively hidden by checking the table renders
// without pagination controls (this is tested indirectly through the mock)
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
});
it('should handle empty data without pagination', () => {
renderComponent({
props: {
data: { items: [], count: 0 },
},
});
expect(screen.getByTestId('data-table')).toBeInTheDocument();
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
});
it('should handle large datasets without showing pagination', () => {
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
id: `user-${i}`,
firstName: `User${i}`,
lastName: `Test${i}`,
email: `user${i}@example.com`,
role: 'project:viewer' as ProjectRole,
}));
renderComponent({
props: {
data: { items: largeDataset, count: largeDataset.length },
},
});
expect(screen.getByTestId('data-table')).toBeInTheDocument();
// Pagination should still be hidden due to our page-sizes configuration
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import type { ProjectRole } from '@n8n/permissions';
import { useI18n } from '@n8n/i18n';
import {
N8nUserInfo,
N8nDataTableServer,
N8nText,
type ActionDropdownItem,
} from '@n8n/design-system';
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
import ProjectMembersRoleCell from '@/components/Projects/ProjectMembersRoleCell.vue';
import type { UsersInfoProps } from '@n8n/design-system/components/N8nUserInfo/UserInfo.vue';
import { isProjectRole } from '@/utils/typeGuards';
import type { ProjectMemberData } from '@/types/projects.types';
const i18n = useI18n();
const props = defineProps<{
data: { items: ProjectMemberData[]; count: number };
loading?: boolean;
currentUserId?: string;
projectRoles: Array<{ slug: string; displayName: string; licensed: boolean }>;
}>();
const emit = defineEmits<{
'update:options': [payload: TableOptions];
'update:role': [payload: { role: ProjectRole | 'remove'; userId: string }];
}>();
const tableOptions = defineModel<TableOptions>('tableOptions', {
default: () => ({
page: 0,
itemsPerPage: 10,
sortBy: [],
}),
});
const rows = computed(() => props.data.items);
const headers = ref<Array<TableHeader<ProjectMemberData>>>([
{
title: i18n.baseText('projects.settings.table.header.user'),
key: 'name',
width: 400,
disableSort: true,
value: (row: ProjectMemberData) => row,
},
{
title: i18n.baseText('projects.settings.table.header.role'),
key: 'role',
disableSort: true,
},
]);
const roles = computed<Record<ProjectRole, { label: string; desc: string }>>(() => ({
'project:admin': {
label: i18n.baseText('projects.settings.role.admin'),
desc: i18n.baseText('projects.settings.role.admin.description'),
},
'project:editor': {
label: i18n.baseText('projects.settings.role.editor'),
desc: i18n.baseText('projects.settings.role.editor.description'),
},
'project:viewer': {
label: i18n.baseText('projects.settings.role.viewer'),
desc: i18n.baseText('projects.settings.role.viewer.description'),
},
'project:personalOwner': {
label: i18n.baseText('projects.settings.role.personalOwner'),
desc: '',
},
}));
const roleActions = computed<Array<ActionDropdownItem<ProjectRole | 'remove'>>>(() => [
...props.projectRoles.map((role) => ({
id: role.slug as ProjectRole,
label: role.displayName,
disabled: !role.licensed,
})),
{
id: 'remove',
label: i18n.baseText('projects.settings.table.row.removeUser'),
divided: true,
},
]);
const canUpdateRole = (member: ProjectMemberData): boolean => {
// User cannot change their own role or remove themselves
return member.id !== props.currentUserId;
};
const onRoleChange = ({ role, userId }: { role: ProjectRole | 'remove'; userId: string }) => {
emit('update:role', { role, userId });
};
</script>
<template>
<div>
<N8nDataTableServer
v-model:sort-by="tableOptions.sortBy"
v-model:page="tableOptions.page"
:items-per-page="data.count"
:headers="headers"
:items="rows"
:items-length="data.count"
:loading="loading"
:page-sizes="[data.count + 1]"
@update:options="emit('update:options', $event)"
>
<template #[`item.name`]="{ value }">
<div class="pt-xs pb-xs">
<N8nUserInfo v-bind="value as UsersInfoProps" />
</div>
</template>
<template #[`item.role`]="{ item }">
<ProjectMembersRoleCell
v-if="canUpdateRole(item)"
:data="item"
:roles="roles"
:actions="roleActions"
@update:role="onRoleChange"
/>
<N8nText v-else color="text-dark">{{
isProjectRole(item.role) ? roles[item.role]?.label || item.role : item.role
}}</N8nText>
</template>
</N8nDataTableServer>
</div>
</template>

View File

@@ -13,7 +13,13 @@ export type ProjectType = ProjectTypeKeys[keyof ProjectTypeKeys];
export type ProjectRelation = Pick<IUserResponse, 'id' | 'email' | 'firstName' | 'lastName'> & {
role: string;
};
export type ProjectRelationPayload = { userId: string; role: ProjectRole };
export type ProjectMemberData = {
id: string;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
role: ProjectRole;
};
export type ProjectSharingData = {
id: string;
name: string | null;

View File

@@ -4,6 +4,7 @@ import type {
TriggerPanelDefinition,
} from 'n8n-workflow';
import { nodeConnectionTypes } from 'n8n-workflow';
import type { ProjectRole, TeamProjectRole } from '@n8n/permissions';
import type {
IExecutionResponse,
ICredentialsResponse,
@@ -144,3 +145,15 @@ export function isBaseTextKey(key: string): key is BaseTextKey {
return false;
}
}
// Type guard to check if a string is a valid ProjectRole
export function isProjectRole(role: string): role is ProjectRole {
return ['project:admin', 'project:editor', 'project:viewer', 'project:personalOwner'].includes(
role,
);
}
// Type guard to check if a role is a valid TeamProjectRole (ProjectRole excluding personalOwner)
export function isTeamProjectRole(role: string): role is TeamProjectRole {
return isProjectRole(role) && role !== 'project:personalOwner';
}

View File

@@ -1,35 +1,151 @@
import { within } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { defineComponent, nextTick, reactive } from 'vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import { useRouter } from 'vue-router';
import {
getDropdownItems,
mockedStore,
type MockedStore,
useEmitters,
type Emitter,
waitAllPromises,
} from '@/__tests__/utils';
import ProjectSettings from '@/views/ProjectSettings.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { VIEWS } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useSettingsStore } from '@/stores/settings.store';
import type { FrontendSettings } from '@n8n/api-types';
import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { createProjectListItem } from '@/__tests__/data/projects';
import { createUser } from '@/__tests__/data/users';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useRolesStore } from '@/stores/roles.store';
import type { FrontendSettings } from '@n8n/api-types';
vi.mock('vue-router', () => {
const params = {};
const push = vi.fn();
const mockTrack = vi.fn();
const mockShowMessage = vi.fn();
const mockShowError = vi.fn();
const mockRouterPush = vi.fn();
const { emitters, addEmitter } = useEmitters<
'projectMembersTable' | 'n8nUserSelect' | 'n8nIconPicker'
>();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: () => ({
track: mockTrack,
}),
}));
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showMessage: mockShowMessage,
showError: mockShowError,
}),
}));
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
return {
useRoute: () => ({
params,
...actual,
useRouter: vi.fn(() => ({
currentRoute: {
value: {
params: {},
},
},
push: mockRouterPush,
})),
useRoute: () =>
reactive({
params: {},
query: {},
}),
};
});
// Stub child components to simplify event-driven testing
vi.mock('@/components/Projects/ProjectMembersTable.vue', () => ({
default: defineComponent({
name: 'ProjectMembersTableStub',
props: {
data: { type: Object, required: true },
currentUserId: { type: String, required: false },
projectRoles: { type: Array, required: true },
},
emits: ['update:options', 'update:role'],
setup(_, { emit }) {
addEmitter('projectMembersTable', emit as unknown as Emitter);
return {};
},
data() {
return {
tableOptions: {
page: 0,
itemsPerPage: 10,
sortBy: [] as Array<{ id: string; desc: boolean }>,
},
};
},
template:
'<div data-test-id="project-members-table">' +
'<div data-test-id="members-count">{{ data.items.length }}</div>' +
'<div data-test-id="members-page">{{ tableOptions.page }}</div>' +
'</div>',
}),
}));
vi.mock('@n8n/design-system', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
N8nInput: defineComponent({
name: 'N8nInputStub',
props: { modelValue: { type: String, required: false } },
emits: ['update:model-value'],
template:
'<div data-test-id="n8n-input-stub"><input :value="modelValue" @input="$emit(\'update:model-value\', $event.target.value)" /></div>',
}),
useRouter: () => ({
push,
N8nUserSelect: defineComponent({
name: 'N8nUserSelectStub',
props: {
users: { type: Array, required: true },
currentUserId: { type: String, required: false },
placeholder: { type: String, required: false },
},
emits: ['update:model-value'],
setup(_, { emit }) {
addEmitter('n8nUserSelect', emit as unknown as Emitter);
return {};
},
template: '<div data-test-id="project-members-select"></div>',
}),
N8nIconPicker: defineComponent({
name: 'N8nIconPickerStub',
props: { modelValue: { type: Object, required: false } },
emits: ['update:model-value'],
setup(_, { emit }) {
addEmitter('n8nIconPicker', emit as unknown as Emitter);
return {};
},
template: '<div data-test-id="icon-picker"></div>',
}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ProjectSettings);
const getInput = (element: Element): HTMLInputElement => {
const input = element.querySelector('input');
if (!input) throw new Error('Input element not found');
return input;
};
const getTextarea = (element: Element): HTMLTextAreaElement => {
const textarea = element.querySelector('textarea');
if (!textarea) throw new Error('Textarea element not found');
return textarea;
};
const projects = [
ProjectTypes.Personal,
ProjectTypes.Personal,
@@ -37,150 +153,440 @@ const projects = [
ProjectTypes.Team,
].map(createProjectListItem);
let router: ReturnType<typeof useRouter>;
let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let rolesStore: ReturnType<typeof useRolesStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let rolesStore: MockedStore<typeof useRolesStore>;
describe('ProjectSettings', () => {
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
router = useRouter();
projectsStore = useProjectsStore();
usersStore = useUsersStore();
settingsStore = useSettingsStore();
rolesStore = useRolesStore();
mockTrack.mockClear();
mockShowMessage.mockClear();
mockShowError.mockClear();
mockRouterPush.mockClear();
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects);
vi.spyOn(projectsStore, 'isProjectEmpty').mockResolvedValue(false);
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
mockShowMessage.mockReturnValue({ id: 'test', close: vi.fn() });
createTestingPinia();
projectsStore = mockedStore(useProjectsStore);
usersStore = mockedStore(useUsersStore);
settingsStore = mockedStore(useSettingsStore);
rolesStore = mockedStore(useRolesStore);
settingsStore.settings = {
enterprise: {
projects: {
team: {
limit: -1,
},
team: { limit: -1 },
},
},
folders: {
enabled: false,
},
} as FrontendSettings);
vi.spyOn(rolesStore, 'processedProjectRoles', 'get').mockReturnValue([
folders: { enabled: false },
} as FrontendSettings;
rolesStore.processedProjectRoles = [
{
slug: 'project:admin',
displayName: 'Project Admin',
description: 'Can manage project settings',
displayName: 'Admin',
description: null,
systemRole: false,
roleType: 'project' as const,
scopes: [],
licensed: true,
roleType: 'project',
scopes: ['project:read', 'project:write', 'project:delete'],
systemRole: true,
},
{
slug: 'project:editor',
displayName: 'Project Editor',
description: 'Can edit project settings',
displayName: 'Editor',
description: null,
systemRole: false,
roleType: 'project' as const,
scopes: [],
licensed: true,
roleType: 'project',
scopes: ['project:read', 'project:write'],
systemRole: true,
},
{
slug: 'project:custom',
displayName: 'Custom',
description: 'Can do some custom actions',
licensed: true,
roleType: 'project',
scopes: ['workflow:list'],
slug: 'project:viewer',
displayName: 'Viewer',
description: null,
systemRole: false,
roleType: 'project' as const,
scopes: [],
licensed: true,
},
]);
projectsStore.setCurrentProject({
];
const mockProject: Project = {
id: '123',
type: 'team',
name: 'Test Project',
icon: { type: 'icon', value: 'folder' },
description: '',
type: 'team',
icon: { type: 'icon', value: 'layers' },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
relations: [
{
id: '1',
lastName: 'Doe',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
role: 'project:admin',
email: 'admin@example.com',
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scopes: [],
scopes: ['project:read'],
};
projectsStore.currentProject = mockProject;
projectsStore.currentProjectId = mockProject.id;
projectsStore.availableProjects = projects;
usersStore.allUsers = [
createUser({ id: '1', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }),
createUser({ id: '2', firstName: 'Jane', lastName: 'Roe', email: 'jane@example.com' }),
createUser({
id: 'current-user',
firstName: 'Current',
lastName: 'User',
email: 'current@example.com',
}),
];
usersStore.usersById = Object.fromEntries(usersStore.allUsers.map((u) => [u.id, u]));
usersStore.currentUser = {
id: 'current-user',
firstName: 'Current',
lastName: 'User',
email: 'current@example.com',
isDefaultUser: false,
isPending: false,
isPendingUser: false,
mfaEnabled: false,
signInType: 'email',
};
});
it('deletes project with transfer or wipe based on modal selection', async () => {
projectsStore.deleteProject.mockResolvedValue();
projectsStore.isProjectEmpty.mockResolvedValue(false);
const r1 = renderComponent();
const deleteButton = r1.getByTestId('project-settings-delete-button');
// Case 1: Non-empty project, transfer to another project
await userEvent.click(deleteButton);
await nextTick();
expect(r1.getByTestId('project-settings-delete-confirm-button')).toBeInTheDocument();
const transferRadio = document.querySelector('input[value="transfer"]') as HTMLInputElement;
await userEvent.click(transferRadio);
await nextTick();
const transferSelect = r1.getByTestId('project-sharing-select');
const transferOptions = await getDropdownItems(transferSelect);
await userEvent.click(transferOptions[0]);
await nextTick();
await userEvent.click(r1.getByTestId('project-settings-delete-confirm-button'));
await waitAllPromises();
expect(projectsStore.deleteProject).toHaveBeenCalledWith('123', expect.any(String));
expect(mockRouterPush).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
// Case 2: Empty project, wiping directly (no transfer)
projectsStore.deleteProject.mockClear();
projectsStore.isProjectEmpty.mockResolvedValue(true);
// unmount previous instance and re-render to reset dialog internal state
r1.unmount();
const r2 = renderComponent();
const deleteButton2 = r2.getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton2);
await nextTick();
await userEvent.click(r2.getByTestId('project-settings-delete-confirm-button'));
await waitAllPromises();
expect(projectsStore.deleteProject).toHaveBeenCalledWith('123', undefined);
});
it('renders core form elements and initializes state', async () => {
const { getByTestId } = renderComponent();
await nextTick();
expect(getByTestId('project-settings-container')).toBeInTheDocument();
const nameInput = getByTestId('project-settings-name-input');
const descriptionInput = getByTestId('project-settings-description-input');
const saveButton = getByTestId('project-settings-save-button');
const cancelButton = getByTestId('project-settings-cancel-button');
expect(nameInput).toBeInTheDocument();
expect(descriptionInput).toBeInTheDocument();
expect(saveButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
const actualName = getInput(nameInput);
const actualDesc = getTextarea(descriptionInput);
expect(actualName.value).toBe('Test Project');
expect(actualDesc.value).toBe('');
});
describe('Form interactions', () => {
it('marks dirty, cancels reset, and saves via Enter and button', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const saveButton = getByTestId('project-settings-save-button');
const cancelButton = getByTestId('project-settings-cancel-button');
const actualInput = getInput(nameInput);
// Dirty then cancel
await userEvent.type(actualInput, ' Extra');
expect(cancelButton).toBeEnabled();
await userEvent.click(cancelButton);
expect(actualInput.value).toBe('Test Project');
expect(cancelButton).toBeDisabled();
// Save via Enter
await userEvent.type(actualInput, ' - Updated');
await userEvent.type(actualInput, '{enter}');
await nextTick();
expect(updateSpy).toHaveBeenCalledWith(
'123',
expect.objectContaining({ name: 'Test Project - Updated', description: '' }),
);
expect(mockShowMessage).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User changed project name',
expect.objectContaining({ project_id: '123', name: 'Test Project - Updated' }),
);
// Save via button
await userEvent.type(actualInput, ' Again');
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await nextTick();
expect(updateSpy).toHaveBeenCalledTimes(2);
expect(mockShowMessage).toHaveBeenCalledTimes(2);
});
});
it('should show confirmation modal before deleting project and delete with transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
describe('Validation and error handling', () => {
it('prevents invalid save and shows error on failed save', async () => {
const error = new Error('Save failed');
projectsStore.updateProject.mockRejectedValue(error);
const { getByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const saveButton = getByTestId('project-settings-save-button');
const actualInput = getInput(nameInput);
const { getByTestId, findByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.type(actualInput, ' Updated');
await userEvent.type(actualInput, '{enter}');
await nextTick();
expect(projectsStore.updateProject).toHaveBeenCalled();
expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String));
expect(mockShowMessage).not.toHaveBeenCalled();
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = await findByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[0]);
const projectSelect = getByTestId('project-sharing-select');
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', expect.any(String));
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
projectsStore.updateProject.mockClear();
await userEvent.clear(actualInput);
expect(saveButton).toBeDisabled();
await userEvent.click(saveButton);
expect(projectsStore.updateProject).not.toHaveBeenCalled();
});
});
it('should show confirmation modal before deleting project and deleting without transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
describe('Save state and validation', () => {
it('maintains state after save and validation toggles', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const cancelButton = getByTestId('project-settings-cancel-button');
const saveButton = getByTestId('project-settings-save-button');
const actualInput = getInput(nameInput);
const { getByTestId, findByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = await findByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[1]);
const input = within(modal).getByRole('textbox');
await userEvent.type(input, 'delete all ');
expect(confirmButton).toBeDisabled();
await userEvent.type(input, 'data');
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined);
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
await userEvent.clear(actualInput);
expect(saveButton).toBeDisabled();
await userEvent.type(actualInput, 'Valid Project Name');
expect(saveButton).toBeEnabled();
expect(cancelButton).toBeEnabled();
await userEvent.click(saveButton);
await nextTick();
expect(updateSpy).toHaveBeenCalled();
expect(cancelButton).toBeDisabled();
expect(saveButton).toBeDisabled();
});
});
it('should show role dropdown', async () => {
const { getByTestId } = renderComponent();
const roleDropdown = getByTestId('projects-settings-user-role-select');
expect(roleDropdown).toBeVisible();
const roleDropdownItems = await getDropdownItems(roleDropdown);
expect(roleDropdownItems).toHaveLength(3);
expect(roleDropdownItems[0]).toHaveTextContent('Admin');
expect(roleDropdownItems[1]).toHaveTextContent('Editor');
expect(roleDropdownItems[2]).toHaveTextContent('Custom');
describe('Members table and role updates', () => {
it('adds a member and saves with telemetry', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
await nextTick();
// Initially one member
expect(getByTestId('members-count').textContent).toBe('1');
// Add member via user select
emitters.n8nUserSelect.emit('update:model-value', '2');
await nextTick();
expect(getByTestId('members-count').textContent).toBe('2');
// Ensure form valid + dirty; name keystroke triggers validate and enables save
const nameInput = getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' ');
await userEvent.click(getByTestId('project-settings-save-button'));
await nextTick();
expect(updateSpy).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User added member to project',
expect.objectContaining({ project_id: '123', target_user_id: '2' }),
);
});
it('filters members via search and saves', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
await nextTick();
expect(getByTestId('members-count').textContent).toBe('1');
const searchContainer = getByTestId('project-members-search');
const searchInput = searchContainer.querySelector('input')!;
await userEvent.type(searchInput, 'john@example.com');
await new Promise((r) => setTimeout(r, 350));
expect(getByTestId('members-count').textContent).toBe('1');
// Make a minor change to mark the form dirty so save is enabled
const nameInput = getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' ');
await userEvent.click(getByTestId('project-settings-save-button'));
await nextTick();
expect(updateSpy).toHaveBeenCalled();
});
it('inline role change saves immediately with telemetry', async () => {
projectsStore.updateProject.mockResolvedValue(undefined);
renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:editor' });
await nextTick();
expect(projectsStore.updateProject).toHaveBeenCalledWith(
'123',
expect.objectContaining({
relations: expect.arrayContaining([
expect.objectContaining({ userId: '1', role: 'project:editor' }),
]),
}),
);
expect(mockShowMessage).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User changed member role on project',
expect.objectContaining({ project_id: '123', target_user_id: '1', role: 'project:editor' }),
);
});
it('rolls back role on save error', async () => {
// First, inline update fails and rolls back
projectsStore.updateProject.mockRejectedValueOnce(new Error('fail'));
const utils = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:viewer' });
await nextTick();
expect(mockShowError).toHaveBeenCalled();
// Next form save should contain original role (admin) due to rollback
const nameInput = utils.getByTestId('project-settings-name-input');
await userEvent.type(getInput(nameInput), ' touch');
await userEvent.click(utils.getByTestId('project-settings-save-button'));
await nextTick();
const lastCall = projectsStore.updateProject.mock.calls.pop();
expect(lastCall?.[1].relations).toEqual(
expect.arrayContaining([expect.objectContaining({ userId: '1', role: 'project:admin' })]),
);
});
it('marks member for removal, filters it out, and saves without it with telemetry', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId, queryByTestId } = renderComponent();
await nextTick();
expect(getByTestId('members-count').textContent).toBe('1');
// Mark for removal
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'remove' });
await nextTick();
// Pending removal should hide members table container entirely
expect(queryByTestId('members-count')).toBeNull();
await userEvent.click(getByTestId('project-settings-save-button'));
await nextTick();
expect(updateSpy).toHaveBeenCalled();
const payload = updateSpy.mock.calls[0][1];
expect(payload.relations).toEqual([]);
expect(mockTrack).toHaveBeenCalledWith(
'User removed member from project',
expect.objectContaining({ project_id: '123', target_user_id: '1' }),
);
});
it('prevents saving when invalid role selected', async () => {
// Set invalid role and try to save
const utils = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', {
userId: '1',
role: 'project:personalOwner',
});
await nextTick();
// Clear prior success toast from inline update (if any)
mockShowMessage.mockClear();
// Mark form dirty so save is enabled
const nameInput = utils.getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' ');
await userEvent.click(utils.getByTestId('project-settings-save-button'));
await nextTick();
expect(mockShowError).toHaveBeenCalled();
// Should not show success on invalid role
expect(mockShowMessage).not.toHaveBeenCalled();
});
it('resets pagination to first page on search', async () => {
const utils = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:options', {
page: 2,
itemsPerPage: 10,
sortBy: [],
});
await nextTick();
const searchContainer = utils.getByTestId('project-members-search');
const searchInput = searchContainer.querySelector('input')!;
await userEvent.type(searchInput, 'john');
await new Promise((r) => setTimeout(r, 350));
// unmount first to avoid duplicate elements
utils.unmount();
const utils2 = renderComponent();
expect(utils2.getByTestId('members-page').textContent).toBe('0');
});
});
describe('Icon updates', () => {
it('updates project icon and shows success toast', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
renderComponent();
await nextTick();
emitters.n8nIconPicker.emit('update:model-value', { type: 'icon', value: 'zap' });
await nextTick();
await waitAllPromises();
expect(updateSpy).toHaveBeenCalled();
expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
});
describe('Lifecycle', () => {
it('reacts to project change and keeps inputs bound', async () => {
const { getByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const descInput = getByTestId('project-settings-description-input');
const actualName = getInput(nameInput);
const actualDesc = descInput.querySelector('textarea')!;
expect(actualName.value).toBe('Test Project');
expect(actualDesc.value).toBe('');
const updatedProject = {
...projectsStore.currentProject!,
name: 'Updated Project',
description: 'Updated description',
};
projectsStore.setCurrentProject(updatedProject);
await nextTick();
expect(nameInput).toBeInTheDocument();
expect(descInput).toBeInTheDocument();
expect(getByTestId('project-members-select')).toBeInTheDocument();
});
});
});

View File

@@ -1,9 +1,10 @@
<script lang="ts" setup>
import type { ProjectRole, TeamProjectRole } from '@n8n/permissions';
import type { ProjectRole } from '@n8n/permissions';
import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow';
import { N8nFormInput } from '@n8n/design-system';
import { N8nFormInput, N8nInput } from '@n8n/design-system';
import { useDebounceFn } from '@vueuse/core';
import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { useI18n } from '@n8n/i18n';
@@ -13,12 +14,15 @@ import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue';
import ProjectMembersTable from '@/components/Projects/ProjectMembersTable.vue';
import { useRolesStore } from '@/stores/roles.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
import { isProjectRole } from '@/utils/typeGuards';
type FormDataDiff = {
name?: string;
@@ -49,6 +53,7 @@ const formData = ref<Pick<Project, 'name' | 'description' | 'relations'>>({
description: '',
relations: [],
});
const projectRoleTranslations = ref<{ [key: string]: string }>({
'project:viewer': i18n.baseText('projects.settings.role.viewer'),
'project:editor': i18n.baseText('projects.settings.role.editor'),
@@ -61,6 +66,19 @@ const projectIcon = ref<IconOrEmoji>({
value: 'layers',
});
const search = ref('');
const membersTableState = ref<TableOptions>({
page: 0,
itemsPerPage: 10,
sortBy: [
{ id: 'firstName', desc: false },
{ id: 'lastName', desc: false },
{ id: 'email', desc: false },
],
});
const pendingRemovals = ref<Set<string>>(new Set());
const usersList = computed(() =>
usersStore.allUsers.filter((user: IUser) => {
const isAlreadySharedWithUser = (formData.value.relations || []).find(
@@ -99,13 +117,59 @@ const onAddMember = (userId: string) => {
formData.value.relations.push(relation);
};
const onRoleAction = (userId: string, role?: string) => {
isDirty.value = true;
const index = formData.value.relations.findIndex((r: ProjectRelation) => r.id === userId);
const onUpdateMemberRole = async ({
userId,
role,
}: {
userId: string;
role: ProjectRole | 'remove';
}) => {
if (role === 'remove') {
formData.value.relations.splice(index, 1);
} else {
formData.value.relations[index].role = role as ProjectRole;
// Mark for pending removal instead of immediate removal
pendingRemovals.value.add(userId);
isDirty.value = true;
return;
}
if (!projectsStore.currentProject) {
return;
}
const memberIndex = formData.value.relations.findIndex((r) => r.id === userId);
if (memberIndex === -1) {
return;
}
// Store original role for rollback
const originalRole = formData.value.relations[memberIndex].role;
// Update UI optimistically
formData.value.relations[memberIndex].role = role;
try {
await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name ?? '',
icon: projectIcon.value,
description: formData.value.description ?? '',
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
})),
});
toast.showMessage({
type: 'success',
title: i18n.baseText('projects.settings.memberRole.updated.title'),
});
telemetry.track('User changed member role on project', {
project_id: projectsStore.currentProject.id,
target_user_id: userId,
role,
});
} catch (error) {
// Rollback to original role on API failure
formData.value.relations[memberIndex].role = originalRole;
toast.showError(error, i18n.baseText('projects.settings.memberRole.update.error.title'));
}
};
@@ -119,6 +183,7 @@ const onCancel = () => {
: [];
formData.value.name = projectsStore.currentProject?.name ?? '';
formData.value.description = projectsStore.currentProject?.description ?? '';
pendingRemovals.value.clear();
isDirty.value = false;
};
@@ -199,18 +264,29 @@ const updateProject = async () => {
if (formData.value.relations.some((r) => r.role === 'project:personalOwner')) {
throw new Error('Invalid role selected for this project.');
}
// Remove pending removal members from relations before saving
const relationsToSave = formData.value.relations.filter(
(r: ProjectRelation) => !pendingRemovals.value.has(r.id),
);
await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name!,
name: formData.value.name ?? '',
icon: projectIcon.value,
description: formData.value.description!,
relations: formData.value.relations.map((r: ProjectRelation) => ({
description: formData.value.description ?? '',
relations: relationsToSave.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role as TeamProjectRole,
role: r.role,
})),
});
// After successful save, actually remove pending members from formData
formData.value.relations = relationsToSave;
pendingRemovals.value.clear();
isDirty.value = false;
} catch (error) {
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
throw error;
}
};
@@ -218,15 +294,20 @@ const onSubmit = async () => {
if (!isDirty.value) {
return;
}
await updateProject();
const diff = makeFormDataDiff();
sendTelemetry(diff);
toast.showMessage({
title: i18n.baseText('projects.settings.save.successful.title', {
interpolate: { projectName: formData.value.name ?? '' },
}),
type: 'success',
});
try {
await updateProject();
const diff = makeFormDataDiff();
sendTelemetry(diff);
toast.showMessage({
title: i18n.baseText('projects.settings.save.successful.title', {
interpolate: { projectName: formData.value.name ?? '' },
}),
type: 'success',
});
} catch (error) {
// Error already handled and displayed by updateProject()
// Just prevent success toast/telemetry from executing
}
};
const onDelete = async () => {
@@ -293,18 +374,67 @@ watch(
);
// Add users property to the relation objects,
// So that N8nUsersList has access to the full user data
// So that the table has access to the full user data
// Filter out users marked for pending removal
const relationUsers = computed(() =>
formData.value.relations.map((relation: ProjectRelation) => {
const user = usersStore.usersById[relation.id];
if (!user) return relation as ProjectRelation & IUser;
return {
...user,
...relation,
};
}),
formData.value.relations
.filter((relation: ProjectRelation) => !pendingRemovals.value.has(relation.id))
.map((relation: ProjectRelation) => {
const user = usersStore.usersById[relation.id];
// Ensure type safety for UI display while preserving original role in formData
const safeRole: ProjectRole = isProjectRole(relation.role) ? relation.role : 'project:viewer';
if (!user) {
return {
...relation,
role: safeRole,
firstName: null,
lastName: null,
email: null,
};
}
return {
...user,
...relation,
role: safeRole,
};
}),
);
const membersTableData = computed(() => ({
items: relationUsers.value,
count: relationUsers.value.length,
}));
const filteredMembersData = computed(() => {
if (!search.value.trim()) {
return membersTableData.value;
}
const searchTerm = search.value.toLowerCase();
const filtered = relationUsers.value.filter((member) => {
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.toLowerCase();
const email = (member.email || '').toLowerCase();
return fullName.includes(searchTerm) || email.includes(searchTerm);
});
return {
items: filtered,
count: filtered.length,
};
});
const debouncedSearch = useDebounceFn(() => {
membersTableState.value.page = 0; // Reset to first page on search
}, 300);
const onSearch = (value: string) => {
search.value = value;
void debouncedSearch();
};
const onUpdateMembersTableOptions = (options: TableOptions) => {
membersTableState.value = options;
};
onBeforeMount(async () => {
await usersStore.fetchUsers();
});
@@ -316,7 +446,7 @@ onMounted(() => {
</script>
<template>
<div :class="$style.projectSettings">
<div :class="$style.projectSettings" data-test-id="project-settings-container">
<div :class="$style.header">
<ProjectHeader />
</div>
@@ -377,49 +507,29 @@ onMounted(() => {
<N8nIcon icon="search" />
</template>
</N8nUserSelect>
<N8nUsersList
:actions="[]"
:users="relationUsers"
:current-user-id="usersStore.currentUser?.id"
:delete-label="i18n.baseText('workflows.shareModal.list.delete')"
>
<template #actions="{ user }">
<div :class="$style.buttons">
<N8nSelect
class="mr-2xs"
:model-value="user?.role || projectRoles[0].slug"
size="small"
data-test-id="projects-settings-user-role-select"
@update:model-value="onRoleAction(user.id, $event)"
>
<N8nOption
v-for="role in projectRoles"
:key="role.slug"
:value="role.slug"
:label="role.displayName"
:disabled="!role.licensed"
>
{{ role.displayName
}}<span
v-if="!role.licensed"
:class="$style.upgrade"
@click="upgradeDialogVisible = true"
>
&nbsp;-&nbsp;{{ i18n.baseText('generic.upgrade') }}
</span>
</N8nOption>
</N8nSelect>
<N8nButton
type="tertiary"
native-type="button"
square
icon="trash-2"
data-test-id="project-user-remove"
@click="onRoleAction(user.id, 'remove')"
/>
</div>
</template>
</N8nUsersList>
<div v-if="relationUsers.length > 0" :class="$style.membersTableContainer">
<N8nInput
:class="$style.search"
:model-value="search"
:placeholder="i18n.baseText('projects.settings.members.search.placeholder')"
clearable
data-test-id="project-members-search"
@update:model-value="onSearch"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<ProjectMembersTable
v-model:table-options="membersTableState"
data-test-id="project-members-table"
:data="filteredMembersData"
:current-user-id="usersStore.currentUser?.id"
:project-roles="projectRoles"
@update:options="onUpdateMembersTableOptions"
@update:role="onUpdateMemberRole"
/>
</div>
</fieldset>
<fieldset :class="$style.buttons">
<div>
@@ -513,6 +623,15 @@ onMounted(() => {
align-items: center;
}
.membersTableContainer {
margin-top: var(--spacing-s);
}
.search {
max-width: 300px;
margin-bottom: var(--spacing-s);
}
.project-name {
display: flex;
gap: var(--spacing-2xs);