Skip to content

Commit

Permalink
test contact method: Fix verification dialog, and refactor send test …
Browse files Browse the repository at this point in the history
…dialog (#4062)

* Improve error handling for validation in GraphQL

* fix verify dialog rendering

* fix send test dialog
  • Loading branch information
mastercactapus authored Sep 9, 2024
1 parent ba53a0a commit 5926f5e
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 99 deletions.
2 changes: 1 addition & 1 deletion graphql2/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,7 @@ type UserContactMethod {
value: String!
@deprecated(reason: "Use dest instead.")
@goField(forceResolver: true)
formattedValue: String!
formattedValue: String! @deprecated(reason: "Use dest.displayInfo instead.")
disabled: Boolean!
pending: Boolean!

Expand Down
214 changes: 125 additions & 89 deletions web/src/app/users/SendTestDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import React, { useEffect, MouseEvent, useState } from 'react'
import { gql, useQuery, useMutation } from 'urql'

import Spinner from '../loading/components/Spinner'

import {
Button,
Dialog,
Expand All @@ -12,16 +10,23 @@ import {
DialogContentText,
} from '@mui/material'
import toTitleCase from '../util/toTitleCase'
import DialogContentError from '../dialogs/components/DialogContentError'
import { DateTime } from 'luxon'
import { ContactMethodType, UserContactMethod } from '../../schema'
import { DateTime, Duration } from 'luxon'
import {
DestinationInput,
NotificationState,
UserContactMethod,
} from '../../schema'
import DestinationInputChip from '../util/DestinationInputChip'
import { Time } from '../util/Time'

const query = gql`
query ($id: ID!) {
userContactMethod(id: $id) {
id
type
formattedValue
dest {
type
args
}
lastTestVerifyAt
lastTestMessageState {
details
Expand All @@ -38,103 +43,137 @@ const mutation = gql`
}
`

function getTestStatusColor(status?: string | null): string {
switch (status) {
case 'OK':
return 'success'
case 'ERROR':
return 'error'
default:
return 'warning'
}
}

type SendTestContentProps = {
dest: DestinationInput
isSending: boolean
isWaiting: boolean

sentTime?: string | null
sentState?: NotificationState | null
}

export function SendTestContent(props: SendTestContentProps): React.ReactNode {
return (
<React.Fragment>
<DialogContent>
<DialogContentText>
GoAlert is sending a test to{' '}
<DestinationInputChip value={props.dest} />.
</DialogContentText>

{props.sentTime && (
<DialogContentText style={{ marginTop: '1rem' }}>
The test message was scheduled for delivery at{' '}
<Time time={props.sentTime} />.
</DialogContentText>
)}
{props.sentState?.formattedSrcValue && (
<DialogContentText>
<b>Sender:</b> {props.sentState.formattedSrcValue}
</DialogContentText>
)}

{props.isWaiting && <Spinner text='Waiting to send (< 1 min)...' />}
{props.isSending && <Spinner text='Sending Test...' />}
{props.sentState?.details === 'Pending' ? (
<Spinner text='Waiting in queue...' />
) : (
<DialogContentText
color={getTestStatusColor(props.sentState?.status)}
>
<b>Status:</b> {toTitleCase(props.sentState?.details || '')}
</DialogContentText>
)}
</DialogContent>
</React.Fragment>
)
}

export default function SendTestDialog(
props: SendTestDialogProps,
): JSX.Element {
const { title = 'Test Delivery Status', onClose, messageID } = props

const { onClose, contactMethodID } = props
const [sendTestStatus, sendTest] = useMutation(mutation)

const [{ data, fetching, error }] = useQuery<{
const [cmInfo, refreshCMInfo] = useQuery<{
userContactMethod: UserContactMethod
}>({
query,
variables: {
id: messageID,
id: contactMethodID,
},
requestPolicy: 'network-only',
})

// We keep a stable timestamp to track how long the dialog has been open
const [now] = useState(DateTime.utc())
const status = data?.userContactMethod?.lastTestMessageState?.status ?? ''
const cmDestValue = data?.userContactMethod?.formattedValue ?? ''
const cmType: ContactMethodType | '' = data?.userContactMethod.type ?? ''
const lastSent = data?.userContactMethod?.lastTestVerifyAt
? DateTime.fromISO(data.userContactMethod.lastTestVerifyAt)
: now.plus({ day: -1 })
const fromValue =
data?.userContactMethod?.lastTestMessageState?.formattedSrcValue ?? ''
const errorMessage = (error?.message || sendTestStatus.error?.message) ?? ''

const hasSent =
Boolean(data?.userContactMethod.lastTestMessageState) &&
now.diff(lastSent).as('seconds') < 60
// Should not happen, but just in case.
if (cmInfo.error) throw cmInfo.error
const cm = cmInfo.data?.userContactMethod
if (!cm) throw new Error('missing contact method') // should be impossible (since we already checked the error)

// We expect the status to update over time, so we manually refresh
// as long as the dialog is open.
useEffect(() => {
if (fetching || !data?.userContactMethod) return
if (errorMessage || sendTestStatus.data) return

if (hasSent) return

sendTest({ id: messageID })
}, [lastSent.toISO(), fetching, data, errorMessage, sendTestStatus.data])

const details =
(hasSent && data?.userContactMethod?.lastTestMessageState?.details) || ''

const isLoading =
sendTestStatus.fetching ||
(!!details && !!errorMessage) ||
['pending', 'sending'].includes(details.toLowerCase())

const getTestStatusColor = (status: string): string => {
switch (status) {
case 'OK':
return 'success'
case 'ERROR':
return 'error'
default:
return 'warning'
}
}
const t = setInterval(refreshCMInfo, 3000)
return () => clearInterval(t)
}, [])

const msg = (): string => {
switch (cmType) {
case 'SMS':
case 'VOICE':
return `${
cmType === 'SMS' ? 'SMS message' : 'voice call'
} to ${cmDestValue}`
case 'EMAIL':
return `email to ${cmDestValue}`
default:
return `to ${cmDestValue}`
}
}
// We keep a stable timestamp to track how long the dialog has been open.
const [now] = useState(DateTime.utc())

return (
<Dialog open onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
const isWaitingToSend =
(cm.lastTestVerifyAt
? now.diff(DateTime.fromISO(cm.lastTestVerifyAt))
: Duration.fromObject({ day: 1 })
).as('seconds') < 60

<DialogContent>
<DialogContentText>
GoAlert is sending a test {msg()}.
</DialogContentText>
{isLoading && <Spinner text='Sending Test...' />}
{fromValue && (
<DialogContentText>
The test message was sent from {fromValue}.
</DialogContentText>
)}
{!!details && (
<DialogContentText color={getTestStatusColor(status)}>
{toTitleCase(details)}
</DialogContentText>
)}
</DialogContent>
// already sent a test message recently
const [alreadySent, setAlreadySent] = useState(
!!cm.lastTestMessageState && isWaitingToSend,
)

{errorMessage && <DialogContentError error={errorMessage} />}
useEffect(() => {
if (alreadySent) return

// wait until at least a minute has passed
if (isWaitingToSend) return

sendTest(
{ id: contactMethodID },
{
additionalTypenames: ['UserContactMethod'],
},
)

setAlreadySent(true)
}, [isWaitingToSend, alreadySent])

return (
<Dialog open onClose={onClose}>
<DialogTitle>Test Contact Method</DialogTitle>
<SendTestContent
dest={cm.dest}
isWaiting={isWaitingToSend && !alreadySent}
isSending={sendTestStatus.fetching}
sentState={
cm.lastTestMessageState && alreadySent
? cm.lastTestMessageState
: undefined
}
sentTime={
cm.lastTestMessageState && alreadySent ? cm.lastTestVerifyAt : null
}
/>
<DialogActions>
<Button color='primary' variant='contained' onClick={onClose}>
Done
Expand All @@ -145,9 +184,6 @@ export default function SendTestDialog(
}

interface SendTestDialogProps {
messageID: string
contactMethodID: string
onClose: (event: MouseEvent) => void
disclaimer?: string
title?: string
subtitle?: string
}
2 changes: 1 addition & 1 deletion web/src/app/users/UserContactMethodList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export default function UserContactMethodList(
)}
{showSendTestByID && (
<SendTestDialog
messageID={showSendTestByID}
contactMethodID={showSendTestByID}
onClose={() => setShowSendTestByID('')}
/>
)}
Expand Down
23 changes: 15 additions & 8 deletions web/src/app/users/UserContactMethodVerificationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useMutation, useQuery, gql } from 'urql'
import FormDialog from '../dialogs/FormDialog'
import { fieldErrors, nonFieldErrors } from '../util/errutil'
import UserContactMethodVerificationForm from './UserContactMethodVerificationForm'
import DestinationInputChip from '../util/DestinationInputChip'
import { UserContactMethod } from '../../schema'

/*
* Reactivates a cm if disabled and the verification code matches
Expand All @@ -20,8 +22,10 @@ const contactMethodQuery = gql`
query ($id: ID!) {
userContactMethod(id: $id) {
id
type
formattedValue
dest {
type
args
}
lastVerifyMessageState {
status
details
Expand All @@ -36,7 +40,6 @@ interface UserContactMethodVerificationDialogProps {
contactMethodID: string
}

const noSuspense = { suspense: false }
export default function UserContactMethodVerificationDialog(
props: UserContactMethodVerificationDialogProps,
): React.ReactNode {
Expand All @@ -47,15 +50,14 @@ export default function UserContactMethodVerificationDialog(

const [status, submitVerify] = useMutation(verifyContactMethodMutation)

const [{ data }] = useQuery({
const [{ data }] = useQuery<{ userContactMethod: UserContactMethod }>({
query: contactMethodQuery,
variables: { id: props.contactMethodID },
context: noSuspense,
})

const fromNumber =
data?.userContactMethod?.lastVerifyMessageState?.formattedSrcValue ?? ''
const cm = data?.userContactMethod ?? {}
const cm = data?.userContactMethod ?? ({} as UserContactMethod)

const { fetching, error } = status
const fieldErrs = fieldErrors(error)
Expand All @@ -67,9 +69,14 @@ export default function UserContactMethodVerificationDialog(
return (
<FormDialog
title='Verify Contact Method'
subTitle={`A verification code has been sent to ${cm.formattedValue} (${cm.type})`}
subTitle={
<React.Fragment>
A verification code has been sent to{' '}
<DestinationInputChip value={cm.dest} />
</React.Fragment>
}
caption={caption}
loading={fetching || !cm.type}
loading={fetching}
errors={
sendError
? [new Error(sendError)].concat(nonFieldErrors(error))
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/util/DestinationAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
RotateRight as RotationIcon,
Today as ScheduleIcon,
Webhook as WebhookIcon,
Email,
} from '@mui/icons-material'

const builtInIcons: { [key: string]: React.ReactNode } = {
'builtin://alert': <AlertIcon />,
'builtin://rotation': <RotationIcon />,
'builtin://schedule': <ScheduleIcon />,
'builtin://webhook': <WebhookIcon />,
'builtin://email': <Email />,
}

export type DestinationAvatarProps = {
Expand Down

0 comments on commit 5926f5e

Please sign in to comment.