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)