From f906dbaf6337764297de4fd7afaf96e1baebd4a5 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 15 May 2025 07:35:52 +0200 Subject: [PATCH] feat(editor): Include pending users for project users search (#15389) Co-authored-by: Csaba Tuncsik --- .../N8nUserSelect/UserSelect.test.ts | 365 ++++++++++++++++++ .../components/N8nUserSelect/UserSelect.vue | 9 +- 2 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.test.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.test.ts new file mode 100644 index 0000000000..b2dc5c0aed --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.test.ts @@ -0,0 +1,365 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/vue'; + +import N8nUserSelect from '.'; +import { createComponentRenderer } from '../../__tests__/render'; +import type { IUser } from '../../types/user'; + +const renderComponent = createComponentRenderer(N8nUserSelect); + +const getRenderedOptions = async () => { + const dropdown = await waitFor(() => screen.getByRole('listbox')); + expect(dropdown).toBeInTheDocument(); + return dropdown.querySelectorAll('.el-select-dropdown__item'); +}; + +const filterInput = async (filterText: string) => { + const input = screen.getByRole('combobox'); + await userEvent.type(input, filterText); +}; + +const sampleUsers: IUser[] = [ + { + id: 'u1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Smith', + fullName: 'Alice Smith', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u2', + email: 'bob@example.com', + firstName: 'Bob', + lastName: 'Johnson', + fullName: 'Bob Johnson', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u3', + email: 'charlie@example.com', + firstName: 'Charlie', + lastName: 'Brown', + fullName: 'Charlie Brown', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u4', + email: 'dave@example.com', + firstName: 'Dave', + lastName: 'Smith', + fullName: 'Dave Smith', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u5', + email: 'eve@example.com', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u6', + email: 'frank@example.com', + fullName: 'Frank Castle', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'u7', + email: 'gina@example.com', + firstName: 'Gina', + lastName: 'Davis', + fullName: 'Gina Davis', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, +]; + +describe('UserSelect', () => { + it('should render user select with all users (even pending ones)', async () => { + const { getByRole } = renderComponent({ + props: { + users: sampleUsers, + }, + }); + + // ACT + const selectInput = getByRole('combobox'); // Find the select input + expect(selectInput).toBeInTheDocument(); + + // Simulate clicking the select input to open the dropdown + await userEvent.click(selectInput); + + // ASSERT + // Wait for the dropdown to appear in the DOM + const options = await getRenderedOptions(); + expect(options).toHaveLength(sampleUsers.length); + }); + + it('filters users by full name (case-insensitive)', async () => { + renderComponent({ + props: { + users: sampleUsers, + }, + }); + + await filterInput('alice'); + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(1); + expect(options[0]).toHaveAttribute('id', 'user-select-option-id-u1'); + }); + + await userEvent.click(document.body); + + await filterInput('SMITH'); + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(2); // Alice Smith, Dave Smith + expect(Array.from(options).map((o) => o.getAttribute('id'))).toEqual([ + 'user-select-option-id-u1', + 'user-select-option-id-u4', + ]); // Sorted by first name + }); + }); + + it('filters users by email (case-sensitive for filter term, if full name does not match)', async () => { + renderComponent({ + props: { + users: sampleUsers, + }, + }); + + await filterInput('alice@example.com'); + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(1); + expect(options[0]).toHaveAttribute('id', 'user-select-option-id-u1'); + }); + + await userEvent.click(document.body); + + await filterInput('Example.com'); // Email part of filter is case-sensitive in the component's logic + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + await userEvent.click(document.body); + + await filterInput('example.com'); // Matches all users with email containing 'example.com' + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(sampleUsers.length); + }); + }); + + it('filters by full name and email and sorts by last name', async () => { + const specificUsers: IUser[] = [ + { + id: 's1', + email: 'test@email.com', + fullName: 'Alice TestName', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 's2', + email: 'alice@another.com', + fullName: 'Bob Something', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + ]; + renderComponent({ + props: { + users: specificUsers, + }, + }); + await filterInput('alice'); // Should match "Alice TestName" by fullName and "Bob Something" by email + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(2); + expect(Array.from(options).map((o) => o.getAttribute('id'))).toEqual([ + 'user-select-option-id-s2', + 'user-select-option-id-s1', + ]); + }); + }); + + it('excludes users without an email from filtered results', async () => { + const usersWithNoEmail: IUser[] = [ + sampleUsers[0], // Alice + { + id: 'noemail', + fullName: 'No Email User', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + ]; + const { getByRole } = renderComponent({ + props: { + users: usersWithNoEmail, + }, + }); + + const selectInput = getByRole('combobox'); + expect(selectInput).toBeInTheDocument(); + + await userEvent.click(selectInput); + + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(1); + expect(options[0]).toHaveAttribute('id', 'user-select-option-id-u1'); + }); + + await filterInput('No Email User'); // Try to filter by name + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + it('excludes users in ignoreIds from filtered results', async () => { + const { getByRole } = renderComponent({ + props: { + users: sampleUsers, + ignoreIds: ['u1', 'u3'], // Exclude Alice and Bob + }, + }); + + const selectInput = getByRole('combobox'); + expect(selectInput).toBeInTheDocument(); + + await userEvent.click(selectInput); + + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(5); + }); + + await userEvent.click(document.body); + + await filterInput('smith'); // Would normally match Alice Smith (u1) and Dave Smith (u4) + await waitFor(async () => { + const options = await getRenderedOptions(); + expect(options.length).toBe(1); + expect(options[0]).toHaveAttribute('id', 'user-select-option-id-u4'); // Only Dave Smith + }); + }); + + it('sorts users by lastName, then firstName, then email', async () => { + const usersToSort: IUser[] = [ + { + id: 'a', + email: 'zeta@example.com', + firstName: 'Zeta', + lastName: 'Able', + fullName: 'Zeta Able', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'b', + email: 'alpha@example.com', + firstName: 'Alpha', + lastName: 'Baker', + fullName: 'Alpha Baker', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'c', + email: 'beta@example.com', + firstName: 'Beta', + lastName: 'Able', + fullName: 'Beta Able', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'd', + email: 'gamma@example.com', + firstName: 'Gamma', + lastName: 'Able', + fullName: 'Gamma Able', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + { + id: 'e', + email: 'delta@example.com', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, // No names, sort by email + { + id: 'f', + email: 'charlie@example.com', + firstName: 'Charlie', + lastName: 'Baker', + fullName: 'Charlie Baker', + isOwner: true, + isPendingUser: false, + disabled: false, + signInType: 'email', + }, + ]; + const { getByRole } = renderComponent({ + props: { + users: usersToSort, + }, + }); + + const selectInput = getByRole('combobox'); + expect(selectInput).toBeInTheDocument(); + + await userEvent.click(selectInput); + + const dropdown = await waitFor(() => getByRole('listbox')); + expect(dropdown).toBeInTheDocument(); + const options = dropdown.querySelectorAll('.el-select-dropdown__item'); + const sortedIds = Array.from(options).map((option) => option.getAttribute('id')); + + expect(sortedIds).toEqual([ + 'user-select-option-id-c', + 'user-select-option-id-e', + 'user-select-option-id-d', + 'user-select-option-id-a', + 'user-select-option-id-b', + 'user-select-option-id-f', + ]); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.vue b/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.vue index 5c2cd0eaca..e4c289a67d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nUserSelect/UserSelect.vue @@ -34,22 +34,18 @@ const filter = ref(''); const filteredUsers = computed(() => props.users.filter((user) => { - if (user.isPendingUser || !user.email) { - return false; - } - if (props.ignoreIds.includes(user.id)) { return false; } - if (user.fullName) { + if (user.fullName && user.email) { const match = user.fullName.toLowerCase().includes(filter.value.toLowerCase()); if (match) { return true; } } - return user.email.includes(filter.value); + return user.email?.includes(filter.value) ?? false; }), ); @@ -102,6 +98,7 @@ const getLabel = (user: IUser) =>