Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

List pending invites UI #305

Merged
merged 7 commits into from
Aug 27, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
alex-chew marked this conversation as resolved.
Show resolved Hide resolved
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