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

dest: add sched notif form #3704

Merged
merged 8 commits into from
Feb 26, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import ScheduleOnCallNotificationsFormDest, {
Value,
} from './ScheduleOnCallNotificationsFormDest'
import { useArgs } from '@storybook/preview-api'
import { expect, userEvent, within } from '@storybook/test'

const meta = {
title: 'schedules/on-call-notifications/FormDest',
component: ScheduleOnCallNotificationsFormDest,
argTypes: {},
args: {
scheduleID: '',
value: {
time: null,
weekdayFilter: [false, false, false, false, false, false, false],
dest: {
type: 'single-field',
values: [],
},
},
},
tags: ['autodocs'],
render: function Component(args) {
const [, setArgs] = useArgs()
const onChange = (newValue: Value): void => {
if (args.onChange) args.onChange(newValue)
setArgs({ value: newValue })
}
return <ScheduleOnCallNotificationsFormDest {...args} onChange={onChange} />
},
} satisfies Meta<typeof ScheduleOnCallNotificationsFormDest>

export default meta
type Story = StoryObj<typeof meta>

export const Empty: Story = {
args: {},
}

export const ValidationErrors: Story = {
args: {
errors: [
{
path: ['mutation', 'input', 'time'],
message: 'error with time',
extensions: {
code: 'INVALID_INPUT_VALUE',
},
},
{
path: ['mutation', 'input', 'dest'],
message: 'error with dest field',
extensions: {
code: 'INVALID_DEST_FIELD_VALUE',
fieldID: 'phone-number',
},
},
{
path: ['mutation', 'input', 'dest', 'type'],
message: 'error with dest type',
extensions: {
code: 'INVALID_INPUT_VALUE',
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)

await userEvent.click(
await canvas.findByLabelText(
'Notify at a specific day and time every week',
),
)

await expect(await canvas.findByLabelText('Time')).toBeInvalid()
await expect(
// mui puts aria-invalid on the input, but not the combobox (which the label points to)
canvasElement.querySelector('input[name="dest.type"]'),
).toBeInvalid()
await expect(await canvas.findByLabelText('Phone Number')).toBeInvalid()

await expect(await canvas.findByText('Error with time')).toBeVisible()
await expect(await canvas.findByText('Error with dest field')).toBeVisible()
await expect(await canvas.findByText('Error with dest type')).toBeVisible()
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import {
Checkbox,
FormControlLabel,
Grid,
Radio,
RadioGroup,
TextField,
Typography,
} from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { DateTime } from 'luxon'
import React from 'react'

import { FormContainer, FormField } from '../../forms'
import { renderMenuItem } from '../../selection/DisableableMenuItem'
import { ISOTimePicker } from '../../util/ISOPickers'
import { Time } from '../../util/Time'
import { useScheduleTZ } from '../useScheduleTZ'
import { EVERY_DAY, NO_DAY } from './util'
import { useSchedOnCallNotifyTypes } from '../../util/RequireConfig'
import { DestinationInput, WeekdayFilter } from '../../../schema'
import DestinationField from '../../selection/DestinationField'
import {
DestFieldValueError,
KnownError,
isDestFieldError,
isInputFieldError,
} from '../../util/errtypes'

const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

export type Value = {
time: string | null
weekdayFilter: WeekdayFilter
dest: DestinationInput
}

interface ScheduleOnCallNotificationsFormProps {
scheduleID: string
value: Value
errors?: Array<KnownError | DestFieldValueError>
onChange: (val: Value) => void
disablePortal?: boolean
}

const useStyles = makeStyles({
margin0: { margin: 0 },
tzNote: { fontStyle: 'italic' },
})

export const errorPaths = (prefix = '*'): string[] => [
`${prefix}.time`,
`${prefix}.weedkayFilter`,
`${prefix}.dest.type`,
`${prefix}.dest`,
]

export default function ScheduleOnCallNotificationsFormDest(
props: ScheduleOnCallNotificationsFormProps,
): JSX.Element {
const { scheduleID, ...formProps } = props
const classes = useStyles()
const { zone } = useScheduleTZ(scheduleID)
const destinationTypes = useSchedOnCallNotifyTypes()
const currentType = destinationTypes.find(
(d) => d.type === props.value.dest.type,
)

if (!currentType) throw new Error('invalid destination type')

const handleRuleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (e.target.value === 'on-change') {
props.onChange({ ...formProps.value, time: null, weekdayFilter: NO_DAY })
return
}
props.onChange({
...props.value,
weekdayFilter: EVERY_DAY,
time: DateTime.fromObject({ hour: 9 }, { zone }).toISO(),
})
}

return (
<FormContainer
{...formProps}
errors={props.errors?.filter(isInputFieldError).map((e) => {
let field = e.path[e.path.length - 1].toString()
if (field === 'type') field = 'dest.type'
return {
// need to convert to FormContainer's error format
message: e.message,
field,
}
})}
optionalLabels
>
<Grid container spacing={2} direction='column'>
<Grid item>
<RadioGroup
name='ruleType'
value={formProps.value.time ? 'time-of-day' : 'on-change'}
onChange={handleRuleChange}
>
<FormControlLabel
data-cy='notify-on-change'
label='Notify when on-call changes'
value='on-change'
control={<Radio />}
/>
<FormControlLabel
data-cy='notify-at-time'
label='Notify at a specific day and time every week'
value='time-of-day'
control={<Radio />}
/>
</RadioGroup>
</Grid>
{props.value.time && (
<Grid item xs={12}>
<Typography color='textSecondary' className={classes.tzNote}>
Times shown in schedule timezone ({zone})
</Typography>
</Grid>
)}
<Grid item container spacing={2} alignItems='center'>
<Grid item xs={12} sm={5} md={4}>
<FormField
component={ISOTimePicker}
timeZone={zone}
fullWidth
name='time'
disabled={!props.value.time}
required={!!props.value.time}
hint={
<Time
format='clock'
time={props.value.time}
suffix=' in local time'
/>
}
/>
</Grid>
<Grid item xs={12} sm={7} md={8}>
<Grid container justifyContent='space-between'>
{days.map((day, i) => (
<FormControlLabel
key={i}
label={day}
labelPlacement='top'
classes={{ labelPlacementTop: classes.margin0 }}
control={
<FormField
noError
component={Checkbox}
checkbox
name={`weekdayFilter[${i}]`}
disabled={!props.value.time}
/>
}
/>
))}
</Grid>
</Grid>
</Grid>
<Grid item xs={12} sm={12} md={6}>
<FormField
fullWidth
name='dest.type'
label='Destination Type'
required
select
disablePortal={props.disablePortal}
component={TextField}
>
{destinationTypes.map((t) =>
renderMenuItem({
label: t.name,
value: t.type,
disabled: !t.enabled,
disabledMessage: t.enabled ? '' : t.disabledMessage,
}),
)}
</FormField>
</Grid>
<Grid item xs={12}>
<FormField
fullWidth
name='value'
fieldName='dest.values'
required
destType={props.value.dest.type}
component={DestinationField}
destFieldErrors={props.errors?.filter(isDestFieldError)}
/>
</Grid>
<Grid item xs={12}>
<Typography variant='caption'>
{currentType?.userDisclaimer}
</Typography>
</Grid>
</Grid>
</FormContainer>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const errorScheduleID = '11111111-1111-1111-1111-111111111111'
const manyNotificationsScheduleID = '22222222-2222-2222-2222-222222222222'

const meta = {
title: 'schedules/on-call-notifications/ScheduleOnCallNotificationsListDest',
title: 'schedules/on-call-notifications/ListDest',
component: ScheduleOnCallNotificationsListDest,
argTypes: {},
parameters: {
Expand Down
4 changes: 2 additions & 2 deletions web/src/app/storybook/defaultDestTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const destTypes: DestinationTypeInfo[] = [
userDisclaimer: '',
isContactMethod: true,
isEPTarget: false,
isSchedOnCallNotify: false,
isSchedOnCallNotify: true,
iconURL: '',
iconAltText: '',
supportsStatusUpdates: false,
Expand All @@ -36,7 +36,7 @@ export const destTypes: DestinationTypeInfo[] = [
userDisclaimer: '',
isContactMethod: true,
isEPTarget: false,
isSchedOnCallNotify: false,
isSchedOnCallNotify: true,
iconURL: '',
iconAltText: '',
supportsStatusUpdates: true,
Expand Down
6 changes: 6 additions & 0 deletions web/src/app/util/RequireConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export function useContactMethodTypes(): DestinationTypeInfo[] {
return cfg.destTypes.filter((t) => t.isContactMethod)
}

/** useSchedOnCallNotifyTypes returns a list of schedule on-call notification destination types. */
export function useSchedOnCallNotifyTypes(): DestinationTypeInfo[] {
const cfg = React.useContext(ConfigContext)
return cfg.destTypes.filter((t) => t.isSchedOnCallNotify)
}

// useDestinationType returns information about the given destination type.
export function useDestinationType(type: DestinationType): DestinationTypeInfo {
const ctx = React.useContext(ConfigContext)
Expand Down
Loading