diff --git a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.stories.tsx b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.stories.tsx new file mode 100644 index 0000000000..aeba910f69 --- /dev/null +++ b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.stories.tsx @@ -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 + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +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() + }, +} diff --git a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.tsx b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.tsx new file mode 100644 index 0000000000..7dafc4fd1b --- /dev/null +++ b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsFormDest.tsx @@ -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 + 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): 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 ( + { + 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 + > + + + + } + /> + } + /> + + + {props.value.time && ( + + + Times shown in schedule timezone ({zone}) + + + )} + + + + } + /> + + + + {days.map((day, i) => ( + + } + /> + ))} + + + + + + {destinationTypes.map((t) => + renderMenuItem({ + label: t.name, + value: t.type, + disabled: !t.enabled, + disabledMessage: t.enabled ? '' : t.disabledMessage, + }), + )} + + + + + + + + {currentType?.userDisclaimer} + + + + + ) +} diff --git a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsListDest.stories.tsx b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsListDest.stories.tsx index b21e34b182..02c0377956 100644 --- a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsListDest.stories.tsx +++ b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsListDest.stories.tsx @@ -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: { diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts index 0eb177b41e..ae8eda4aae 100644 --- a/web/src/app/storybook/defaultDestTypes.ts +++ b/web/src/app/storybook/defaultDestTypes.ts @@ -9,7 +9,7 @@ export const destTypes: DestinationTypeInfo[] = [ userDisclaimer: '', isContactMethod: true, isEPTarget: false, - isSchedOnCallNotify: false, + isSchedOnCallNotify: true, iconURL: '', iconAltText: '', supportsStatusUpdates: false, @@ -36,7 +36,7 @@ export const destTypes: DestinationTypeInfo[] = [ userDisclaimer: '', isContactMethod: true, isEPTarget: false, - isSchedOnCallNotify: false, + isSchedOnCallNotify: true, iconURL: '', iconAltText: '', supportsStatusUpdates: true, diff --git a/web/src/app/util/RequireConfig.tsx b/web/src/app/util/RequireConfig.tsx index d4dd6166bb..be40158c72 100644 --- a/web/src/app/util/RequireConfig.tsx +++ b/web/src/app/util/RequireConfig.tsx @@ -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)