From 885dec90df37cb3d820013202dc85575f9e441e8 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 20 Aug 2019 15:32:20 -0700 Subject: [PATCH 1/7] Add PendingInvites UI --- .../Admin/Accounts/AccountsTableColumns.jsx | 24 + .../pages/Admin/Accounts/AccountInvites.jsx | 7 - .../pages/Admin/Accounts/PendingInvites.jsx | 244 ++++++++++ .../Accounts/__tests__/PendingInvites.jsx | 439 ++++++++++++++++++ dev-portal/src/pages/Admin/Admin.jsx | 4 +- dev-portal/src/services/accounts.js | 49 ++ dev-portal/src/utils/use-boolean.jsx | 22 + 7 files changed, 780 insertions(+), 9 deletions(-) delete mode 100644 dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx create mode 100644 dev-portal/src/utils/use-boolean.jsx diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 4e3f05842..6099e696e 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -73,6 +73,21 @@ export const Promoter = { }, } +export const Inviter = { + id: 'inviter', + title: 'Inviter', + render: ({ inviterIdentityPoolId, inviterEmailAddress }) => + inviterIdentityPoolId + ? `${inviterEmailAddress} (${inviterIdentityPoolId})` + : '', + filtering: { + accessor: ({ inviterIdentityPoolId, inviterEmailAddress }) => + inviterIdentityPoolId + ? `${inviterEmailAddress} ${inviterIdentityPoolId}` + : '', + }, +} + export const DatePromoted = { id: 'datePromoted', title: 'Date promoted', @@ -91,6 +106,15 @@ export const DateRequested = { }, } +export const DateInvited = { + id: 'dateInvited', + title: 'Date invited', + render: account => formatDate(account.dateInvited), + ordering: { + iteratee: 'dateInvited', + }, +} + const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { year: 'numeric', month: 'numeric', diff --git a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx deleted file mode 100644 index 98209e757..000000000 --- a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, { Component } from 'react' - -export default class AccountInvites extends Component { - render = () => { - return

TODO: Account invites

- } -} diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx new file mode 100644 index 000000000..4f9a233f3 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, + Container, + Header, + Input, + Message, + Modal, +} from 'semantic-ui-react' + +import * as MessageList from 'components/MessageList' +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +import { useBoolean } from 'utils/use-boolean' + +const PendingInvites = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + const [isCreateModalOpen, openCreateModal, closeCreateModal] = useBoolean( + false, + ) + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useBoolean( + false, + ) + const [messages, sendMessage] = MessageList.useMessages() + + const refreshAccounts = () => + AccountService.fetchPendingInviteAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback( + account => setSelectedAccount(account), + [], + ) + + const onConfirmCreate = useCallback( + async emailAddress => { + setLoading(true) + closeCreateModal() + try { + await AccountService.createInviteByEmail(emailAddress) + sendMessage(dismiss => ( + + )) + await refreshAccounts() + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, + [sendMessage, closeCreateModal], + ) + + const onConfirmDelete = useCallback(async () => { + setLoading(true) + closeDeleteModal() + try { + await AccountService.deleteInviteByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage(dismiss => ( + + )) + await refreshAccounts() + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount, closeDeleteModal]) + + return ( + +
Pending invites
+ + + + + + +
+ ) +} +export default PendingInvites + +const TableActions = React.memo( + ({ canCreate, onClickCreate, canDelete, onClickDelete }) => ( + + + + + + ) +} + +const DeleteInviteModal = React.memo( + ({ account, onConfirm, open, onClose }) => + account && ( + + Confirm invite deletion + +

+ Are you sure you want to delete this account invite for{' '} + {account.emailAddress}? This action is + irreversible. +

+
+ + + + +
+ ), +) + +const CreateSuccessMessage = React.memo(({ emailAddress, dismiss }) => ( + + + Sent account invite to {emailAddress}. + + +)) + +const CreateFailureMessage = React.memo( + ({ emailAddress, errorMessage, dismiss }) => ( + + +

+ Failed to send account invite to {emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) + +const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( + + + Deleted account invite for {account.emailAddress}. + + +)) + +const DeleteFailureMessage = React.memo( + ({ account, errorMessage, dismiss }) => ( + + +

+ Failed to delete account invite for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx new file mode 100644 index 000000000..e4204fee1 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -0,0 +1,439 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import * as testUtils from 'utils/test-utils' +import * as accountsTestUtils from 'utils/accounts-test-utils' + +import PendingInvites from 'pages/Admin/Accounts/PendingInvites' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +//: remove when React 16.9 is released +testUtils.suppressReact16Dot8ActWarningsGlobally() + +afterEach(rtl.cleanup) + +const renderPage = () => testUtils.renderWithRouter() + +describe('PendingInvites page', () => { + it('renders', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect( + page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + ).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + _.take(MOCK_ACCOUNTS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( + ({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page16Button = rtl.queryByText(pagination, '16') + expect(page16Button).not.toBeNull() + rtl.fireEvent.click(page16Button) + accountsTestUtils.expectEmailIn('150@example.com', page.baseElement) + }) + + it('orders accounts by email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .drop( + Math.floor(MOCK_ACCOUNTS.length / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['desc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('orders accounts by date invited', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const dateInvitedHeader = rtl.getByText(table, 'Date invited') + rtl.fireEvent.click(dateInvitedHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['dateInvited'], ['asc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + rtl.fireEvent.change(filterInput, { target: { value: '1' } }) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('1')) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + rtl.fireEvent.change(filterInput, { target: { value: '90' } }) + expect( + accountsTestUtils + .queryAllByColumnText(table, 'emailAddress', /@example\.com/) + .map(el => el.textContent), + ).toEqual(['90@example.com']) + }) + + it('filters accounts by inviter email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId( + AccountsTable.FILTER_DROPDOWN_TESTID, + ) + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + rtl.fireEvent.click(filterDropdown) + const filterByInviterOption = rtl.getByText(filterDropdown, 'Inviter') + rtl.fireEvent.click(filterByInviterOption) + + rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) + _(MOCK_ACCOUNTS) + .filter({ inviterEmailAddress: '20@example.com' }) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) + expect( + accountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), + ).toHaveLength(0) + }) + + it('creates an invite', async () => { + const createdAccounts = [] + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockImplementation(() => + Promise.resolve([...MOCK_ACCOUNTS, ...createdAccounts]), + ) + AccountService.createInviteByEmail = jest + .fn() + .mockImplementation(emailAddress => { + createdAccounts.push({ + identityPoolId: `createedIdentityId${createdAccounts.length}`, + userPoolId: `createdUserId${createdAccounts.length}`, + emailAddress: emailAddress, + dateInvited: new Date(), + inviterEmailAddress: 'you@example.com', + inviterIdentityPoolId: 'me', + }) + }) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { + target: { value: '000@example.com' }, + }) + expect(confirmCreateButton.disabled).toBe(false) + rtl.fireEvent.click(confirmCreateButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + expect( + page.getAllByText( + (_content, element) => + element.textContent === 'Sent account invite to 000@example.com.', + )[0], + ).toBeInTheDocument() + + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + rtl.fireEvent.change(filterInput, { target: { value: '000' } }) + accountsTestUtils.expectEmailIn('000@example.com', table) + }) + + it('shows a message when creation fails', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + AccountService.createInviteByEmail = jest + .fn() + .mockRejectedValue(new Error('You must construct additional pylons')) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + rtl.fireEvent.change(emailInput, { + target: { value: '000@example.com' }, + }) + rtl.fireEvent.click(confirmCreateButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Failed to send account invite to 000@example.com', + ), + ) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Error message: You must construct additional pylons', + ), + ) + }) + + it('deletes an invite', async () => { + const deletedIdentityIds = [] + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockImplementation(() => + Promise.resolve( + MOCK_ACCOUNTS.filter( + ({ identityPoolId }) => + !deletedIdentityIds.includes(identityPoolId), + ), + ), + ) + AccountService.deleteInviteByIdentityPoolId = jest + .fn() + .mockImplementation(identityPoolId => { + deletedIdentityIds.push(identityPoolId) + }) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startDeleteButton = page.getByText('Delete') + expect(startDeleteButton.disabled).toBe(true) + + const targetAccountEmailCell = accountsTestUtils.queryByColumnText( + page.baseElement, + 'emailAddress', + '5@example.com', + ) + expect(targetAccountEmailCell).toBeInTheDocument() + rtl.fireEvent.click(targetAccountEmailCell) + expect(startDeleteButton.disabled).toBe(false) + + rtl.fireEvent.click(startDeleteButton) + const deleteModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Confirm invite deletion').closest('.modal'), + ) + rtl.queryAllByText(deleteModal, (_content, element) => + element.textContent.startsWith( + 'Are you sure you want to delete this account invite for 5@example.com?', + ), + ) + + const confirmDeleteButton = rtl + .getAllByRole(deleteModal, 'button') + .filter(el => el.textContent === 'Delete')[0] + expect(confirmDeleteButton).toBeInTheDocument() + rtl.fireEvent.click(confirmDeleteButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + expect( + page.getAllByText( + (_content, element) => + element.textContent === 'Deleted account invite for 5@example.com.', + )[0], + ).toBeInTheDocument() + + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + rtl.fireEvent.change(filterInput, { target: { value: '5@' } }) + expect( + accountsTestUtils.queryByColumnText( + table, + 'emailAddress', + '5@example.com', + ), + ).toBeNull() + }) + + it('shows a message when deletion fails', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValue(MOCK_ACCOUNTS) + AccountService.deleteInviteByIdentityPoolId = jest + .fn() + .mockRejectedValue(new Error('Target lost.')) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const targetAccountEmailCell = accountsTestUtils.queryByColumnText( + page.baseElement, + 'emailAddress', + '2@example.com', + ) + expect(targetAccountEmailCell).toBeInTheDocument() + rtl.fireEvent.click(targetAccountEmailCell) + + const startDeleteButton = page.getByText('Delete') + rtl.fireEvent.click(startDeleteButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Confirm invite deletion').closest('.modal'), + ) + const confirmDeleteButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Delete')[0] + expect(confirmDeleteButton).toBeInTheDocument() + rtl.fireEvent.click(confirmDeleteButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Failed to delete account invite for 2@example.com', + ), + ) + page.getAllByText((_content, element) => + element.textContent.startsWith('Error message: Target lost.'), + ) + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_INVITERS = _.range(NUM_MOCK_ACCOUNTS).map(index => { + if (_.inRange(index, 20, 90)) { + return 10 + } else if (_.inRange(index, 90, 120)) { + return 20 + } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { + return 100 + } + return null +}) + +const MOCK_DATES_INVITED = (() => { + const now = Date.now() + return _.range(NUM_MOCK_ACCOUNTS).map( + index => new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() + +const MOCK_ACCOUNTS = MOCK_INVITERS.map((inviter, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateInvited: MOCK_DATES_INVITED[index], + inviterEmailAddress: inviter && `${inviter}@example.com`, + inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, +})) diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 47caa5183..4c41a0625 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -6,7 +6,7 @@ import { AdminRoute } from 'index' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' -import AccountInvites from 'pages/Admin/Accounts/AccountInvites' +import PendingInvites from 'pages/Admin/Accounts/PendingInvites' import PendingRequests from 'pages/Admin/Accounts/PendingRequests' export class Admin extends Component { @@ -20,7 +20,7 @@ export class Admin extends Component { - + diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 435535bac..38d701499 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -7,13 +7,17 @@ const mockData = (() => { const now = Date.now() const adminStep = 10 return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + let inviter = 1 let promoter = null if (_.inRange(index, 20, 90)) { promoter = 10 + inviter = 10 } else if (_.inRange(index, 90, 120)) { promoter = 20 + inviter = 20 } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { promoter = 100 + inviter = 100 } return { @@ -25,6 +29,8 @@ const mockData = (() => { new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), promoterEmailAddress: promoter && `${promoter}@example.com`, promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, + inviterEmailAddress: inviter && `${inviter}@example.com`, + inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, dateRegistered: new Date( now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, ).toJSON(), @@ -39,6 +45,13 @@ const mockPendingRequestAccounts = _.cloneDeep(mockData).map( ({ dateRegistered, ...rest }) => ({ ...rest, dateRequested: dateRegistered }), ) +const mockPendingInviteAccounts = _.cloneDeep(mockData).map( + ({ dateRegistered, ...rest }) => ({ + ...rest, + dateInvited: dateRegistered, + }), +) + export const fetchRegisteredAccounts = () => { return resolveAfter(1500, mockData.slice()) } @@ -51,6 +64,10 @@ export const fetchPendingRequestAccounts = () => { return resolveAfter(1500, mockPendingRequestAccounts.slice()) } +export const fetchPendingInviteAccounts = () => { + return resolveAfter(1500, mockPendingInviteAccounts.slice()) +} + export const deleteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) @@ -66,6 +83,38 @@ export const deleteAccountByIdentityPoolId = async identityPoolId => { mockData.splice(accountIndex, 1) } +export const deleteInviteByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockPendingInviteAccounts.findIndex( + account => account.identityPoolId === identityPoolId, + ) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockPendingInviteAccounts.splice(accountIndex, 1) +} + +export const createInviteByEmail = async emailAddress => { + await resolveAfter(1500) + + const account = { + identityPoolId: `temp`, + userPoolId: `temp`, + emailAddress, + dateInvited: new Date(Date.now()).toJSON(), + inviterEmailAddress: `you@localhost`, + inviterIdentityPoolId: `yourIdentityId`, + apiKeyId: `temp`, + registrationMethod: `invite`, + } + + mockPendingInviteAccounts.push(account) +} + export const promoteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) diff --git a/dev-portal/src/utils/use-boolean.jsx b/dev-portal/src/utils/use-boolean.jsx new file mode 100644 index 000000000..f745c948a --- /dev/null +++ b/dev-portal/src/utils/use-boolean.jsx @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react' + +/** + * A React state hook wrapping a boolean value, returning `setTrue` and + * `setFalse` functions which do as their names suggest. Returns `[state, + * setTrue, setFalse, setState]`, in which the first and last functions + * correspond to those returned by `useState`, and in which all functions (i.e. + * all but `state`) are stable. + * + * This is especially useful for controlled modals, for example, which may + * close themselves via a callback: + * ```javascript + * const [isOpen, open, close] = useBoolean(false) + * return ( ... ) + * ``` + */ +export const useBoolean = initialState => { + const [state, setState] = useState(initialState) + const setTrue = useCallback(() => setState(true), []) + const setFalse = useCallback(() => setState(false), []) + return [state, setTrue, setFalse, setState] +} From 334bb0b64cafbef70b4219fbea380c39119ecaf1 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 26 Aug 2019 15:21:41 -0700 Subject: [PATCH 2/7] PendingInvites: change some Create modal wording --- dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 4f9a233f3..8ae1d3a65 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -160,7 +160,7 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => {

Enter an email address below and select Create to - send an account invite. + send an invitation to create an account.

Date: Mon, 26 Aug 2019 15:22:12 -0700 Subject: [PATCH 3/7] PendingInvites: show "Please enter a valid email address" in Create modal --- .../pages/Admin/Accounts/PendingInvites.jsx | 6 +- .../Accounts/__tests__/PendingInvites.jsx | 55 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 8ae1d3a65..501035f06 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -155,17 +155,21 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => { }, [onConfirm, email]) return ( - + Create invite

Enter an email address below and select Create to send an invitation to create an account.

+
diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx index e4204fee1..503a8eff8 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -235,15 +235,9 @@ describe('PendingInvites page', () => { .filter(el => el.textContent === 'Create')[0] expect(confirmCreateButton).toBeInTheDocument() - expect(confirmCreateButton.disabled).toBe(true) - rtl.fireEvent.change(emailInput, { target: { value: '000' } }) - expect(confirmCreateButton.disabled).toBe(true) - rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) - expect(confirmCreateButton.disabled).toBe(true) rtl.fireEvent.change(emailInput, { target: { value: '000@example.com' }, }) - expect(confirmCreateButton.disabled).toBe(false) rtl.fireEvent.click(confirmCreateButton) await accountsTestUtils.waitForAccountsToLoad(page) @@ -260,6 +254,55 @@ describe('PendingInvites page', () => { accountsTestUtils.expectEmailIn('000@example.com', table) }) + it('prevents creating an invite for an invalid email address', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + + const pleaseEnterAValidEmail = rtl.queryByText( + createModal, + 'Please enter a valid email address.', + ) + const pleaseEnterAValidEmailIsVisible = () => + !pleaseEnterAValidEmail.classList.contains('hidden') + + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000@example.com' } }) + expect(confirmCreateButton.disabled).toBe(false) + expect(pleaseEnterAValidEmailIsVisible()).toBe(false) + + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + }) + it('shows a message when creation fails', async () => { AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) AccountService.createInviteByEmail = jest From 74b505c12a962a61e0092ad249ec31719b19e1c0 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 08:53:06 -0700 Subject: [PATCH 4/7] MessageList: refactor to avoid components in state --- dev-portal/src/components/MessageList.jsx | 33 ++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index bbcacb20b..b5cb80dd5 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -1,14 +1,15 @@ import React, { useState } from 'react' export const MessageList = ({ messages }) => - messages.map((message, index) => ( - {message} + messages.map(({ render, id }) => ( + {render()} )) /** * A Hook for operating a list of "messages" which should be self-dismissable. * Returns `[messages, sendMessage]`, where: - * - `messages` is an array of renderable messages (of type `React.ReactNode`) + * - `messages` is an array of messages, which should be provided as a prop + * to `MessageList` to be rendered * - `sendMessage` is a function which accepts a renderer callback, and * calls the callback to obtain a renderable message to append to * `messages`. The renderer callback should accept a `dismiss` function as @@ -16,14 +17,28 @@ export const MessageList = ({ messages }) => * when called. */ export const useMessages = () => { - const [messages, setMessages] = useState([]) + const [state, setState] = useState({ + messages: [], + nextId: 0, + }) const sendMessage = renderWithDismiss => { - const target = renderWithDismiss(() => { - setMessages(messages => messages.filter(message => message !== target)) - }) - setMessages(messages => [...messages, target]) + const id = state.nextId + const dismiss = () => { + setState(state => ({ + ...state, + messages: state.messages.filter(message => message.id !== id), + })) + } + const newMessage = { + render: () => renderWithDismiss(dismiss), + id: state.nextId, + } + setState(state => ({ + messages: [...state.messages, newMessage], + nextId: state.nextId + 1, + })) } - return [messages, sendMessage] + return [state.messages, sendMessage] } From 7f83aa0be8bda0324dccbbf3f4d9499209a79112 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 09:26:47 -0700 Subject: [PATCH 5/7] PendingInvites: close/reset Create modal only if creation succeeds --- dev-portal/src/components/MessageList.jsx | 6 +- .../pages/Admin/Accounts/PendingInvites.jsx | 56 +++++++++++++++---- .../Accounts/__tests__/PendingInvites.jsx | 4 +- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index b5cb80dd5..39f76f95e 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -40,5 +40,9 @@ export const useMessages = () => { })) } - return [state.messages, sendMessage] + const clearMessages = () => { + setState(state => ({ ...state, messages: [] })) + } + + return [state.messages, sendMessage, clearMessages] } diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 501035f06..a1b4a0c16 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -26,6 +26,11 @@ const PendingInvites = () => { false, ) const [messages, sendMessage] = MessageList.useMessages() + const [ + createModalMessages, + sendCreateModalMessage, + clearCreateModalMessages, + ] = MessageList.useMessages() const refreshAccounts = () => AccountService.fetchPendingInviteAccounts().then(accounts => @@ -45,26 +50,35 @@ const PendingInvites = () => { const onConfirmCreate = useCallback( async emailAddress => { setLoading(true) - closeCreateModal() + clearCreateModalMessages() try { await AccountService.createInviteByEmail(emailAddress) + closeCreateModal() + clearCreateModalMessages() sendMessage(dismiss => ( )) - await refreshAccounts() + // Don't need to wait for this + refreshAccounts().then(() => setLoading(false)) + return true } catch (error) { - sendMessage(dismiss => ( + sendCreateModalMessage(dismiss => ( )) - } finally { setLoading(false) + return false } }, - [sendMessage, closeCreateModal], + [ + sendMessage, + sendCreateModalMessage, + clearCreateModalMessages, + closeCreateModal, + ], ) const onConfirmDelete = useCallback(async () => { @@ -117,6 +131,7 @@ const PendingInvites = () => { onConfirm={onConfirmCreate} open={isCreateModalOpen} onClose={closeCreateModal} + messages={createModalMessages} /> { +/* + * Note: `onConfirm` should return a boolean indicating whether the creation + * succeeded. + */ +const CreateInviteModal = ({ onConfirm, open, onClose, messages }) => { const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) const isEmailValid = useMemo(() => /^[^@\s]+@[^@\s]+$/.test(email), [email]) const onChangeEmailAddress = useCallback( (_event, { value }) => setEmail(value), [], ) - const onClickCreate = useCallback(() => { - onConfirm(email) - setEmail('') + const onClickCreate = useCallback(async () => { + setLoading(true) + if (await onConfirm(email)) { + setEmail('') + } + setLoading(false) }, [onConfirm, email]) return ( @@ -162,19 +185,28 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => { Enter an email address below and select Create to send an invitation to create an account.

-
- - + diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx index 503a8eff8..b1f7b918b 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -329,12 +329,12 @@ describe('PendingInvites page', () => { rtl.fireEvent.click(confirmCreateButton) await accountsTestUtils.waitForAccountsToLoad(page) - page.getAllByText((_content, element) => + rtl.getAllByText(createModal, (_content, element) => element.textContent.startsWith( 'Failed to send account invite to 000@example.com', ), ) - page.getAllByText((_content, element) => + rtl.getAllByText(createModal, (_content, element) => element.textContent.startsWith( 'Error message: You must construct additional pylons', ), From cbf846352f49af39fe1b8b9b0a1bde3e00027333 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 09:30:10 -0700 Subject: [PATCH 6/7] PendingInvites: don't say creation failed if UI code fails --- .../src/pages/Admin/Accounts/PendingInvites.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index a1b4a0c16..ab2d69f46 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -53,14 +53,6 @@ const PendingInvites = () => { clearCreateModalMessages() try { await AccountService.createInviteByEmail(emailAddress) - closeCreateModal() - clearCreateModalMessages() - sendMessage(dismiss => ( - - )) - // Don't need to wait for this - refreshAccounts().then(() => setLoading(false)) - return true } catch (error) { sendCreateModalMessage(dismiss => ( { setLoading(false) return false } + closeCreateModal() + clearCreateModalMessages() + sendMessage(dismiss => ( + + )) + // Don't need to wait for this + refreshAccounts().then(() => setLoading(false)) + return true }, [ sendMessage, From ae5f7faeecc06ae6c551ee33ce513cebffcb3ce2 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 11:17:17 -0700 Subject: [PATCH 7/7] PendingInvites: remove unnecssary React.memo's --- .../pages/Admin/Accounts/PendingInvites.jsx | 114 +++++++++--------- 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index ab2d69f46..a7f53550c 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -144,17 +144,20 @@ const PendingInvites = () => { } export default PendingInvites -const TableActions = React.memo( - ({ canCreate, onClickCreate, canDelete, onClickDelete }) => ( - - - - - - ), -) +const DeleteInviteModal = ({ account, onConfirm, open, onClose }) => + account ? ( + + Confirm invite deletion + +

+ Are you sure you want to delete this account invite for{' '} + {account.emailAddress}? This action is irreversible. +

+
+ + + + +
+ ) : null -const CreateSuccessMessage = React.memo(({ emailAddress, dismiss }) => ( +const CreateSuccessMessage = ({ emailAddress, dismiss }) => ( Sent account invite to {emailAddress}. -)) +) -const CreateFailureMessage = React.memo( - ({ emailAddress, errorMessage, dismiss }) => ( - - -

- Failed to send account invite to {emailAddress}. -

- {errorMessage &&

Error message: {errorMessage}

} -
-
- ), +const CreateFailureMessage = ({ emailAddress, errorMessage, dismiss }) => ( + + +

+ Failed to send account invite to {emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
) -const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( +const DeleteSuccessMessage = ({ account, dismiss }) => ( Deleted account invite for {account.emailAddress}. -)) +) -const DeleteFailureMessage = React.memo( - ({ account, errorMessage, dismiss }) => ( - - -

- Failed to delete account invite for{' '} - {account.emailAddress}. -

- {errorMessage &&

Error message: {errorMessage}

} -
-
- ), +const DeleteFailureMessage = ({ account, errorMessage, dismiss }) => ( + + +

+ Failed to delete account invite for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
)