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)