diff --git a/web/src/app/forms/FormField.jsx b/web/src/app/forms/FormField.jsx
index 96041e8ada..b146b3c602 100644
--- a/web/src/app/forms/FormField.jsx
+++ b/web/src/app/forms/FormField.jsx
@@ -287,6 +287,7 @@ FormField.propTypes = {
multiple: p.bool,
+ destType: p.string,
options: p.arrayOf(
p.shape({
label: p.string,
diff --git a/web/src/app/selection/DestinationInputDirect.tsx b/web/src/app/selection/DestinationInputDirect.tsx
index 81e96d0c37..0b2a212cd4 100644
--- a/web/src/app/selection/DestinationInputDirect.tsx
+++ b/web/src/app/selection/DestinationInputDirect.tsx
@@ -94,7 +94,7 @@ export default function DestinationInputDirect(
}
// add live validation icon to the right of the textfield as an endAdornment
- if (adorn && props.value === debouncedValue) {
+ if (adorn && props.value === debouncedValue && !props.disabled) {
iprops = {
endAdornment: {adorn},
...iprops,
diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts
index 030a588ac4..8162b4c487 100644
--- a/web/src/app/storybook/defaultDestTypes.ts
+++ b/web/src/app/storybook/defaultDestTypes.ts
@@ -109,4 +109,60 @@ export const destTypes: DestinationTypeInfo[] = [
},
],
},
+ {
+ type: 'supports-status',
+ name: 'Single Field Destination Type',
+ enabled: true,
+ disabledMessage: 'Single field destination type must be configured.',
+ userDisclaimer: '',
+ isContactMethod: true,
+ isEPTarget: false,
+ isSchedOnCallNotify: false,
+ iconURL: '',
+ iconAltText: '',
+ supportsStatusUpdates: true,
+ statusUpdatesRequired: false,
+ requiredFields: [
+ {
+ fieldID: 'phone-number',
+ labelSingular: 'Phone Number',
+ labelPlural: 'Phone Numbers',
+ hint: 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)',
+ hintURL: '',
+ placeholderText: '11235550123',
+ prefix: '+',
+ inputType: 'tel',
+ isSearchSelectable: false,
+ supportsValidation: true,
+ },
+ ],
+ },
+ {
+ type: 'required-status',
+ name: 'Single Field Destination Type',
+ enabled: true,
+ disabledMessage: 'Single field destination type must be configured.',
+ userDisclaimer: '',
+ isContactMethod: true,
+ isEPTarget: false,
+ isSchedOnCallNotify: false,
+ iconURL: '',
+ iconAltText: '',
+ supportsStatusUpdates: false,
+ statusUpdatesRequired: true,
+ requiredFields: [
+ {
+ fieldID: 'phone-number',
+ labelSingular: 'Phone Number',
+ labelPlural: 'Phone Numbers',
+ hint: 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)',
+ hintURL: '',
+ placeholderText: '11235550123',
+ prefix: '+',
+ inputType: 'tel',
+ isSearchSelectable: false,
+ supportsValidation: true,
+ },
+ ],
+ },
]
diff --git a/web/src/app/users/UserContactMethodFormDest.stories.tsx b/web/src/app/users/UserContactMethodFormDest.stories.tsx
new file mode 100644
index 0000000000..5d9e8dd8f0
--- /dev/null
+++ b/web/src/app/users/UserContactMethodFormDest.stories.tsx
@@ -0,0 +1,218 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import UserContactMethodFormDest, { Value } from './UserContactMethodFormDest'
+import { expect } from '@storybook/jest'
+import { within, screen, userEvent, waitFor } from '@storybook/testing-library'
+import { handleDefaultConfig } from '../storybook/graphql'
+import { useArgs } from '@storybook/preview-api'
+import { HttpResponse, graphql } from 'msw'
+
+const meta = {
+ title: 'users/UserContactMethodFormDest',
+ component: UserContactMethodFormDest,
+ tags: ['autodocs'],
+ parameters: {
+ msw: {
+ handlers: [
+ handleDefaultConfig,
+ graphql.query('ValidateDestination', ({ variables: vars }) => {
+ return HttpResponse.json({
+ data: {
+ destinationFieldValidate: vars.input.value === '+15555555555',
+ },
+ })
+ }),
+ ],
+ },
+ },
+ 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 SupportStatusUpdates: Story = {
+ args: {
+ value: {
+ name: 'supports status',
+ dest: {
+ type: 'supports-status',
+ values: [
+ {
+ fieldID: 'phone-number',
+ value: '+15555555555',
+ },
+ ],
+ },
+ statusUpdates: false,
+ },
+ disabled: false,
+ },
+ play: async () => {
+ // ensure status updates checkbox is clickable
+ const status = await screen.getByLabelText('Send alert status updates')
+ userEvent.click(status, {
+ pointerEventsCheck: 1,
+ })
+ },
+}
+
+export const RequiredStatusUpdates: Story = {
+ args: {
+ value: {
+ name: 'required status',
+ dest: {
+ type: 'required-status',
+ values: [
+ {
+ fieldID: 'phone-number',
+ value: '+15555555555',
+ },
+ ],
+ },
+ statusUpdates: false,
+ },
+ disabled: false,
+ },
+ play: async () => {
+ // ensure status updates checkbox is not clickable
+ const status = await screen.getByLabelText(
+ 'Send alert status updates (cannot be disabled for this type)',
+ )
+ userEvent.click(status, {
+ pointerEventsCheck: 0,
+ })
+ },
+}
+
+export const ErrorSingleField: Story = {
+ args: {
+ value: {
+ name: '-notvalid',
+ dest: {
+ type: 'single-field',
+ values: [
+ {
+ fieldID: 'phone-number',
+ value: '+',
+ },
+ ],
+ },
+ statusUpdates: false,
+ },
+ disabled: false,
+ errors: [
+ {
+ field: 'name',
+ message: 'must begin with a letter',
+ name: 'FieldError',
+ path: [],
+ details: {},
+ },
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.type(screen.getByLabelText('Phone Number'), '123')
+
+ // ensure errors are shown
+ await expect(canvas.getByText('Must begin with a letter')).toBeVisible()
+ await expect(await canvas.findByTestId('CloseIcon')).toBeVisible()
+ },
+}
+
+export const ErrorMultiField: Story = {
+ args: {
+ value: {
+ name: '-notvalid',
+ dest: {
+ type: 'triple-field',
+ values: [
+ {
+ fieldID: 'first-field',
+ value: '+',
+ },
+ {
+ fieldID: 'second-field',
+ value: 'notAnEmail',
+ },
+ {
+ fieldID: 'third-field',
+ value: '-',
+ },
+ ],
+ },
+ statusUpdates: false,
+ },
+ disabled: false,
+ errors: [
+ {
+ field: 'name',
+ message: 'must begin with a letter',
+ name: 'FieldError',
+ path: [],
+ details: {},
+ },
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.type(screen.getByLabelText('First Item'), '123')
+
+ // ensure errors are shown
+ await expect(canvas.getByText('Must begin with a letter')).toBeVisible()
+ await waitFor(async () => {
+ await expect((await canvas.findAllByTestId('CloseIcon')).length).toBe(3)
+ })
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ value: {
+ name: 'disabled dest',
+ dest: {
+ type: 'triple-field',
+ values: [],
+ },
+ statusUpdates: false,
+ },
+ disabled: true,
+ },
+ play: async () => {
+ // ensure all fields are disabled
+ const destTypeOptions = await screen.getByText(
+ 'Multi Field Destination Type',
+ )
+ const firstField = await screen.getByPlaceholderText('11235550123')
+ const secondField = await screen.getByPlaceholderText('foobar@example.com')
+ const thirdField = await screen.getByPlaceholderText('slack user ID')
+
+ userEvent.click(destTypeOptions, {
+ pointerEventsCheck: 0,
+ })
+ userEvent.click(firstField, {
+ pointerEventsCheck: 0,
+ })
+ userEvent.click(secondField, {
+ pointerEventsCheck: 0,
+ })
+ userEvent.click(thirdField, {
+ pointerEventsCheck: 0,
+ })
+
+ const status = await screen.getByLabelText(
+ 'Send alert status updates (not supported for this type)',
+ )
+ userEvent.click(status, {
+ pointerEventsCheck: 0,
+ })
+ },
+}
diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx
new file mode 100644
index 0000000000..c2bd17d903
--- /dev/null
+++ b/web/src/app/users/UserContactMethodFormDest.tsx
@@ -0,0 +1,135 @@
+import { Checkbox, FormControlLabel, Typography } from '@mui/material'
+import Grid from '@mui/material/Grid'
+import TextField from '@mui/material/TextField'
+import React from 'react'
+import { DestinationInput } from '../../schema'
+import { FormContainer, FormField } from '../forms'
+import { renderMenuItem } from '../selection/DisableableMenuItem'
+import { FieldError } from '../util/errutil'
+import DestinationField from '../selection/DestinationField'
+import { useContactMethodTypes } from '../util/RequireConfig'
+
+export type Value = {
+ name: string
+ dest: DestinationInput
+ statusUpdates: boolean
+}
+
+export type UserContactMethodFormProps = {
+ value: Value
+
+ errors?: Array
+
+ disabled?: boolean
+ edit?: boolean
+
+ onChange?: (CMValue: Value) => void
+}
+
+export default function UserContactMethodFormDest(
+ props: UserContactMethodFormProps,
+): JSX.Element {
+ const { value, edit = false, ...other } = props
+
+ const destinationTypes = useContactMethodTypes()
+ const currentType = destinationTypes.find((d) => d.type === value.dest.type)
+
+ if (!currentType) throw new Error('invalid destination type')
+
+ let statusLabel = 'Send alert status updates'
+ let statusUpdateChecked = value.statusUpdates
+ if (currentType.statusUpdatesRequired) {
+ statusLabel = 'Send alert status updates (cannot be disabled for this type)'
+ statusUpdateChecked = true
+ } else if (!currentType.supportsStatusUpdates) {
+ statusLabel = 'Send alert status updates (not supported for this type)'
+ statusUpdateChecked = false
+ }
+
+ return (
+ {
+ if (newValue.dest.type === value.dest.type) {
+ return newValue
+ }
+
+ // reset otherwise
+ return {
+ ...newValue,
+ dest: {
+ ...newValue.dest,
+ values: [],
+ },
+ }
+ }}
+ optionalLabels
+ >
+
+
+
+
+
+
+ {destinationTypes.map((t) =>
+ renderMenuItem({
+ label: t.name,
+ value: t.type,
+ disabled: !t.enabled,
+ disabledMessage: t.enabled ? '' : t.disabledMessage,
+ }),
+ )}
+
+
+
+
+
+
+
+ {currentType?.userDisclaimer}
+
+
+
+
+
+ props.onChange &&
+ props.onChange({
+ ...value,
+ statusUpdates: v.target.checked,
+ })
+ }
+ />
+ }
+ />
+
+
+
+ )
+}
diff --git a/web/src/app/util/RequireConfig.tsx b/web/src/app/util/RequireConfig.tsx
index ba92753cf4..d9da7fbc92 100644
--- a/web/src/app/util/RequireConfig.tsx
+++ b/web/src/app/util/RequireConfig.tsx
@@ -209,6 +209,12 @@ export function useConfigValue(...fields: ConfigID[]): Value[] {
return fields.map((f) => config[f])
}
+// useContactMethodTypes returns a list of contact method destination types.
+export function useContactMethodTypes(): DestinationTypeInfo[] {
+ const cfg = React.useContext(ConfigContext)
+ return cfg.destTypes.filter((t) => t.isContactMethod)
+}
+
// useDestinationType returns information about the given destination type.
export function useDestinationType(type: DestinationType): DestinationTypeInfo {
const ctx = React.useContext(ConfigContext)