From b9af51afbd65b3e47b4fa95eed78eeb1b5e03794 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 16 Sep 2025 14:43:10 +0200 Subject: [PATCH] refactor(editor): Update project settings to use new table component and role selector (#19152) --- .../frontend/@n8n/i18n/src/locales/en.json | 32 +- .../Projects/ProjectMembersRoleCell.test.ts | 399 +++++++++++ .../Projects/ProjectMembersRoleCell.vue | 105 +++ .../Projects/ProjectMembersTable.test.ts | 524 ++++++++++++++ .../Projects/ProjectMembersTable.vue | 129 ++++ .../editor-ui/src/types/projects.types.ts | 8 +- .../editor-ui/src/utils/typeGuards.ts | 13 + .../src/views/ProjectSettings.test.ts | 652 ++++++++++++++---- .../editor-ui/src/views/ProjectSettings.vue | 267 +++++-- packages/testing/playwright/CONTRIBUTING.md | 11 +- .../playwright/pages/ProjectSettingsPage.ts | 68 ++ .../playwright/tests/ui/39-projects.spec.ts | 233 +++++++ 12 files changed, 2231 insertions(+), 210 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.test.ts create mode 100644 packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.vue create mode 100644 packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.test.ts create mode 100644 packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.vue diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 4dbce6c2b0..696d5b93b6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 view their version (in new tab).

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", diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.test.ts new file mode 100644 index 0000000000..f581ce978c --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.test.ts @@ -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(); + return { + ...original, + N8nActionDropdown: { + name: 'N8nActionDropdown', + props: { + items: { type: Array, required: true }, + placement: { type: String }, + }, + emits: ['select'], + template: ` +
+
+ +
+
    +
  • + +
  • +
+
+ `, + }, + N8nText: { + name: 'N8nText', + props: { + color: { type: String }, + size: { type: String }, + }, + template: '', + }, + N8nIcon: { + name: 'N8nIcon', + props: { + icon: { type: String, required: true }, + size: { type: String }, + color: { type: String }, + }, + template: '', + }, + }; +}); + +// Mock element-plus components +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ElRadio: { + name: 'ElRadio', + props: { + modelValue: {}, + label: {}, + disabled: { type: Boolean }, + }, + emits: ['update:model-value'], + template: ` + + `, + }, + ElTooltip: { + name: 'ElTooltip', + template: '
', + }, + }; +}); + +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 = { + '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> = [ + { 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; + +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 = { + ...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)['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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.vue new file mode 100644 index 0000000000..591c13e45a --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersRoleCell.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.test.ts new file mode 100644 index 0000000000..4c8cc32331 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.test.ts @@ -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(); + 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: ` +
+ + + + + + + + + + + +
+ {{ header.title }} +
+ +
+
Loading...
+
+ `, + }, + N8nUserInfo: { + name: 'N8nUserInfo', + props: { + firstName: { type: String }, + lastName: { type: String }, + email: { type: String }, + isPendingUser: { type: Boolean }, + }, + template: ` +
+ {{ firstName }} {{ lastName }} + {{ email }} +
+ `, + }, + N8nText: { + name: 'N8nText', + props: { + color: { type: String }, + }, + template: '', + }, + }; +}); + +// 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: ` +
+ +
+ `, + }, +})); + +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; + +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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.vue new file mode 100644 index 0000000000..cf25071ed3 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectMembersTable.vue @@ -0,0 +1,129 @@ + + + diff --git a/packages/frontend/editor-ui/src/types/projects.types.ts b/packages/frontend/editor-ui/src/types/projects.types.ts index c1280daa26..5e400a8ea2 100644 --- a/packages/frontend/editor-ui/src/types/projects.types.ts +++ b/packages/frontend/editor-ui/src/types/projects.types.ts @@ -13,7 +13,13 @@ export type ProjectType = ProjectTypeKeys[keyof ProjectTypeKeys]; export type ProjectRelation = Pick & { 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; diff --git a/packages/frontend/editor-ui/src/utils/typeGuards.ts b/packages/frontend/editor-ui/src/utils/typeGuards.ts index 2c8cc41af9..d54a33ba4d 100644 --- a/packages/frontend/editor-ui/src/utils/typeGuards.ts +++ b/packages/frontend/editor-ui/src/utils/typeGuards.ts @@ -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'; +} diff --git a/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts b/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts index ba0bd75dc3..41d3f62ac6 100644 --- a/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts +++ b/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts @@ -1,35 +1,151 @@ -import { within } from '@testing-library/vue'; -import { createPinia, setActivePinia } from 'pinia'; +import { defineComponent, nextTick, reactive } from 'vue'; import userEvent from '@testing-library/user-event'; +import { createTestingPinia } from '@pinia/testing'; import { createComponentRenderer } from '@/__tests__/render'; -import { getDropdownItems } from '@/__tests__/utils'; -import { useRouter } from 'vue-router'; +import { + getDropdownItems, + mockedStore, + type MockedStore, + useEmitters, + type Emitter, + waitAllPromises, +} from '@/__tests__/utils'; import ProjectSettings from '@/views/ProjectSettings.vue'; import { useProjectsStore } from '@/stores/projects.store'; import { VIEWS } from '@/constants'; -import { useUsersStore } from '@/stores/users.store'; -import { createProjectListItem } from '@/__tests__/data/projects'; -import { useSettingsStore } from '@/stores/settings.store'; -import type { FrontendSettings } from '@n8n/api-types'; +import type { Project } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types'; +import { createProjectListItem } from '@/__tests__/data/projects'; +import { createUser } from '@/__tests__/data/users'; +import { useUsersStore } from '@/stores/users.store'; +import { useSettingsStore } from '@/stores/settings.store'; import { useRolesStore } from '@/stores/roles.store'; +import type { FrontendSettings } from '@n8n/api-types'; -vi.mock('vue-router', () => { - const params = {}; - const push = vi.fn(); +const mockTrack = vi.fn(); +const mockShowMessage = vi.fn(); +const mockShowError = vi.fn(); +const mockRouterPush = vi.fn(); +const { emitters, addEmitter } = useEmitters< + 'projectMembersTable' | 'n8nUserSelect' | 'n8nIconPicker' +>(); + +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ + track: mockTrack, + }), +})); + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + showMessage: mockShowMessage, + showError: mockShowError, + }), +})); + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); return { - useRoute: () => ({ - params, + ...actual, + useRouter: vi.fn(() => ({ + currentRoute: { + value: { + params: {}, + }, + }, + push: mockRouterPush, + })), + useRoute: () => + reactive({ + params: {}, + query: {}, + }), + }; +}); + +// Stub child components to simplify event-driven testing +vi.mock('@/components/Projects/ProjectMembersTable.vue', () => ({ + default: defineComponent({ + name: 'ProjectMembersTableStub', + props: { + data: { type: Object, required: true }, + currentUserId: { type: String, required: false }, + projectRoles: { type: Array, required: true }, + }, + emits: ['update:options', 'update:role'], + setup(_, { emit }) { + addEmitter('projectMembersTable', emit as unknown as Emitter); + return {}; + }, + data() { + return { + tableOptions: { + page: 0, + itemsPerPage: 10, + sortBy: [] as Array<{ id: string; desc: boolean }>, + }, + }; + }, + template: + '
' + + '
{{ data.items.length }}
' + + '
{{ tableOptions.page }}
' + + '
', + }), +})); + +vi.mock('@n8n/design-system', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + N8nInput: defineComponent({ + name: 'N8nInputStub', + props: { modelValue: { type: String, required: false } }, + emits: ['update:model-value'], + template: + '
', }), - useRouter: () => ({ - push, + N8nUserSelect: defineComponent({ + name: 'N8nUserSelectStub', + props: { + users: { type: Array, required: true }, + currentUserId: { type: String, required: false }, + placeholder: { type: String, required: false }, + }, + emits: ['update:model-value'], + setup(_, { emit }) { + addEmitter('n8nUserSelect', emit as unknown as Emitter); + return {}; + }, + template: '
', + }), + 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: '
', }), - 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; -let projectsStore: ReturnType; -let usersStore: ReturnType; -let settingsStore: ReturnType; -let rolesStore: ReturnType; +let projectsStore: MockedStore; +let usersStore: MockedStore; +let settingsStore: MockedStore; +let rolesStore: MockedStore; describe('ProjectSettings', () => { beforeEach(() => { - const pinia = createPinia(); - setActivePinia(pinia); - router = useRouter(); - projectsStore = useProjectsStore(); - usersStore = useUsersStore(); - settingsStore = useSettingsStore(); - rolesStore = useRolesStore(); + mockTrack.mockClear(); + mockShowMessage.mockClear(); + mockShowError.mockClear(); + mockRouterPush.mockClear(); - vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve()); - vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {}); - vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects); - vi.spyOn(projectsStore, 'isProjectEmpty').mockResolvedValue(false); - vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({ + mockShowMessage.mockReturnValue({ id: 'test', close: vi.fn() }); + createTestingPinia(); + + projectsStore = mockedStore(useProjectsStore); + usersStore = mockedStore(useUsersStore); + settingsStore = mockedStore(useSettingsStore); + rolesStore = mockedStore(useRolesStore); + + settingsStore.settings = { enterprise: { projects: { - team: { - limit: -1, - }, + team: { limit: -1 }, }, }, - folders: { - enabled: false, - }, - } as FrontendSettings); - vi.spyOn(rolesStore, 'processedProjectRoles', 'get').mockReturnValue([ + folders: { enabled: false }, + } as FrontendSettings; + + rolesStore.processedProjectRoles = [ { slug: 'project:admin', - displayName: 'Project Admin', - description: 'Can manage project settings', + displayName: 'Admin', + description: null, + systemRole: false, + roleType: 'project' as const, + scopes: [], licensed: true, - roleType: 'project', - scopes: ['project:read', 'project:write', 'project:delete'], - systemRole: true, }, { slug: 'project:editor', - displayName: 'Project Editor', - description: 'Can edit project settings', + displayName: 'Editor', + description: null, + systemRole: false, + roleType: 'project' as const, + scopes: [], licensed: true, - roleType: 'project', - scopes: ['project:read', 'project:write'], - systemRole: true, }, { - slug: 'project:custom', - displayName: 'Custom', - description: 'Can do some custom actions', - licensed: true, - roleType: 'project', - scopes: ['workflow:list'], + slug: 'project:viewer', + displayName: 'Viewer', + description: null, systemRole: false, + roleType: 'project' as const, + scopes: [], + licensed: true, }, - ]); - projectsStore.setCurrentProject({ + ]; + + const mockProject: Project = { id: '123', - type: 'team', name: 'Test Project', - icon: { type: 'icon', value: 'folder' }, + description: '', + type: 'team', + icon: { type: 'icon', value: 'layers' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), relations: [ { id: '1', - lastName: 'Doe', firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', role: 'project:admin', - email: 'admin@example.com', }, ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - scopes: [], + scopes: ['project:read'], + }; + + projectsStore.currentProject = mockProject; + projectsStore.currentProjectId = mockProject.id; + projectsStore.availableProjects = projects; + + usersStore.allUsers = [ + createUser({ id: '1', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }), + createUser({ id: '2', firstName: 'Jane', lastName: 'Roe', email: 'jane@example.com' }), + createUser({ + id: 'current-user', + firstName: 'Current', + lastName: 'User', + email: 'current@example.com', + }), + ]; + usersStore.usersById = Object.fromEntries(usersStore.allUsers.map((u) => [u.id, u])); + + usersStore.currentUser = { + id: 'current-user', + firstName: 'Current', + lastName: 'User', + email: 'current@example.com', + isDefaultUser: false, + isPending: false, + isPendingUser: false, + mfaEnabled: false, + signInType: 'email', + }; + }); + + it('deletes project with transfer or wipe based on modal selection', async () => { + projectsStore.deleteProject.mockResolvedValue(); + projectsStore.isProjectEmpty.mockResolvedValue(false); + + const r1 = renderComponent(); + const deleteButton = r1.getByTestId('project-settings-delete-button'); + + // Case 1: Non-empty project, transfer to another project + await userEvent.click(deleteButton); + await nextTick(); + expect(r1.getByTestId('project-settings-delete-confirm-button')).toBeInTheDocument(); + + const transferRadio = document.querySelector('input[value="transfer"]') as HTMLInputElement; + await userEvent.click(transferRadio); + await nextTick(); + + const transferSelect = r1.getByTestId('project-sharing-select'); + const transferOptions = await getDropdownItems(transferSelect); + await userEvent.click(transferOptions[0]); + await nextTick(); + + await userEvent.click(r1.getByTestId('project-settings-delete-confirm-button')); + await waitAllPromises(); + expect(projectsStore.deleteProject).toHaveBeenCalledWith('123', expect.any(String)); + expect(mockRouterPush).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + + // Case 2: Empty project, wiping directly (no transfer) + projectsStore.deleteProject.mockClear(); + projectsStore.isProjectEmpty.mockResolvedValue(true); + // unmount previous instance and re-render to reset dialog internal state + r1.unmount(); + const r2 = renderComponent(); + const deleteButton2 = r2.getByTestId('project-settings-delete-button'); + await userEvent.click(deleteButton2); + await nextTick(); + await userEvent.click(r2.getByTestId('project-settings-delete-confirm-button')); + await waitAllPromises(); + expect(projectsStore.deleteProject).toHaveBeenCalledWith('123', undefined); + }); + + it('renders core form elements and initializes state', async () => { + const { getByTestId } = renderComponent(); + await nextTick(); + expect(getByTestId('project-settings-container')).toBeInTheDocument(); + const nameInput = getByTestId('project-settings-name-input'); + const descriptionInput = getByTestId('project-settings-description-input'); + const saveButton = getByTestId('project-settings-save-button'); + const cancelButton = getByTestId('project-settings-cancel-button'); + expect(nameInput).toBeInTheDocument(); + expect(descriptionInput).toBeInTheDocument(); + expect(saveButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + const actualName = getInput(nameInput); + const actualDesc = getTextarea(descriptionInput); + expect(actualName.value).toBe('Test Project'); + expect(actualDesc.value).toBe(''); + }); + + describe('Form interactions', () => { + it('marks dirty, cancels reset, and saves via Enter and button', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + const { getByTestId } = renderComponent(); + const nameInput = getByTestId('project-settings-name-input'); + const saveButton = getByTestId('project-settings-save-button'); + const cancelButton = getByTestId('project-settings-cancel-button'); + const actualInput = getInput(nameInput); + + // Dirty then cancel + await userEvent.type(actualInput, ' Extra'); + expect(cancelButton).toBeEnabled(); + await userEvent.click(cancelButton); + expect(actualInput.value).toBe('Test Project'); + expect(cancelButton).toBeDisabled(); + + // Save via Enter + await userEvent.type(actualInput, ' - Updated'); + await userEvent.type(actualInput, '{enter}'); + await nextTick(); + expect(updateSpy).toHaveBeenCalledWith( + '123', + expect.objectContaining({ name: 'Test Project - Updated', description: '' }), + ); + expect(mockShowMessage).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'User changed project name', + expect.objectContaining({ project_id: '123', name: 'Test Project - Updated' }), + ); + + // Save via button + await userEvent.type(actualInput, ' Again'); + expect(saveButton).toBeEnabled(); + await userEvent.click(saveButton); + await nextTick(); + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(mockShowMessage).toHaveBeenCalledTimes(2); }); }); - it('should show confirmation modal before deleting project and delete with transfer', async () => { - const deleteProjectSpy = vi - .spyOn(projectsStore, 'deleteProject') - .mockImplementation(async () => {}); + describe('Validation and error handling', () => { + it('prevents invalid save and shows error on failed save', async () => { + const error = new Error('Save failed'); + projectsStore.updateProject.mockRejectedValue(error); + const { getByTestId } = renderComponent(); + const nameInput = getByTestId('project-settings-name-input'); + const saveButton = getByTestId('project-settings-save-button'); + const actualInput = getInput(nameInput); - const { getByTestId, findByRole } = renderComponent(); - const deleteButton = getByTestId('project-settings-delete-button'); + await userEvent.type(actualInput, ' Updated'); + await userEvent.type(actualInput, '{enter}'); + await nextTick(); + expect(projectsStore.updateProject).toHaveBeenCalled(); + expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String)); + expect(mockShowMessage).not.toHaveBeenCalled(); - await userEvent.click(deleteButton); - expect(deleteProjectSpy).not.toHaveBeenCalled(); - const modal = await findByRole('dialog'); - expect(modal).toBeVisible(); - const confirmButton = getByTestId('project-settings-delete-confirm-button'); - expect(confirmButton).toBeDisabled(); - - await userEvent.click(within(modal).getAllByRole('radio')[0]); - const projectSelect = getByTestId('project-sharing-select'); - const projectSelectDropdownItems = await getDropdownItems(projectSelect); - await userEvent.click(projectSelectDropdownItems[0]); - expect(confirmButton).toBeEnabled(); - - await userEvent.click(confirmButton); - expect(deleteProjectSpy).toHaveBeenCalledWith('123', expect.any(String)); - expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + projectsStore.updateProject.mockClear(); + await userEvent.clear(actualInput); + expect(saveButton).toBeDisabled(); + await userEvent.click(saveButton); + expect(projectsStore.updateProject).not.toHaveBeenCalled(); + }); }); - it('should show confirmation modal before deleting project and deleting without transfer', async () => { - const deleteProjectSpy = vi - .spyOn(projectsStore, 'deleteProject') - .mockImplementation(async () => {}); + describe('Save state and validation', () => { + it('maintains state after save and validation toggles', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + const { getByTestId } = renderComponent(); + const nameInput = getByTestId('project-settings-name-input'); + const cancelButton = getByTestId('project-settings-cancel-button'); + const saveButton = getByTestId('project-settings-save-button'); + const actualInput = getInput(nameInput); - const { getByTestId, findByRole } = renderComponent(); - const deleteButton = getByTestId('project-settings-delete-button'); - - await userEvent.click(deleteButton); - expect(deleteProjectSpy).not.toHaveBeenCalled(); - const modal = await findByRole('dialog'); - expect(modal).toBeVisible(); - const confirmButton = getByTestId('project-settings-delete-confirm-button'); - expect(confirmButton).toBeDisabled(); - - await userEvent.click(within(modal).getAllByRole('radio')[1]); - const input = within(modal).getByRole('textbox'); - - await userEvent.type(input, 'delete all '); - expect(confirmButton).toBeDisabled(); - - await userEvent.type(input, 'data'); - expect(confirmButton).toBeEnabled(); - - await userEvent.click(confirmButton); - expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined); - expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + await userEvent.clear(actualInput); + expect(saveButton).toBeDisabled(); + await userEvent.type(actualInput, 'Valid Project Name'); + expect(saveButton).toBeEnabled(); + expect(cancelButton).toBeEnabled(); + await userEvent.click(saveButton); + await nextTick(); + expect(updateSpy).toHaveBeenCalled(); + expect(cancelButton).toBeDisabled(); + expect(saveButton).toBeDisabled(); + }); }); - it('should show role dropdown', async () => { - const { getByTestId } = renderComponent(); - const roleDropdown = getByTestId('projects-settings-user-role-select'); - expect(roleDropdown).toBeVisible(); - const roleDropdownItems = await getDropdownItems(roleDropdown); - expect(roleDropdownItems).toHaveLength(3); - expect(roleDropdownItems[0]).toHaveTextContent('Admin'); - expect(roleDropdownItems[1]).toHaveTextContent('Editor'); - expect(roleDropdownItems[2]).toHaveTextContent('Custom'); + describe('Members table and role updates', () => { + it('adds a member and saves with telemetry', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + const { getByTestId } = renderComponent(); + await nextTick(); + + // Initially one member + expect(getByTestId('members-count').textContent).toBe('1'); + + // Add member via user select + emitters.n8nUserSelect.emit('update:model-value', '2'); + await nextTick(); + expect(getByTestId('members-count').textContent).toBe('2'); + + // Ensure form valid + dirty; name keystroke triggers validate and enables save + const nameInput = getByTestId('project-settings-name-input'); + await userEvent.type(nameInput.querySelector('input')!, ' '); + await userEvent.click(getByTestId('project-settings-save-button')); + await nextTick(); + + expect(updateSpy).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'User added member to project', + expect.objectContaining({ project_id: '123', target_user_id: '2' }), + ); + }); + it('filters members via search and saves', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + const { getByTestId } = renderComponent(); + await nextTick(); + expect(getByTestId('members-count').textContent).toBe('1'); + const searchContainer = getByTestId('project-members-search'); + const searchInput = searchContainer.querySelector('input')!; + await userEvent.type(searchInput, 'john@example.com'); + await new Promise((r) => setTimeout(r, 350)); + expect(getByTestId('members-count').textContent).toBe('1'); + // Make a minor change to mark the form dirty so save is enabled + const nameInput = getByTestId('project-settings-name-input'); + await userEvent.type(nameInput.querySelector('input')!, ' '); + await userEvent.click(getByTestId('project-settings-save-button')); + await nextTick(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('inline role change saves immediately with telemetry', async () => { + projectsStore.updateProject.mockResolvedValue(undefined); + renderComponent(); + await nextTick(); + + emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:editor' }); + await nextTick(); + expect(projectsStore.updateProject).toHaveBeenCalledWith( + '123', + expect.objectContaining({ + relations: expect.arrayContaining([ + expect.objectContaining({ userId: '1', role: 'project:editor' }), + ]), + }), + ); + expect(mockShowMessage).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'User changed member role on project', + expect.objectContaining({ project_id: '123', target_user_id: '1', role: 'project:editor' }), + ); + }); + + it('rolls back role on save error', async () => { + // First, inline update fails and rolls back + projectsStore.updateProject.mockRejectedValueOnce(new Error('fail')); + const utils = renderComponent(); + await nextTick(); + emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:viewer' }); + await nextTick(); + expect(mockShowError).toHaveBeenCalled(); + + // Next form save should contain original role (admin) due to rollback + const nameInput = utils.getByTestId('project-settings-name-input'); + await userEvent.type(getInput(nameInput), ' touch'); + await userEvent.click(utils.getByTestId('project-settings-save-button')); + await nextTick(); + const lastCall = projectsStore.updateProject.mock.calls.pop(); + expect(lastCall?.[1].relations).toEqual( + expect.arrayContaining([expect.objectContaining({ userId: '1', role: 'project:admin' })]), + ); + }); + + it('marks member for removal, filters it out, and saves without it with telemetry', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + const { getByTestId, queryByTestId } = renderComponent(); + await nextTick(); + expect(getByTestId('members-count').textContent).toBe('1'); + + // Mark for removal + emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'remove' }); + await nextTick(); + // Pending removal should hide members table container entirely + expect(queryByTestId('members-count')).toBeNull(); + + await userEvent.click(getByTestId('project-settings-save-button')); + await nextTick(); + expect(updateSpy).toHaveBeenCalled(); + const payload = updateSpy.mock.calls[0][1]; + expect(payload.relations).toEqual([]); + expect(mockTrack).toHaveBeenCalledWith( + 'User removed member from project', + expect.objectContaining({ project_id: '123', target_user_id: '1' }), + ); + }); + + it('prevents saving when invalid role selected', async () => { + // Set invalid role and try to save + const utils = renderComponent(); + await nextTick(); + emitters.projectMembersTable.emit('update:role', { + userId: '1', + role: 'project:personalOwner', + }); + await nextTick(); + // Clear prior success toast from inline update (if any) + mockShowMessage.mockClear(); + // Mark form dirty so save is enabled + const nameInput = utils.getByTestId('project-settings-name-input'); + await userEvent.type(nameInput.querySelector('input')!, ' '); + await userEvent.click(utils.getByTestId('project-settings-save-button')); + await nextTick(); + expect(mockShowError).toHaveBeenCalled(); + // Should not show success on invalid role + expect(mockShowMessage).not.toHaveBeenCalled(); + }); + + it('resets pagination to first page on search', async () => { + const utils = renderComponent(); + await nextTick(); + emitters.projectMembersTable.emit('update:options', { + page: 2, + itemsPerPage: 10, + sortBy: [], + }); + await nextTick(); + const searchContainer = utils.getByTestId('project-members-search'); + const searchInput = searchContainer.querySelector('input')!; + await userEvent.type(searchInput, 'john'); + await new Promise((r) => setTimeout(r, 350)); + // unmount first to avoid duplicate elements + utils.unmount(); + const utils2 = renderComponent(); + expect(utils2.getByTestId('members-page').textContent).toBe('0'); + }); + }); + + describe('Icon updates', () => { + it('updates project icon and shows success toast', async () => { + const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined); + renderComponent(); + await nextTick(); + emitters.n8nIconPicker.emit('update:model-value', { type: 'icon', value: 'zap' }); + await nextTick(); + await waitAllPromises(); + expect(updateSpy).toHaveBeenCalled(); + expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + }); + + describe('Lifecycle', () => { + it('reacts to project change and keeps inputs bound', async () => { + const { getByTestId } = renderComponent(); + const nameInput = getByTestId('project-settings-name-input'); + const descInput = getByTestId('project-settings-description-input'); + const actualName = getInput(nameInput); + const actualDesc = descInput.querySelector('textarea')!; + expect(actualName.value).toBe('Test Project'); + expect(actualDesc.value).toBe(''); + const updatedProject = { + ...projectsStore.currentProject!, + name: 'Updated Project', + description: 'Updated description', + }; + projectsStore.setCurrentProject(updatedProject); + await nextTick(); + expect(nameInput).toBeInTheDocument(); + expect(descInput).toBeInTheDocument(); + expect(getByTestId('project-members-select')).toBeInTheDocument(); + }); }); }); diff --git a/packages/frontend/editor-ui/src/views/ProjectSettings.vue b/packages/frontend/editor-ui/src/views/ProjectSettings.vue index 7ef85c9895..e68332df72 100644 --- a/packages/frontend/editor-ui/src/views/ProjectSettings.vue +++ b/packages/frontend/editor-ui/src/views/ProjectSettings.vue @@ -1,9 +1,10 @@ - - - +
+ + + + +
@@ -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); diff --git a/packages/testing/playwright/CONTRIBUTING.md b/packages/testing/playwright/CONTRIBUTING.md index 31cefb088e..6079ef4db7 100644 --- a/packages/testing/playwright/CONTRIBUTING.md +++ b/packages/testing/playwright/CONTRIBUTING.md @@ -343,17 +343,17 @@ test('should create workflow via API, activate it, trigger webhook externally @a const workflowDefinition = JSON.parse( readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'), ); - + const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition); await api.workflowApi.setActive(createdWorkflow.id, true); - + const testPayload = { message: 'Hello from Playwright test' }; const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { data: testPayload }); expect(webhookResponse.ok()).toBe(true); - + const execution = await api.workflowApi.waitForExecution(createdWorkflow.id, 10000); expect(execution.status).toBe('success'); - + const executionDetails = await api.workflowApi.getExecution(execution.id); expect(executionDetails.data).toContain('Hello from Playwright test'); }); @@ -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() { diff --git a/packages/testing/playwright/pages/ProjectSettingsPage.ts b/packages/testing/playwright/pages/ProjectSettingsPage.ts index 81885b5382..9a957ebf69 100644 --- a/packages/testing/playwright/pages/ProjectSettingsPage.ts +++ b/packages/testing/playwright/pages/ProjectSettingsPage.ts @@ -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(); + } } diff --git a/packages/testing/playwright/tests/ui/39-projects.spec.ts b/packages/testing/playwright/tests/ui/39-projects.spec.ts index ca8e8e9f21..0761f1fe65 100644 --- a/packages/testing/playwright/tests/ui/39-projects.spec.ts +++ b/packages/testing/playwright/tests/ui/39-projects.spec.ts @@ -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); + }); + }); });