mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(editor): Update project settings to use new table component and role selector (#19152)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push,
|
||||
};
|
||||
});
|
||||
|
||||
// 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>',
|
||||
}),
|
||||
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('should show confirmation modal before deleting project and delete with transfer', async () => {
|
||||
const deleteProjectSpy = vi
|
||||
.spyOn(projectsStore, 'deleteProject')
|
||||
.mockImplementation(async () => {});
|
||||
it('deletes project with transfer or wipe based on modal selection', async () => {
|
||||
projectsStore.deleteProject.mockResolvedValue();
|
||||
projectsStore.isProjectEmpty.mockResolvedValue(false);
|
||||
|
||||
const { getByTestId, findByRole } = renderComponent();
|
||||
const deleteButton = getByTestId('project-settings-delete-button');
|
||||
const r1 = renderComponent();
|
||||
const deleteButton = r1.getByTestId('project-settings-delete-button');
|
||||
|
||||
// Case 1: Non-empty project, transfer to another project
|
||||
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 nextTick();
|
||||
expect(r1.getByTestId('project-settings-delete-confirm-button')).toBeInTheDocument();
|
||||
|
||||
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();
|
||||
const transferRadio = document.querySelector('input[value="transfer"]') as HTMLInputElement;
|
||||
await userEvent.click(transferRadio);
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(confirmButton);
|
||||
expect(deleteProjectSpy).toHaveBeenCalledWith('123', expect.any(String));
|
||||
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
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('should show confirmation modal before deleting project and deleting without transfer', async () => {
|
||||
const deleteProjectSpy = vi
|
||||
.spyOn(projectsStore, 'deleteProject')
|
||||
.mockImplementation(async () => {});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it('should show role dropdown', async () => {
|
||||
it('renders core form elements and initializes state', 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');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
projectsStore.updateProject.mockClear();
|
||||
await userEvent.clear(actualInput);
|
||||
expect(saveButton).toBeDisabled();
|
||||
await userEvent.click(saveButton);
|
||||
expect(projectsStore.updateProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +294,7 @@ const onSubmit = async () => {
|
||||
if (!isDirty.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateProject();
|
||||
const diff = makeFormDataDiff();
|
||||
sendTelemetry(diff);
|
||||
@@ -227,6 +304,10 @@ const onSubmit = async () => {
|
||||
}),
|
||||
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) => {
|
||||
formData.value.relations
|
||||
.filter((relation: ProjectRelation) => !pendingRemovals.value.has(relation.id))
|
||||
.map((relation: ProjectRelation) => {
|
||||
const user = usersStore.usersById[relation.id];
|
||||
if (!user) return relation as ProjectRelation & IUser;
|
||||
// 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"
|
||||
<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"
|
||||
: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"
|
||||
>
|
||||
- {{ 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')"
|
||||
:project-roles="projectRoles"
|
||||
@update:options="onUpdateMembersTableOptions"
|
||||
@update:role="onUpdateMemberRole"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</N8nUsersList>
|
||||
</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);
|
||||
|
||||
@@ -525,7 +525,8 @@ Here's a complete example from our codebase showing all layers:
|
||||
export class ProjectSettingsPage extends BasePage {
|
||||
// Simple action methods only
|
||||
async fillProjectName(name: string) {
|
||||
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
|
||||
// Prefer stable ID selectors on the wrapper element and then target the inner control
|
||||
await this.page.locator('#projectName input').fill(name);
|
||||
}
|
||||
|
||||
async clickSaveButton() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { BasePage } from './BasePage';
|
||||
|
||||
export class ProjectSettingsPage extends BasePage {
|
||||
@@ -5,7 +7,73 @@ export class ProjectSettingsPage extends BasePage {
|
||||
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
|
||||
}
|
||||
|
||||
async fillProjectDescription(description: string) {
|
||||
await this.page
|
||||
.getByTestId('project-settings-description-input')
|
||||
.locator('textarea')
|
||||
.fill(description);
|
||||
}
|
||||
|
||||
async clickSaveButton() {
|
||||
await this.clickButtonByName('Save');
|
||||
}
|
||||
|
||||
async clickCancelButton() {
|
||||
await this.page.getByTestId('project-settings-cancel-button').click();
|
||||
}
|
||||
|
||||
async clearMemberSearch() {
|
||||
const searchInput = this.page.getByTestId('project-members-search');
|
||||
const clearButton = searchInput.locator('+ span');
|
||||
if (await clearButton.isVisible()) {
|
||||
await clearButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
getMembersTable() {
|
||||
return this.page.getByTestId('project-members-table');
|
||||
}
|
||||
|
||||
async getMemberRowCount() {
|
||||
const table = this.getMembersTable();
|
||||
const rows = table.locator('tbody tr');
|
||||
return await rows.count();
|
||||
}
|
||||
|
||||
async expectTableHasMemberCount(expectedCount: number) {
|
||||
const actualCount = await this.getMemberRowCount();
|
||||
expect(actualCount).toBe(expectedCount);
|
||||
}
|
||||
|
||||
async expectSearchInputValue(expectedValue: string) {
|
||||
const searchInput = this.page.getByTestId('project-members-search').locator('input');
|
||||
await expect(searchInput).toHaveValue(expectedValue);
|
||||
}
|
||||
|
||||
// Robust value assertions on inner form controls
|
||||
getNameInput() {
|
||||
return this.page.locator('#projectName input');
|
||||
}
|
||||
|
||||
getDescriptionTextarea() {
|
||||
return this.page.locator('#projectDescription textarea');
|
||||
}
|
||||
|
||||
async expectProjectNameValue(value: string) {
|
||||
await expect(this.getNameInput()).toHaveValue(value);
|
||||
}
|
||||
|
||||
async expectProjectDescriptionValue(value: string) {
|
||||
await expect(this.getDescriptionTextarea()).toHaveValue(value);
|
||||
}
|
||||
|
||||
async expectTableIsVisible() {
|
||||
const table = this.getMembersTable();
|
||||
await expect(table).toBeVisible();
|
||||
}
|
||||
|
||||
async expectMembersSelectIsVisible() {
|
||||
const select = this.page.getByTestId('project-members-select');
|
||||
await expect(select).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,237 @@ test.describe('Projects', () => {
|
||||
await expect(subn8n.page.locator('[data-test-id="resources-list-item"]')).toHaveCount(1);
|
||||
await expect(subn8n.page.getByRole('heading', { name: 'Notion account' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Project Settings - Member Management', () => {
|
||||
test('should display project settings page with correct layout @auth:owner', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('UI Test Project');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Verify basic project settings form elements are visible (inner controls)
|
||||
await expect(n8n.projectSettings.getNameInput()).toBeVisible();
|
||||
await expect(n8n.projectSettings.getDescriptionTextarea()).toBeVisible();
|
||||
await n8n.projectSettings.expectMembersSelectIsVisible();
|
||||
|
||||
// Verify members table is visible when there are members
|
||||
await n8n.projectSettings.expectTableIsVisible();
|
||||
|
||||
// Initially should have only the owner (current user)
|
||||
await n8n.projectSettings.expectTableHasMemberCount(1);
|
||||
|
||||
// Verify project settings action buttons are present
|
||||
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeVisible();
|
||||
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeVisible();
|
||||
await expect(n8n.page.getByTestId('project-settings-delete-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow editing project name and description @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Edit Test Project');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Update project name
|
||||
const newName = 'Updated Project Name';
|
||||
await n8n.projectSettings.fillProjectName(newName);
|
||||
|
||||
// Update project description
|
||||
const newDescription = 'This is an updated project description.';
|
||||
await n8n.projectSettings.fillProjectDescription(newDescription);
|
||||
|
||||
// Save changes
|
||||
await n8n.projectSettings.clickSaveButton();
|
||||
|
||||
// Wait for success notification
|
||||
await expect(
|
||||
n8n.page.getByText('Project Updated Project Name saved successfully', { exact: false }),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify the form shows the updated values
|
||||
await n8n.projectSettings.expectProjectNameValue(newName);
|
||||
await n8n.projectSettings.expectProjectDescriptionValue(newDescription);
|
||||
});
|
||||
|
||||
test('should display members table with correct structure @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Table Structure Test');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const table = n8n.projectSettings.getMembersTable();
|
||||
|
||||
// Verify table headers are present
|
||||
await expect(table.getByText('User')).toBeVisible();
|
||||
await expect(table.getByText('Role')).toBeVisible();
|
||||
|
||||
// Verify the owner is displayed in the table
|
||||
const memberRows = table.locator('tbody tr');
|
||||
await expect(memberRows).toHaveCount(1);
|
||||
|
||||
// Verify owner cannot change their own role
|
||||
const ownerRow = memberRows.first();
|
||||
const roleDropdown = ownerRow.getByTestId('project-member-role-dropdown');
|
||||
await expect(roleDropdown).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should display role dropdown for members but not for current user @auth:owner', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Role Dropdown Test');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Current user (owner) should not have a role dropdown
|
||||
const currentUserRow = n8n.page.locator('tbody tr').first();
|
||||
await expect(currentUserRow.getByTestId('project-member-role-dropdown')).not.toBeVisible();
|
||||
|
||||
// The role should be displayed as static text for the current user
|
||||
await expect(currentUserRow.getByText('Admin')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle member search functionality when search input is used', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Search Test Project');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Verify search input is visible
|
||||
const searchInput = n8n.page.getByTestId('project-members-search');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// Test search functionality - enter search term
|
||||
await searchInput.fill('nonexistent');
|
||||
|
||||
// Since we only have the owner, searching for nonexistent should show no filtered results
|
||||
// But the table structure should still be present
|
||||
await expect(searchInput).toHaveValue('nonexistent');
|
||||
|
||||
// Clear search
|
||||
await n8n.projectSettings.clearMemberSearch();
|
||||
await expect(searchInput).toHaveValue('');
|
||||
});
|
||||
|
||||
test('should show project settings form validation @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Validation Test');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Clear the project name (required field)
|
||||
await n8n.projectSettings.fillProjectName('');
|
||||
|
||||
// Save button should be disabled when required field is empty
|
||||
const saveButton = n8n.page.getByTestId('project-settings-save-button');
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Fill in a valid name
|
||||
await n8n.projectSettings.fillProjectName('Valid Project Name');
|
||||
|
||||
// Save button should now be enabled
|
||||
await expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('should handle unsaved changes state @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Unsaved Changes Test');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Initially, save and cancel buttons should be disabled (no changes)
|
||||
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
|
||||
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeDisabled();
|
||||
|
||||
// Make a change to the project name
|
||||
await n8n.projectSettings.fillProjectName('Modified Name');
|
||||
|
||||
// Save and cancel buttons should now be enabled
|
||||
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeDisabled();
|
||||
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeDisabled();
|
||||
|
||||
// Unsaved changes message should be visible
|
||||
await expect(n8n.page.getByText('You have unsaved changes')).toBeVisible();
|
||||
|
||||
// Cancel changes
|
||||
await n8n.projectSettings.clickCancelButton();
|
||||
|
||||
// Buttons should be disabled again
|
||||
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
|
||||
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should display delete project section with warning @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Delete Test Project');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Scroll to bottom to see delete section
|
||||
await n8n.page
|
||||
.locator('[data-test-id="project-settings-delete-button"]')
|
||||
.scrollIntoViewIfNeeded();
|
||||
|
||||
// Verify danger section is visible with warning
|
||||
// Copy was updated in UI to use sentence case and expanded description
|
||||
await expect(n8n.page.getByText('Danger zone')).toBeVisible();
|
||||
await expect(
|
||||
n8n.page.getByText(
|
||||
'When deleting a project, you can also choose to move all workflows and credentials to another project.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(n8n.page.getByTestId('project-settings-delete-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should persist settings after page reload @auth:owner', async ({ n8n }) => {
|
||||
// Create a new project
|
||||
const { projectId } = await n8n.projectComposer.createProject('Persistence Test');
|
||||
|
||||
// Navigate to project settings
|
||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Update project details
|
||||
const projectName = 'Persisted Project Name';
|
||||
const projectDescription = 'This description should persist after reload';
|
||||
|
||||
await n8n.projectSettings.fillProjectName(projectName);
|
||||
await n8n.projectSettings.fillProjectDescription(projectDescription);
|
||||
await n8n.projectSettings.clickSaveButton();
|
||||
|
||||
// Wait for save confirmation (partial match to include project name)
|
||||
await expect(
|
||||
n8n.page.getByText('Project Persisted Project Name saved successfully', { exact: false }),
|
||||
).toBeVisible();
|
||||
|
||||
// Reload the page
|
||||
await n8n.page.reload();
|
||||
await n8n.page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Verify data persisted
|
||||
await n8n.projectSettings.expectProjectNameValue(projectName);
|
||||
await n8n.projectSettings.expectProjectDescriptionValue(projectDescription);
|
||||
|
||||
// Verify table still shows the owner
|
||||
await n8n.projectSettings.expectTableHasMemberCount(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user