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 (
+
+
+
+
+
+
+
+
+
+ )
+}
+export default PendingInvites
+
+const TableActions = React.memo(
+ ({ canCreate, onClickCreate, canDelete, onClickDelete }) => (
+
+
+
+
+ ),
+)
+
+const CreateInviteModal = ({ onConfirm, open, onClose }) => {
+ const [email, setEmail] = useState('')
+ const isEmailValid = useMemo(() => /^[^@\s]+@[^@\s]+$/.test(email), [email])
+ const onChangeEmailAddress = useCallback(
+ (_event, { value }) => setEmail(value),
+ [],
+ )
+ const onClickCreate = useCallback(() => {
+ onConfirm(email)
+ setEmail('')
+ }, [onConfirm, email])
+
+ return (
+
+ Create invite
+
+
+ Enter an email address below and select Create to
+ send an account invite.
+
+
+
+
+
+
+
+
+ )
+}
+
+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.
+
+ Please enter a valid email address.
+
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.
-
+
+
Please enter a valid email address.
-
-
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 TableActions = ({
+ canCreate,
+ onClickCreate,
+ canDelete,
+ onClickDelete,
+}) => (
+
+
+
+
)
/*
@@ -214,67 +217,60 @@ const CreateInviteModal = ({ onConfirm, open, onClose, messages }) => {
)
}
-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.
-
-
-
- Cancel
-
- Delete
-
-
-
- ),
-)
+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.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+ ) : 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}
}
+
+
)