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} + + ) })} )