Skip to content

Commit

Permalink
Merge pull request #305 from alex-chew/list-pending-invites-ui
Browse files Browse the repository at this point in the history
List pending invites UI
  • Loading branch information
alex-chew authored Aug 27, 2019
2 parents a41e8c3 + ae5f7fa commit 297976f
Show file tree
Hide file tree
Showing 8 changed files with 883 additions and 18 deletions.
24 changes: 24 additions & 0 deletions dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
37 changes: 28 additions & 9 deletions dev-portal/src/components/MessageList.jsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,48 @@
import React, { useState } from 'react'

export const MessageList = ({ messages }) =>
messages.map((message, index) => (
<React.Fragment key={index}>{message}</React.Fragment>
messages.map(({ render, id }) => (
<React.Fragment key={id}>{render()}</React.Fragment>
))

/**
* 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
* its sole argument, which removes the renderable message from `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]
const clearMessages = () => {
setState(state => ({ ...state, messages: [] }))
}

return [state.messages, sendMessage, clearMessages]
}
7 changes: 0 additions & 7 deletions dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx

This file was deleted.

276 changes: 276 additions & 0 deletions dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
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 [
createModalMessages,
sendCreateModalMessage,
clearCreateModalMessages,
] = 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)
clearCreateModalMessages()
try {
await AccountService.createInviteByEmail(emailAddress)
} catch (error) {
sendCreateModalMessage(dismiss => (
<CreateFailureMessage
emailAddress={emailAddress}
dismiss={dismiss}
errorMessage={error.message}
/>
))
setLoading(false)
return false
}
closeCreateModal()
clearCreateModalMessages()
sendMessage(dismiss => (
<CreateSuccessMessage emailAddress={emailAddress} dismiss={dismiss} />
))
// Don't need to wait for this
refreshAccounts().then(() => setLoading(false))
return true
},
[
sendMessage,
sendCreateModalMessage,
clearCreateModalMessages,
closeCreateModal,
],
)

const onConfirmDelete = useCallback(async () => {
setLoading(true)
closeDeleteModal()
try {
await AccountService.deleteInviteByIdentityPoolId(
selectedAccount.identityPoolId,
)
sendMessage(dismiss => (
<DeleteSuccessMessage account={selectedAccount} dismiss={dismiss} />
))
await refreshAccounts()
} catch (error) {
sendMessage(dismiss => (
<DeleteFailureMessage
account={selectedAccount}
dismiss={dismiss}
errorMessage={error.message}
/>
))
} finally {
setLoading(false)
}
}, [sendMessage, selectedAccount, closeDeleteModal])

return (
<Container fluid style={{ padding: '2em' }}>
<Header as='h1'>Pending invites</Header>
<MessageList.MessageList messages={messages} />
<AccountsTable.AccountsTable
accounts={accounts}
columns={[
AccountsTableColumns.EmailAddress,
AccountsTableColumns.DateInvited,
AccountsTableColumns.Inviter,
]}
loading={loading}
selectedAccount={selectedAccount}
onSelectAccount={onSelectAccount}
>
<TableActions
canCreate={!loading}
onClickCreate={openCreateModal}
canDelete={!loading && selectedAccount}
onClickDelete={openDeleteModal}
/>
</AccountsTable.AccountsTable>
<CreateInviteModal
onConfirm={onConfirmCreate}
open={isCreateModalOpen}
onClose={closeCreateModal}
messages={createModalMessages}
/>
<DeleteInviteModal
account={selectedAccount}
onConfirm={onConfirmDelete}
open={isDeleteModalOpen}
onClose={closeDeleteModal}
/>
</Container>
)
}
export default PendingInvites

const TableActions = ({
canCreate,
onClickCreate,
canDelete,
onClickDelete,
}) => (
<Button.Group>
<Button
content='Create invite...'
disabled={!canCreate}
onClick={onClickCreate}
/>
<Button content='Delete' disabled={!canDelete} onClick={onClickDelete} />
</Button.Group>
)

/*
* 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(async () => {
setLoading(true)
if (await onConfirm(email)) {
setEmail('')
}
setLoading(false)
}, [onConfirm, email])

return (
<Modal open={open} onClose={onClose} size={'small'}>
<Modal.Header>Create invite</Modal.Header>
<Modal.Content>
<p>
Enter an email address below and select <strong>Create</strong> to
send an invitation to create an account.
</p>
<MessageList.MessageList messages={messages} />
<Message hidden={isEmailValid || loading} warning>
Please enter a valid email address.
</Message>
<Input
placeholder='Email address'
value={email}
onChange={onChangeEmailAddress}
disabled={loading}
style={{ width: '100%' }}
/>
</Modal.Content>
<Modal.Actions>
<Button disabled={loading} loading={loading} onClick={onClose}>
Cancel
</Button>
<Button
positive
disabled={!isEmailValid}
loading={loading}
onClick={onClickCreate}
>
Create
</Button>
</Modal.Actions>
</Modal>
)
}

const DeleteInviteModal = ({ account, onConfirm, open, onClose }) =>
account ? (
<Modal size='small' open={open} onClose={onClose}>
<Modal.Header>Confirm invite deletion</Modal.Header>
<Modal.Content>
<p>
Are you sure you want to delete this account invite for{' '}
<strong>{account.emailAddress}</strong>? This action is irreversible.
</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button negative onClick={onConfirm}>
Delete
</Button>
</Modal.Actions>
</Modal>
) : null

const CreateSuccessMessage = ({ emailAddress, dismiss }) => (
<Message onDismiss={dismiss} positive>
<Message.Content>
Sent account invite to <strong>{emailAddress}</strong>.
</Message.Content>
</Message>
)

const CreateFailureMessage = ({ emailAddress, errorMessage, dismiss }) => (
<Message onDismiss={dismiss} negative>
<Message.Content>
<p>
Failed to send account invite to <strong>{emailAddress}</strong>.
</p>
{errorMessage && <p>Error message: {errorMessage}</p>}
</Message.Content>
</Message>
)

const DeleteSuccessMessage = ({ account, dismiss }) => (
<Message onDismiss={dismiss} positive>
<Message.Content>
Deleted account invite for <strong>{account.emailAddress}</strong>.
</Message.Content>
</Message>
)

const DeleteFailureMessage = ({ account, errorMessage, dismiss }) => (
<Message onDismiss={dismiss} negative>
<Message.Content>
<p>
Failed to delete account invite for{' '}
<strong>{account.emailAddress}</strong>.
</p>
{errorMessage && <p>Error message: {errorMessage}</p>}
</Message.Content>
</Message>
)
Loading

0 comments on commit 297976f

Please sign in to comment.