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)