Skip to content

Commit

Permalink
[CORE-5] Add Multiple Users to Billing Projects, Groups and Workspaces (
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinmarete authored Dec 19, 2024
1 parent 3be28ca commit ac4c49a
Show file tree
Hide file tree
Showing 17 changed files with 718 additions and 147 deletions.
46 changes: 28 additions & 18 deletions src/billing/Members/Members.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { screen, within } from '@testing-library/react';
import { fireEvent, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import React from 'react';
import { Members } from 'src/billing/Members/Members';
import { EmailSelect } from 'src/groups/Members/EmailSelect';
import { Member } from 'src/groups/Members/MemberTable';
import { Billing, BillingContract } from 'src/libs/ajax/billing/Billing';
import { Groups, GroupsContract } from 'src/libs/ajax/Groups';
Expand Down Expand Up @@ -81,8 +82,8 @@ describe('Members', () => {
const projectUsers: Member[] = [{ email: '[email protected]', roles: ['Owner'] }];
const user = userEvent.setup();

const addProjectUser: MockedFn<BillingContract['addProjectUser']> = jest.fn();
asMockedFn(Billing).mockReturnValue(partial<BillingContract>({ addProjectUser }));
const addProjectUsers: MockedFn<BillingContract['addProjectUsers']> = jest.fn();
asMockedFn(Billing).mockReturnValue(partial<BillingContract>({ addProjectUsers }));
// Next 2 mocks are needed for suggestions in the NewUserModal.
asMockedFn(Workspaces).mockReturnValue(
partial<WorkspacesAjaxContract>({
Expand All @@ -97,6 +98,17 @@ describe('Members', () => {

const userAddedCallback = jest.fn();

const defaultProps = {
label: 'User emails',
placeholder: 'Test emails',
isMulti: true,
isClearable: true,
isSearchable: true,
options: ['[email protected]', '[email protected]'],
emails: ['[email protected]'],
setEmails: jest.fn(),
};

// Act
renderWithAppContexts(
<Members
Expand All @@ -108,25 +120,23 @@ describe('Members', () => {
deleteMember={jest.fn()}
/>
);
// Open add user dialog
const addUserButton = screen.getByText('Add User');
renderWithAppContexts(<EmailSelect {...defaultProps} />);
// Open add users dialog
const addUserButton = screen.getByText('Add Users');
await user.click(addUserButton);
// Both the combobox and the input field have the same label (which is not ideal, but already existing),
// so we need to select the second one.
const emailInput = screen.getAllByLabelText('User email *')[1];
await user.type(emailInput, '[email protected]');
// Save button ("Add User") within the dialog, as opposed to the one that opened the dialog.
const saveButton = within(screen.getByRole('dialog')).getByText('Add User');
await user.click(saveButton);
// Get the email select and type in a user email
const emailSelect = screen.getByLabelText(defaultProps.placeholder);
fireEvent.change(emailSelect, { target: { value: '[email protected]' } });
fireEvent.keyDown(emailSelect, { key: 'Enter', code: 'Enter' });
// Save button ("Add Users") within the dialog, as opposed to the one that opened the dialog.
const saveButton = within(screen.getByRole('dialog')).getByText('Add Users');
fireEvent.click(saveButton);

// Assert
expect(userAddedCallback).toHaveBeenCalled();
expect(addProjectUser).toHaveBeenCalledWith('test-project', ['User'], '[email protected]');

// The actual display of the dialog to add a user is done in the parent file.
expect(emailSelect).toHaveValue('[email protected]');
});

it('does not show the Add User button for non-owners', async () => {
it('does not show the Add Users button for non-owners', async () => {
// Arrange
const projectUsers: Member[] = [{ email: '[email protected]', roles: ['Owner'] }];

Expand All @@ -143,7 +153,7 @@ describe('Members', () => {
);

// Assert
expect(screen.queryByText('Add User')).toBeNull();
expect(screen.queryByText('Add Users')).toBeNull();
});

it('disables the action menu for an owner if there are not multiple owners', async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/billing/Members/Members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ export const Members = (props: MembersProps): ReactNode => {
<NewMemberModal
adminLabel={billingRoles.owner}
memberLabel={billingRoles.user}
title='Add user to Billing Project'
title='Add users to Billing Project'
footer={[
'Warning: Adding any user to this project will mean they can incur costs to the billing associated with this project.',
]}
addFunction={(roles: string[], email: string) =>
Billing().addProjectUser(billingProjectName, roles as BillingRole[], email)
addFunction={(roles: string[], emails: string[]) =>
Billing().addProjectUsers(billingProjectName, roles as BillingRole[], emails)
}
onDismiss={() => setAddingMember(false)}
onSuccess={() => {
Expand Down
102 changes: 102 additions & 0 deletions src/billing/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { currencyStringToFloat, parseCurrencyIfNeeded } from 'src/billing/utils';
import { validateUserEmails } from 'src/billing/utils';
import validate from 'validate.js';

describe('currencyStringToFloat', () => {
test('should correctly parse European format (€1.234,56) to float', () => {
Expand Down Expand Up @@ -55,3 +57,103 @@ describe('parseCurrencyIfNeeded', () => {
expect(parseCurrencyIfNeeded('totalSpend', '...')).toBe('...');
});
});

describe('emailArray validator', () => {
it('should return an error message if the value is not an array', () => {
// Arrange
const value = 'not an array';
const options = { message: 'must be an array' };
const key = 'userEmails';

// Act
const result = validate.validators.emailArray(value, options, key);

// Assert
expect(result).toBe('must be an array');
});

it('should return an error message if the array is empty', () => {
// Arrange
const value: string[] = [];
const options = { emptyMessage: 'cannot be empty' };
const key = 'userEmails';

// Act
const result = validate.validators.emailArray(value, options, key);

// Assert
expect(result).toBe('cannot be empty');
});

it('should return an error message if any email is invalid', () => {
// Arrange
const value = ['[email protected]', 'invalid-email'];
const options = {};
const key = 'userEmails';

// Act
const result = validate.validators.emailArray(value, options, key);

// Assert
expect(result).toBe('^Invalid email(s): invalid-email');
});

it('should return null if all emails are valid', () => {
// Arrange
const value = ['[email protected]', '[email protected]'];
const options = {};
const key = 'userEmails';

// Act
const result = validate.validators.emailArray(value, options, key);

// Assert
expect(result).toBeNull();
});
});

describe('validateUserEmails', () => {
it('should return an error if userEmails is not an array', () => {
// Arrange
const userEmails = 'not an array' as any;

// Act
const result = validateUserEmails(userEmails);

// Assert
expect(result).toEqual({ userEmails: ['All inputs must be valid email addresses.'] });
});

it('should return an error if userEmails array is empty', () => {
// Arrange
const userEmails: string[] = [];

// Act
const result = validateUserEmails(userEmails);

// Assert
expect(result).toEqual({ userEmails: ['User emails cannot be empty.'] });
});

it('should return an error if any email in userEmails array is invalid', () => {
// Arrange
const userEmails = ['[email protected]', 'invalid-email'];

// Act
const result = validateUserEmails(userEmails);

// Assert
expect(result).toEqual({ userEmails: ['Invalid email(s): invalid-email'] });
});

it('should return undefined if all emails in userEmails array are valid', () => {
// Arrange
const userEmails = ['[email protected]', '[email protected]'];

// Act
const result = validateUserEmails(userEmails);

// Assert
expect(result).toBeUndefined();
});
});
34 changes: 34 additions & 0 deletions src/billing/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { IconProps } from '@terra-ui-packages/components';
import _ from 'lodash/fp';
import { BillingProject } from 'src/billing-core/models';
import colors from 'src/libs/colors';
import validate from 'validate.js';

export const billingRoles = {
owner: 'Owner',
Expand Down Expand Up @@ -99,3 +101,35 @@ export const parseCurrencyIfNeeded = (field: string, value: string | undefined):
// Return original value for non-currency fields
return value;
};

// Custom validator for an array of emails
validate.validators.emailArray = (value: string[], options: { message: string; emptyMessage: string }, key: any) => {
if (!Array.isArray(value)) {
return options.message || `^${key} must be an array.`;
}

if (value.length === 0) {
return options.emptyMessage || `^${key} cannot be empty.`;
}

const errors = _.flow(
_.map((email: string) => (validate.single(email, { email: true, presence: true }) ? email : null)),
_.filter(Boolean)
)(value);

return errors.length ? `^Invalid email(s): ${errors.join(', ')}` : null;
};

export const validateUserEmails = (userEmails: string[]) => {
return validate(
{ userEmails },
{
userEmails: {
emailArray: {
message: '^All inputs must be valid email addresses.',
emptyMessage: '^User emails cannot be empty.',
},
},
}
);
};
6 changes: 3 additions & 3 deletions src/groups/GroupDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ export const GroupDetails = (props: GroupDetailsProps) => {
<NewMemberModal
adminLabel='admin'
memberLabel='member'
title='Add user to Terra Group'
title='Add users to Terra Group'
addUnregisteredUser
addFunction={(roles: string[], email: string) =>
addFunction={(roles: string[], emails: string[]) =>
Groups()
.group(groupName)
.addUser(roles as GroupRole[], email)
.addUsers(roles as GroupRole[], emails)
}
onDismiss={() => setAddingNewMember(false)}
onSuccess={refresh}
Expand Down
95 changes: 95 additions & 0 deletions src/groups/Members/EmailSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { renderWithAppContexts as render } from 'src/testing/test-utils';

import { EmailSelect } from './EmailSelect';

describe('EmailSelect', () => {
const defaultProps = {
label: 'User emails',
placeholder: 'Type or select user emails',
isMulti: true,
isClearable: true,
isSearchable: true,
options: ['[email protected]', '[email protected]'],
emails: ['[email protected]'],
setEmails: jest.fn(),
};

it('renders the component with default props', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);

// Act
const input = screen.getByLabelText(defaultProps.placeholder);
const label = screen.getByText('User emails *');

// Assert
expect(input).toBeInTheDocument();
expect(label).toBeInTheDocument();
});

it('calls setEmails when an email is selected', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);
const input = screen.getByLabelText(defaultProps.placeholder);

// Act
fireEvent.change(input, { target: { value: '[email protected]' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });

// Assert
expect(defaultProps.setEmails).toHaveBeenCalledWith(['[email protected]', '[email protected]']);
});

it('calls setEmails when an email is removed', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);
const input = screen.getByLabelText(defaultProps.placeholder);

// Act
fireEvent.keyDown(input, { key: 'Backspace', code: 'Backspace' });

// Assert
expect(defaultProps.setEmails).toHaveBeenCalledWith([]);
});

it('renders the correct number of selected options', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);
const input = screen.getByLabelText(defaultProps.placeholder);

// Act
fireEvent.focus(input);
const options = screen.getAllByRole('button'); // Each selected option has a remove button

// Assert
expect(options).toHaveLength(defaultProps.emails.length);
});

it('updates searchValue on input change', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);
const input = screen.getByLabelText(defaultProps.placeholder);

// Act
fireEvent.change(input, { target: { value: '[email protected]' } });

// Assert
// @ts-ignore
expect(input.value).toBe('[email protected]');
});

it('saves searchValue to emails on blur', () => {
// Arrange
render(<EmailSelect {...defaultProps} />);
const input = screen.getByLabelText(defaultProps.placeholder);

// Act
fireEvent.change(input, { target: { value: '[email protected]' } });
fireEvent.blur(input);

// Assert
expect(defaultProps.setEmails).toHaveBeenCalledWith(['[email protected]', '[email protected]']);
});
});
Loading

0 comments on commit ac4c49a

Please sign in to comment.