diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go
index 2ddc81141a..b7984dd547 100644
--- a/graphql2/graphqlapp/contactmethod.go
+++ b/graphql2/graphqlapp/contactmethod.go
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/target/goalert/config"
+ "github.com/target/goalert/expflag"
"github.com/target/goalert/graphql2"
"github.com/target/goalert/notification"
"github.com/target/goalert/notification/webhook"
@@ -214,6 +215,11 @@ func (m *Mutation) UpdateUserContactMethod(ctx context.Context, input graphql2.U
return err
}
if input.Name != nil {
+ err := validate.IDName("input.name", *input.Name)
+ if err != nil && expflag.ContextHas(ctx, expflag.DestTypes) {
+ addInputError(ctx, err)
+ return errAlreadySet
+ }
cm.Name = *input.Name
}
if input.Value != nil {
diff --git a/web/src/app/escalation-policies/PolicyStepCreateDialogDest.stories.tsx b/web/src/app/escalation-policies/PolicyStepCreateDialogDest.stories.tsx
index f44fc96a96..3ec7cdeae8 100644
--- a/web/src/app/escalation-policies/PolicyStepCreateDialogDest.stories.tsx
+++ b/web/src/app/escalation-policies/PolicyStepCreateDialogDest.stories.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import PolicyStepCreateDialogDest from './PolicyStepCreateDialogDest'
-import { expect, userEvent, waitFor, within, fn } from '@storybook/test'
+import { expect, fn, userEvent, waitFor, within } from '@storybook/test'
import { handleDefaultConfig, handleExpFlags } from '../storybook/graphql'
import { HttpResponse, graphql } from 'msw'
import { DestFieldValueError } from '../util/errtypes'
@@ -13,6 +13,9 @@ const meta = {
return
},
tags: ['autodocs'],
+ args: {
+ onClose: fn(),
+ },
parameters: {
docs: {
story: {
@@ -89,7 +92,6 @@ export const CreatePolicyStep: Story = {
},
args: {
escalationPolicyID: '1',
- onClose: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement)
diff --git a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsEditDialogDest.stories.tsx b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsEditDialogDest.stories.tsx
index 90b137e0d0..305fe32b00 100644
--- a/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsEditDialogDest.stories.tsx
+++ b/web/src/app/schedules/on-call-notifications/ScheduleOnCallNotificationsEditDialogDest.stories.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
-import { expect, userEvent, waitFor, within, fn } from '@storybook/test'
+import { expect, fn, userEvent, waitFor, within } from '@storybook/test'
import ScheduleOnCallNotificationsEditDialogDest from './ScheduleOnCallNotificationsEditDialogDest'
import { HttpResponse, graphql } from 'msw'
import { handleDefaultConfig, handleExpFlags } from '../../storybook/graphql'
diff --git a/web/src/app/users/UserContactMethodEditDialog.tsx b/web/src/app/users/UserContactMethodEditDialog.tsx
index b35ce91190..9fbd9e113b 100644
--- a/web/src/app/users/UserContactMethodEditDialog.tsx
+++ b/web/src/app/users/UserContactMethodEditDialog.tsx
@@ -5,7 +5,9 @@ import FormDialog from '../dialogs/FormDialog'
import UserContactMethodForm from './UserContactMethodForm'
import { pick } from 'lodash'
import { useQuery } from 'urql'
+import { useExpFlag } from '../util/useExpFlag'
import { ContactMethodType, StatusUpdateState } from '../../schema'
+import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest'
const query = gql`
query ($id: ID!) {
@@ -31,8 +33,12 @@ type Value = {
value: string
statusUpdates?: StatusUpdateState
}
+type UserContactMethodEditDialogProps = {
+ onClose: () => void
+ contactMethodID: string
+}
-export default function UserContactMethodEditDialog({
+function UserContactMethodEditDialog({
onClose,
contactMethodID,
}: {
@@ -96,3 +102,15 @@ export default function UserContactMethodEditDialog({
/>
)
}
+
+export default function UserContactMethodEditDialogSwitch(
+ props: UserContactMethodEditDialogProps,
+): React.ReactNode {
+ const isDestTypesSet = useExpFlag('dest-types')
+
+ if (isDestTypesSet) {
+ return
+ }
+
+ return
+}
diff --git a/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx b/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx
new file mode 100644
index 0000000000..439d869835
--- /dev/null
+++ b/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx
@@ -0,0 +1,253 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest'
+import { expect, fn, userEvent, waitFor, within } from '@storybook/test'
+import { handleDefaultConfig, handleExpFlags } from '../storybook/graphql'
+import { useArgs } from '@storybook/preview-api'
+import { HttpResponse, graphql } from 'msw'
+import { DestFieldValueError, InputFieldError } from '../util/errtypes'
+
+const meta = {
+ title: 'users/UserContactMethodEditDialogDest',
+ component: UserContactMethodEditDialogDest,
+ tags: ['autodocs'],
+ args: {
+ onClose: fn(),
+ },
+ parameters: {
+ docs: {
+ story: {
+ inline: false,
+ iframeHeight: 500,
+ },
+ },
+ msw: {
+ handlers: [
+ handleDefaultConfig,
+ handleExpFlags('dest-types'),
+ graphql.query('userCm', ({ variables: vars }) => {
+ return HttpResponse.json({
+ data: {
+ userContactMethod:
+ vars.id === '00000000-0000-0000-0000-000000000000'
+ ? {
+ id: '00000000-0000-0000-0000-000000000000',
+ name: 'single-field contact method',
+ dest: {
+ type: 'supports-status',
+ values: [
+ {
+ fieldID: 'phone-number',
+ value: '+15555555555',
+ label: '+1 555-555-5555',
+ },
+ ],
+ },
+ value: 'http://localhost:8080',
+ statusUpdates: 'DISABLED',
+ disabled: false,
+ pending: false,
+ }
+ : {
+ id: '00000000-0000-0000-0000-000000000001',
+ name: 'Multi Field',
+ dest: {
+ type: 'triple-field',
+ values: [
+ {
+ fieldID: 'first-field',
+ label: '+1 555-555-5555',
+ value: '+11235550123',
+ },
+ {
+ fieldID: 'second-field',
+ label: 'email',
+ value: 'foobar@example.com',
+ },
+ {
+ fieldID: 'third-field',
+ label: 'slack user ID',
+ value: 'slack',
+ },
+ ],
+ },
+ statusUpdates: 'ENABLED',
+ disabled: false,
+ pending: false,
+ },
+ },
+ })
+ }),
+ graphql.mutation('UpdateUserContactMethod', ({ variables: vars }) => {
+ if (vars.input.name === 'error-test') {
+ return HttpResponse.json({
+ data: null,
+ errors: [
+ {
+ message: 'This is a dest field-error',
+ path: ['updateUserContactMethod', 'input', 'dest'],
+ extensions: {
+ code: 'INVALID_DEST_FIELD_VALUE',
+ fieldID: 'phone-number',
+ },
+ } satisfies DestFieldValueError,
+ {
+ message: 'This indicates an invalid destination type',
+ path: ['updateUserContactMethod', 'input', 'dest', 'type'],
+ extensions: {
+ code: 'INVALID_INPUT_VALUE',
+ },
+ } satisfies InputFieldError,
+ {
+ message: 'Name error',
+ path: ['updateUserContactMethod', 'input', 'name'],
+ extensions: {
+ code: 'INVALID_INPUT_VALUE',
+ },
+ } satisfies InputFieldError,
+ {
+ message: 'This is a generic error',
+ },
+ ],
+ })
+ }
+ return HttpResponse.json({
+ data: {
+ updateUserContactMethod: {
+ id: '00000000-0000-0000-0000-000000000000',
+ },
+ },
+ })
+ }),
+ graphql.query('ValidateDestination', ({ variables: vars }) => {
+ return HttpResponse.json({
+ data: {
+ destinationFieldValidate:
+ vars.input.value === '@slack' ||
+ vars.input.value === '+12225558989' ||
+ vars.input.value === 'valid@email.com',
+ },
+ })
+ }),
+ ],
+ },
+ },
+ render: function Component(args) {
+ const [, setArgs] = useArgs()
+ const onClose = (contactMethodID: string | undefined): void => {
+ if (args.onClose) args.onClose(contactMethodID)
+ setArgs({ value: contactMethodID })
+ }
+ return (
+
+ )
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const SingleField: Story = {
+ args: {
+ contactMethodID: '00000000-0000-0000-0000-000000000000',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(await canvas.findByLabelText('Destination Type'))
+
+ const single = await canvas.findByLabelText('Destination Type')
+ expect(single).toHaveTextContent('Single With Status')
+ await canvas.findByTestId('CheckBoxOutlineBlankIcon')
+ },
+}
+
+export const MultiField: Story = {
+ args: {
+ contactMethodID: '00000000-0000-0000-0000-000000000001',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const multi = await canvas.findByLabelText('Destination Type')
+ expect(multi).toHaveTextContent('Multi Field')
+
+ canvas.findByTestId('CheckBoxIcon')
+
+ await canvas.findByLabelText('Name')
+ await canvas.findByLabelText('Destination Type')
+ await canvas.findByLabelText('First Item')
+ expect(await canvas.findByPlaceholderText('11235550123')).toBeDisabled()
+ await canvas.findByLabelText('Second Item')
+ expect(
+ await canvas.findByPlaceholderText('foobar@example.com'),
+ ).toBeDisabled()
+ await canvas.findByLabelText('Third Item')
+ expect(await canvas.findByPlaceholderText('slack user ID')).toBeDisabled()
+ },
+}
+
+export const StatusUpdates: Story = {
+ args: {
+ contactMethodID: '00000000-0000-0000-0000-000000000000',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ canvas.findByTestId('CheckBoxOutlineBlankIcon')
+
+ await waitFor(
+ async () => {
+ await userEvent.click(
+ await canvas.getByTitle(
+ 'Alert status updates are sent when an alert is acknowledged, closed, or escalated.',
+ ),
+ )
+ },
+ { timeout: 5000 },
+ )
+ await canvas.findByTestId('CheckBoxIcon')
+ },
+}
+
+export const ErrorField: Story = {
+ args: {
+ contactMethodID: '00000000-0000-0000-0000-000000000000',
+ },
+
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.clear(await canvas.findByLabelText('Name'))
+ await userEvent.type(await canvas.findByLabelText('Name'), 'error-test')
+ await userEvent.type(
+ await canvas.findByPlaceholderText('11235550123'),
+ '123',
+ )
+
+ const submitButton = await canvas.findByText('Submit')
+ await userEvent.click(submitButton)
+
+ // response should set error on all fields plus the generic error
+ await waitFor(
+ async () => {
+ await expect(await canvas.findByLabelText('Name'))
+
+ await expect(await canvas.findByText('Name error')).toBeVisible()
+
+ await expect(
+ await canvas.findByText('This indicates an invalid destination type'),
+ ).toBeVisible()
+ await expect(await canvas.findByLabelText('Phone Number'))
+ await expect(
+ await canvas.findByText('This is a dest field-error'),
+ ).toBeVisible()
+
+ await expect(
+ await canvas.findByText('This is a generic error'),
+ ).toBeVisible()
+ },
+ { timeout: 5000 },
+ )
+ },
+}
diff --git a/web/src/app/users/UserContactMethodEditDialogDest.tsx b/web/src/app/users/UserContactMethodEditDialogDest.tsx
new file mode 100644
index 0000000000..c61188f656
--- /dev/null
+++ b/web/src/app/users/UserContactMethodEditDialogDest.tsx
@@ -0,0 +1,113 @@
+import React, { useEffect, useState } from 'react'
+import { useMutation, gql, CombinedError, useQuery } from 'urql'
+
+import { splitErrorsByPath } from '../util/errutil'
+import FormDialog from '../dialogs/FormDialog'
+import UserContactMethodForm, { errorPaths } from './UserContactMethodFormDest'
+import { DestinationInput } from '../../schema'
+
+type Value = {
+ name: string
+ dest: DestinationInput
+ statusUpdates: boolean
+}
+
+const query = gql`
+ query userCm($id: ID!) {
+ userContactMethod(id: $id) {
+ id
+ name
+ dest {
+ type
+ values {
+ fieldID
+ value
+ label
+ }
+ }
+ statusUpdates
+ }
+ }
+`
+
+const mutation = gql`
+ mutation UpdateUserContactMethod($input: UpdateUserContactMethodInput!) {
+ updateUserContactMethod(input: $input)
+ }
+`
+
+export default function UserContactMethodEditDialogDest(props: {
+ onClose: (contactMethodID?: string) => void
+ contactMethodID: string
+
+ disablePortal?: boolean
+}): React.ReactNode {
+ const [{ data, fetching }] = useQuery({
+ query,
+ variables: { id: props.contactMethodID },
+ })
+ const statusUpdates =
+ data?.userContactMethod?.statusUpdates?.includes('ENABLED')
+ // values for contact method form
+ const [CMValue, _setCMValue] = useState({
+ ...data?.userContactMethod,
+ statusUpdates,
+ })
+
+ const [updateErr, setUpdateErr] = useState(null)
+ const setCMValue = (newValue: Value): void => {
+ _setCMValue(newValue)
+ setUpdateErr(null)
+ }
+
+ const [updateCMStatus, updateCM] = useMutation(mutation)
+ useEffect(() => {
+ setUpdateErr(updateCMStatus.error || null)
+ }, [updateCMStatus.error])
+
+ const [formErrors, otherErrs] = splitErrorsByPath(
+ updateErr,
+ errorPaths('updateUserContactMethod.input'),
+ )
+
+ const form = (
+ setCMValue(CMValue)}
+ value={CMValue}
+ />
+ )
+
+ return (
+ {
+ updateCM(
+ {
+ input: {
+ id: props.contactMethodID,
+ name: CMValue.name,
+ enableStatusUpdates: Boolean(CMValue.statusUpdates),
+ },
+ },
+ { additionalTypenames: ['UserContactMethod'] },
+ ).then((result) => {
+ if (result.error) {
+ return
+ }
+ props.onClose()
+ })
+ }}
+ form={form}
+ />
+ )
+}
diff --git a/web/src/app/users/UserContactMethodListDest.tsx b/web/src/app/users/UserContactMethodListDest.tsx
index c058a83db0..937bbd23a1 100644
--- a/web/src/app/users/UserContactMethodListDest.tsx
+++ b/web/src/app/users/UserContactMethodListDest.tsx
@@ -194,14 +194,18 @@ export default function UserContactMethodListDest(
}
if (fieldInfo?.hintURL) {
return (
-
+
{`${cmText} (`}
{fieldInfo.hint})
)
}
- return {cmText}
+ return (
+
+ {cmText}
+
+ )
})}
)