diff --git a/graphql2/graphqlapp/destinationdisplayinfo.go b/graphql2/graphqlapp/destinationdisplayinfo.go index 491154a9af..715aa4c7b9 100644 --- a/graphql2/graphqlapp/destinationdisplayinfo.go +++ b/graphql2/graphqlapp/destinationdisplayinfo.go @@ -34,6 +34,9 @@ func (a *Destination) DisplayInfo(ctx context.Context, obj *graphql2.Destination func (a *Query) DestinationDisplayInfo(ctx context.Context, dest graphql2.DestinationInput) (*graphql2.DestinationDisplayInfo, error) { app := (*App)(a) cfg := config.FromContext(ctx) + if err := app.ValidateDestination(ctx, "input", &dest); err != nil { + return nil, err + } switch dest.Type { case destTwilioSMS: n, err := phonenumbers.Parse(dest.FieldValue(fieldPhoneNumber), "") diff --git a/web/src/app/escalation-policies/PolicyStepFormDest.stories.tsx b/web/src/app/escalation-policies/PolicyStepFormDest.stories.tsx new file mode 100644 index 0000000000..def7ff1704 --- /dev/null +++ b/web/src/app/escalation-policies/PolicyStepFormDest.stories.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import PolicyStepFormDest, { FormValue } from './PolicyStepFormDest' +import { expect, userEvent, waitFor, within } from '@storybook/test' +import { handleDefaultConfig, handleExpFlags } from '../storybook/graphql' +import { HttpResponse, graphql } from 'msw' +import { useArgs } from '@storybook/preview-api' +import { DestFieldValueError } from '../util/errtypes' + +const VALID_PHONE = '+12225558989' +const VALID_PHONE2 = '+13335558989' +const INVALID_PHONE = '+15555' + +const meta = { + title: 'escalation-policies/PolicyStepFormDest', + component: PolicyStepFormDest, + render: function Component(args) { + const [, setArgs] = useArgs() + const onChange = (newValue: FormValue): void => { + if (args.onChange) args.onChange(newValue) + setArgs({ value: newValue }) + } + return + }, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [ + handleDefaultConfig, + handleExpFlags('dest-types'), + graphql.query('ValidateDestination', ({ variables: vars }) => { + return HttpResponse.json({ + data: { + destinationFieldValidate: vars.input.value === VALID_PHONE, + }, + }) + }), + graphql.query('DestDisplayInfo', ({ variables: vars }) => { + switch (vars.input.values[0].value) { + case VALID_PHONE: + case VALID_PHONE2: + return HttpResponse.json({ + data: { + destinationDisplayInfo: { + text: + vars.input.values[0].value === VALID_PHONE + ? 'VALID_CHIP_1' + : 'VALID_CHIP_2', + iconURL: 'builtin://phone-voice', + iconAltText: 'Voice Call', + }, + }, + }) + + default: + return HttpResponse.json({ + errors: [ + { + message: 'generic error', + }, + { + path: ['destinationDisplayInfo', 'input'], + message: 'invalid phone number', + extensions: { + code: 'INVALID_DEST_FIELD_VALUE', + fieldID: 'phone-number', + }, + } satisfies DestFieldValueError, + ], + }) + } + }), + ], + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj +export const Empty: Story = { + args: { + value: { + delayMinutes: 15, + actions: [], + }, + }, +} + +export const WithExistingActions: Story = { + args: { + value: { + delayMinutes: 15, + actions: [ + { + type: 'single-field', + values: [{ fieldID: 'phone-number', value: VALID_PHONE }], + }, + { + type: 'single-field', + values: [{ fieldID: 'phone-number', value: VALID_PHONE2 }], + }, + ], + }, + }, +} + +export const ManageActions: Story = { + args: { + value: { + delayMinutes: 15, + actions: [], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const phoneInput = await canvas.findByLabelText('Phone Number') + + await userEvent.clear(phoneInput) + await userEvent.type(phoneInput, INVALID_PHONE) + await userEvent.click(await canvas.findByText('Add Action')) + + await waitFor(async () => { + await expect(await canvas.findByLabelText('Phone Number')).toBeInvalid() + await expect(await canvas.findByText('generic error')).toBeVisible() + await expect( + await canvas.findByText('Invalid phone number'), + ).toBeVisible() + }) + + await userEvent.clear(phoneInput) + + // Editing the input should clear the error + await expect(await canvas.findByLabelText('Phone Number')).not.toBeInvalid() + + await userEvent.type(phoneInput, VALID_PHONE) + + await userEvent.click(await canvas.findByText('Add Action')) + + // should result in chip + await expect(await canvas.findByText('VALID_CHIP_1')).toBeVisible() + + // Delete the chip + await userEvent.click(await canvas.findByTestId('CancelIcon')) + + await expect(await canvas.findByText('No actions')).toBeVisible() + }, +} diff --git a/web/src/app/escalation-policies/PolicyStepFormDest.tsx b/web/src/app/escalation-policies/PolicyStepFormDest.tsx new file mode 100644 index 0000000000..4e71ce4160 --- /dev/null +++ b/web/src/app/escalation-policies/PolicyStepFormDest.tsx @@ -0,0 +1,197 @@ +import React, { useState, ReactNode, useEffect } from 'react' +import { FormContainer, FormField } from '../forms' +import Grid from '@mui/material/Grid' + +import NumberField from '../util/NumberField' +import { DestinationInput, FieldValueInput } from '../../schema' +import DestinationInputChip from '../util/DestinationInputChip' +import { Button, TextField, Typography } from '@mui/material' +import { renderMenuItem } from '../selection/DisableableMenuItem' +import DestinationField from '../selection/DestinationField' +import { useEPTargetTypes } from '../util/RequireConfig' +import { gql, useClient, CombinedError } from 'urql' +import { + DestFieldValueError, + KnownError, + isDestFieldError, +} from '../util/errtypes' +import { splitErrorsByPath } from '../util/errutil' +import DialogContentError from '../dialogs/components/DialogContentError' +import makeStyles from '@mui/styles/makeStyles' + +const useStyles = makeStyles(() => { + return { + errorContainer: { + flexGrow: 0, + overflowY: 'visible', + }, + } +}) + +export type FormValue = { + delayMinutes: number + actions: DestinationInput[] +} + +export type PolicyStepFormDestProps = { + value: FormValue + errors?: (KnownError | DestFieldValueError)[] + disabled?: boolean + onChange: (value: FormValue) => void +} + +const query = gql` + query DestDisplayInfo($input: DestinationInput!) { + destinationDisplayInfo(input: $input) { + text + iconURL + iconAltText + linkURL + } + } +` + +export default function PolicyStepFormDest( + props: PolicyStepFormDestProps, +): ReactNode { + const types = useEPTargetTypes() + const classes = useStyles() + const [destType, setDestType] = useState(types[0].type) + const [values, setValues] = useState([]) + const validationClient = useClient() + const [err, setErr] = useState(null) + const [destErrors, otherErrs] = splitErrorsByPath(err || props.errors, [ + 'destinationDisplayInfo.input', + ]) + + useEffect(() => { + setErr(null) + }, [props.value]) + + function handleDelete(a: DestinationInput): void { + if (!props.onChange) return + props.onChange({ + ...props.value, + actions: props.value.actions.filter((b) => a !== b), + }) + } + + function renderErrors(): React.JSX.Element[] { + return otherErrs.map((err, idx) => ( + + )) + } + + return ( + { + if (!props.onChange) return + props.onChange(newValue) + }} + errors={props.errors} + > + + + {props.value.actions.map((a, idx) => ( + handleDelete(a)} + /> + ))} + {props.value.actions.length === 0 && ( + + No actions + + )} + + + setDestType(e.target.value)} + > + {types.map((t) => + renderMenuItem({ + label: t.name, + value: t.type, + disabled: !t.enabled, + disabledMessage: t.enabled ? '' : t.disabledMessage, + }), + )} + + + + { + setErr(null) + setValues(newValue) + }} + destFieldErrors={destErrors.filter(isDestFieldError)} + /> + + + {otherErrs && renderErrors()} + + + + + + + + ) +} diff --git a/web/src/app/selection/DestinationInputDirect.tsx b/web/src/app/selection/DestinationInputDirect.tsx index 8478958f5d..6c03c1f9e4 100644 --- a/web/src/app/selection/DestinationInputDirect.tsx +++ b/web/src/app/selection/DestinationInputDirect.tsx @@ -20,7 +20,9 @@ const noSuspense = { suspense: false } function trimPrefix(value: string, prefix: string): string { if (!prefix) return value if (!value) return value - if (value.startsWith(prefix)) return value.slice(prefix.length) + while (value.startsWith(prefix)) { + value = value.slice(prefix.length) + } return value } diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts index ae8eda4aae..848d142d8c 100644 --- a/web/src/app/storybook/defaultDestTypes.ts +++ b/web/src/app/storybook/defaultDestTypes.ts @@ -8,7 +8,7 @@ export const destTypes: DestinationTypeInfo[] = [ disabledMessage: 'Single field destination type must be configured.', userDisclaimer: '', isContactMethod: true, - isEPTarget: false, + isEPTarget: true, isSchedOnCallNotify: true, iconURL: '', iconAltText: '', @@ -35,7 +35,7 @@ export const destTypes: DestinationTypeInfo[] = [ disabledMessage: 'Multi field destination type must be configured.', userDisclaimer: '', isContactMethod: true, - isEPTarget: false, + isEPTarget: true, isSchedOnCallNotify: true, iconURL: '', iconAltText: '', @@ -77,33 +77,6 @@ export const destTypes: DestinationTypeInfo[] = [ }, ], }, - { - type: 'disabled-destination', - name: 'This is disabled', - enabled: false, - disabledMessage: 'This field is disabled.', - userDisclaimer: '', - isContactMethod: true, - isEPTarget: true, - isSchedOnCallNotify: true, - iconURL: '', - iconAltText: '', - supportsStatusUpdates: false, - statusUpdatesRequired: false, - requiredFields: [ - { - fieldID: 'disabled', - label: '', - hint: '', - hintURL: '', - placeholderText: 'This field is disabled.', - prefix: '', - inputType: 'url', - supportsSearch: false, - supportsValidation: false, - }, - ], - }, { type: 'supports-status', name: 'Single With Status', @@ -158,4 +131,31 @@ export const destTypes: DestinationTypeInfo[] = [ }, ], }, + { + type: 'disabled-destination', + name: 'This is disabled', + enabled: false, + disabledMessage: 'This field is disabled.', + userDisclaimer: '', + isContactMethod: true, + isEPTarget: true, + isSchedOnCallNotify: true, + iconURL: '', + iconAltText: '', + supportsStatusUpdates: false, + statusUpdatesRequired: false, + requiredFields: [ + { + fieldID: 'disabled', + label: '', + hint: '', + hintURL: '', + placeholderText: 'This field is disabled.', + prefix: '', + inputType: 'url', + supportsSearch: false, + supportsValidation: false, + }, + ], + }, ] diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index 39fbe9cd61..48af732cc9 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -151,11 +151,15 @@ export const MultiField: Story = { await canvas.findByRole('option', { hidden: true, name: 'Multi Field' }), ) - await expect(await canvas.findByLabelText('Name')).toBeVisible() - await expect(await canvas.findByLabelText('Destination Type')).toBeVisible() - await expect(await canvas.findByLabelText('First Item')).toBeVisible() - await expect(await canvas.findByLabelText('Second Item')).toBeVisible() - await expect(await canvas.findByLabelText('Third Item')).toBeVisible() + await waitFor(async function Labels() { + await expect(await canvas.findByLabelText('Name')).toBeVisible() + await expect( + await canvas.findByLabelText('Destination Type'), + ).toBeVisible() + await expect(await canvas.findByLabelText('First Item')).toBeVisible() + await expect(await canvas.findByLabelText('Second Item')).toBeVisible() + await expect(await canvas.findByLabelText('Third Item')).toBeVisible() + }) }, } diff --git a/web/src/app/util/RequireConfig.tsx b/web/src/app/util/RequireConfig.tsx index be40158c72..5dfed3bb6b 100644 --- a/web/src/app/util/RequireConfig.tsx +++ b/web/src/app/util/RequireConfig.tsx @@ -216,6 +216,11 @@ export function useContactMethodTypes(): DestinationTypeInfo[] { return cfg.destTypes.filter((t) => t.isContactMethod) } +export function useEPTargetTypes(): DestinationTypeInfo[] { + const cfg = React.useContext(ConfigContext) + return cfg.destTypes.filter((t) => t.isEPTarget) +} + /** useSchedOnCallNotifyTypes returns a list of schedule on-call notification destination types. */ export function useSchedOnCallNotifyTypes(): DestinationTypeInfo[] { const cfg = React.useContext(ConfigContext)