From 851bb92661da010d0b7cbc1caa120e3ded859131 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Fri, 9 Feb 2024 12:11:23 -0600 Subject: [PATCH 01/31] add user CM create and edit dialogs using destinations --- .../users/UserContactMethodCreateDialog.tsx | 22 ++- ...rContactMethodCreateDialogDest.stories.tsx | 177 ++++++++++++++++++ .../UserContactMethodCreateDialogDest.tsx | 149 +++++++++++++++ .../app/users/UserContactMethodEditDialog.tsx | 26 ++- ...serContactMethodEditDialogDest.stories.tsx | 155 +++++++++++++++ .../users/UserContactMethodEditDialogDest.tsx | 99 ++++++++++ 6 files changed, 621 insertions(+), 7 deletions(-) create mode 100644 web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx create mode 100644 web/src/app/users/UserContactMethodCreateDialogDest.tsx create mode 100644 web/src/app/users/UserContactMethodEditDialogDest.stories.tsx create mode 100644 web/src/app/users/UserContactMethodEditDialogDest.tsx diff --git a/web/src/app/users/UserContactMethodCreateDialog.tsx b/web/src/app/users/UserContactMethodCreateDialog.tsx index dafa0a0115..43252f7c21 100644 --- a/web/src/app/users/UserContactMethodCreateDialog.tsx +++ b/web/src/app/users/UserContactMethodCreateDialog.tsx @@ -8,6 +8,8 @@ import { useConfigValue } from '../util/RequireConfig' import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material' import DialogContentError from '../dialogs/components/DialogContentError' import { ContactMethodType } from '../../schema' +import { useExpFlag } from '../util/useExpFlag' +import UserContactMethodCreateDialogDest from './UserContactMethodCreateDialogDest' type Value = { name: string @@ -36,12 +38,16 @@ const userConflictQuery = gql` const noSuspense = { suspense: false } -export default function UserContactMethodCreateDialog(props: { +type UserContactMethodCreateDialogProps = { userID: string onClose: (contactMethodID?: string) => void title?: string subtitle?: string -}): React.ReactNode { +} + +function UserContactMethodCreateDialog( + props: UserContactMethodCreateDialogProps, +): React.ReactNode { const [allowSV, allowE, allowW, allowS] = useConfigValue( 'Twilio.Enable', 'SMTP.Enable', @@ -154,3 +160,15 @@ export default function UserContactMethodCreateDialog(props: { /> ) } + +export default function UserContactMethodCreateDialogSwitch( + props: UserContactMethodCreateDialogProps, +): React.ReactNode { + const isDestTypesSet = useExpFlag('dest-types') + + if (isDestTypesSet) { + return + } + + return +} diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx new file mode 100644 index 0000000000..3271d56061 --- /dev/null +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import UserContactMethodCreateDialogDest from './UserContactMethodCreateDialogDest' +import { expect, userEvent, waitFor, screen } from '@storybook/test' +import { handleDefaultConfig, defaultConfig } from '../storybook/graphql' +import { useArgs } from '@storybook/preview-api' +import { HttpResponse, graphql } from 'msw' + +const meta = { + title: 'users/UserContactMethodCreateDialogDest', + component: UserContactMethodCreateDialogDest, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [ + handleDefaultConfig, + graphql.query('UserConflictCheck', () => { + return HttpResponse.json({ + data: { + users: { + nodes: [ + { name: defaultConfig.user.name, id: defaultConfig.user.id }, + ], + }, + }, + }) + }), + graphql.query('useExpFlag', () => { + return HttpResponse.json({ + data: { + users: { + nodes: [ + { name: defaultConfig.user.name, id: defaultConfig.user.id }, + ], + }, + }, + }) + }), + graphql.mutation('CreateUserContactMethodInput', () => { + return HttpResponse.json({ + data: { + createUserContactMethod: { + 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: { + userID: defaultConfig.user.id, + title: 'Create New Contact Method', + subtitle: 'Create New Contact Method Subtitle', + }, + play: async () => { + await userEvent.clear(await screen.findByPlaceholderText('11235550123')) + + await waitFor(async () => { + await userEvent.type( + await screen.findByPlaceholderText('11235550123'), + '12225558989', + ) + }) + + const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) + await userEvent.click(submitButton) + + await userEvent.clear(await screen.findByLabelText('Name')) + await userEvent.type(await screen.findByLabelText('Name'), 'TEST') + + const retryButton = await screen.findByRole('button', { name: /RETRY/i }) + await userEvent.click(retryButton) + }, +} + +export const MultiField: Story = { + args: { + userID: defaultConfig.user.id, + title: 'Create New Contact Method', + subtitle: 'Create New Contact Method Subtitle', + }, + play: async () => { + // Select the next Dest Type + await userEvent.click(await screen.findByLabelText('Dest Type')) + await userEvent.click( + await screen.findByText('Multi Field Destination Type'), + ) + + // ensure information for phone number renders correctly + await userEvent.clear(await screen.findByLabelText('First Item')) + await waitFor(async () => { + await userEvent.type( + await screen.findByLabelText('First Item'), + '12225558989', + ) + }) + + await waitFor(async () => { + await expect(await screen.findByTestId('CheckIcon')).toBeVisible() + }) + + // ensure information for email renders correctly + await expect( + await screen.findByPlaceholderText('foobar@example.com'), + ).toBeVisible() + await userEvent.clear( + await screen.findByPlaceholderText('foobar@example.com'), + ) + await userEvent.type( + await await screen.findByPlaceholderText('foobar@example.com'), + 'valid@email.com', + ) + + // ensure information for slack renders correctly + await expect( + await screen.findByPlaceholderText('slack user ID'), + ).toBeVisible() + await expect(await screen.findByLabelText('Third Item')).toBeVisible() + await userEvent.clear(await screen.findByLabelText('Third Item')) + await userEvent.type(await screen.findByLabelText('Third Item'), '@slack') + + // Try to submit without all feilds complete + const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) + await userEvent.click(submitButton) + + // Name field + await userEvent.clear(await screen.findByLabelText('Name')) + await userEvent.type(await screen.findByLabelText('Name'), 'TEST') + + const retryButton = await screen.findByRole('button', { name: /RETRY/i }) + await userEvent.click(retryButton) + }, +} + +export const DisabledField: Story = { + args: { + userID: defaultConfig.user.id, + title: 'Create New Contact Method', + subtitle: 'Create New Contact Method Subtitle', + }, + play: async () => { + // Open option select + await userEvent.click(await screen.findByLabelText('Dest Type')) + + // Ensure disabled + await expect( + await screen.findByLabelText( + 'Send alert status updates (not supported for this type)', + ), + ).toBeDisabled() + }, +} diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx new file mode 100644 index 0000000000..e0f0ad123a --- /dev/null +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react' +import { useMutation, useQuery, gql } from 'urql' + +import { fieldErrors, nonFieldErrors } from '../util/errutil' +import FormDialog from '../dialogs/FormDialog' +import UserContactMethodForm from './UserContactMethodFormDest' +import { useContactMethodTypes } from '../util/RequireConfig' +import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material' +import DialogContentError from '../dialogs/components/DialogContentError' +import { DestinationInput } from '../../schema' + +type Value = { + name: string + dest: DestinationInput + statusUpdates: boolean +} + +const createMutation = gql` + mutation CreateUserContactMethodInput($input: CreateUserContactMethodInput!) { + createUserContactMethod(input: $input) { + id + } + } +` + +const userConflictQuery = gql` + query UserConflictCheck($input: UserSearchOptions) { + users(input: $input) { + nodes { + id + name + } + } + } +` + +const noSuspense = { suspense: false } + +export default function UserContactMethodCreateDialogDest(props: { + userID: string + onClose: (contactMethodID?: string) => void + title?: string + subtitle?: string +}): React.ReactNode { + const defaultType = useContactMethodTypes()[0] // will be sorted by priority, and enabled first + + // values for contact method form + const [CMValue, setCMValue] = useState({ + name: '', + dest: { + type: defaultType.type, + values: [], + }, + statusUpdates: false, + }) + + const [{ data, fetching: queryLoading }] = useQuery({ + query: userConflictQuery, + variables: { + input: { + dest: CMValue.dest, + }, + }, + pause: + !CMValue.dest || + !CMValue.dest.values.length || + !CMValue.dest.values[0].value, + context: noSuspense, + }) + + const [createCMStatus, createCM] = useMutation(createMutation) + + if (!defaultType.enabled) { + // default type will be the first enabled type, so if it's not enabled, none are enabled + return ( + props.onClose()}> + No Contact Types Available + + + + + + ) + } + + const { fetching, error } = createCMStatus + const { title = 'Create New Contact Method', subtitle } = props + + let fieldErrs = fieldErrors(error) + if (!queryLoading && data?.users?.nodes?.length > 0) { + fieldErrs = fieldErrs.map((err) => { + if ( + err.message === 'contact method already exists for that type and value' + ) { + return { + ...err, + message: `${err.message}: ${data.users.nodes[0].name}`, + helpLink: `/users/${data.users.nodes[0].id}`, + } + } + return err + }) + } + + const form = ( + setCMValue(CMValue)} + value={CMValue} + /> + ) + + return ( + + createCM( + { + input: { + name: CMValue.name, + dest: CMValue.dest, + enableStatusUpdates: CMValue.statusUpdates, + userID: props.userID, + newUserNotificationRule: { + delayMinutes: 0, + }, + }, + }, + { additionalTypenames: ['UserContactMethod', 'User'] }, + ).then((result) => { + if (result.error) { + return + } + props.onClose(result.data.createUserContactMethod.id) + }) + } + form={form} + /> + ) +} diff --git a/web/src/app/users/UserContactMethodEditDialog.tsx b/web/src/app/users/UserContactMethodEditDialog.tsx index b35ce91190..d9b6ac9aa6 100644 --- a/web/src/app/users/UserContactMethodEditDialog.tsx +++ b/web/src/app/users/UserContactMethodEditDialog.tsx @@ -6,6 +6,8 @@ import UserContactMethodForm from './UserContactMethodForm' import { pick } from 'lodash' import { useQuery } from 'urql' import { ContactMethodType, StatusUpdateState } from '../../schema' +import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest' +import { useExpFlag } from '../util/useExpFlag' const query = gql` query ($id: ID!) { @@ -32,13 +34,15 @@ type Value = { statusUpdates?: StatusUpdateState } -export default function UserContactMethodEditDialog({ - onClose, - contactMethodID, -}: { +type UserContactMethodEditDialogProps = { onClose: () => void contactMethodID: string -}): React.ReactNode { +} + +function UserContactMethodEditDialog({ + onClose, + contactMethodID, +}: UserContactMethodEditDialogProps): React.ReactNode { const [value, setValue] = useState(null) const [{ data, fetching }] = useQuery({ query, @@ -96,3 +100,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..17c7291896 --- /dev/null +++ b/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest' +import { expect, screen, userEvent, waitFor } from '@storybook/test' +import { handleDefaultConfig } from '../storybook/graphql' +import { useArgs } from '@storybook/preview-api' +import { HttpResponse, graphql } from 'msw' + +const meta = { + title: 'users/UserContactMethodEditDialogDest', + component: UserContactMethodEditDialogDest, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [ + handleDefaultConfig, + graphql.query('UserContactMethod', ({ variables: vars }) => { + return HttpResponse.json({ + data: { + userContactMethod: + vars.id === '00000000-0000-0000-0000-000000000001' + ? { + id: '00000000-0000-0000-0000-000000000001', + name: 'test_cm', + dest: { + type: 'single-field', + values: [ + { + fieldID: 'phone-number', + value: '+11235555555', + __typename: 'FieldValuePair', + }, + ], + __typename: 'Destination', + }, + statusUpdates: 'DISABLED', + __typename: 'UserContactMethod', + } + : { + id: '00000000-0000-0000-0000-000000000002', + name: 'test_cm', + dest: { + type: 'triple-field', + values: [ + { + fieldID: 'first-field', + value: '+12225559999', + __typename: 'FieldValuePair', + }, + { + fieldID: 'second-field', + value: 'multiemail@target.com', + __typename: 'FieldValuePair', + }, + { + fieldID: 'third-field', + value: 'slackID', + __typename: 'FieldValuePair', + }, + ], + __typename: 'Destination', + }, + statusUpdates: 'DISABLED', + __typename: 'UserContactMethod', + }, + }, + }) + }), + graphql.mutation('UpdateUserContactMethod', () => { + return HttpResponse.json({ + data: { + updateUserContactMethod: true, + }, + }) + }), + ], + }, + }, + render: function Component(args) { + const [, setArgs] = useArgs() + const onClose = (): void => { + if (args.onClose) args.onClose() + setArgs({ value: '' }) + } + return + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SingleField: Story = { + args: { + contactMethodID: '00000000-0000-0000-0000-000000000001', + }, + play: async () => { + // ensure correct values are displayed and disabled + await waitFor(async () => { + await expect(await screen.findByLabelText('Name')).toBeVisible() + await expect(await screen.findByLabelText('Dest Type')).toHaveAttribute( + 'aria-disabled', + 'true', + ) + await expect( + await screen.findByPlaceholderText('11235550123'), + ).toBeDisabled() + await expect( + await screen.findByLabelText( + 'Send alert status updates (not supported for this type)', + ), + ).toBeDisabled() + }) + }, +} + +export const MultiField: Story = { + args: { + contactMethodID: '00000000-0000-0000-0000-000000000002', + }, + play: async () => { + // ensure correct values are displayed and disabled for all fields + await waitFor(async () => { + await expect(await screen.findByLabelText('Name')).toBeVisible() + await expect(await screen.findByLabelText('Dest Type')).toBeVisible() + await expect(await screen.findByLabelText('First Item')).toBeVisible() + await expect(await screen.findByLabelText('Second Item')).toBeVisible() + await expect(await screen.findByLabelText('Third Item')).toBeVisible() + + await expect(await screen.findByLabelText('Dest Type')).toHaveAttribute( + 'aria-disabled', + 'true', + ) + await expect( + await screen.findByPlaceholderText('11235550123'), + ).toBeDisabled() + await expect( + await screen.findByPlaceholderText('foobar@example.com'), + ).toBeDisabled() + await expect( + await screen.findByPlaceholderText('slack user ID'), + ).toBeDisabled() + }) + + await expect( + await screen.findByLabelText('Send alert status updates'), + ).not.toBeDisabled() + + // ensure we can update name and submit + await userEvent.clear(await screen.findByLabelText('Name')) + await userEvent.type(await screen.findByLabelText('Name'), 'changed') + + const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) + await userEvent.click(submitButton) + }, +} diff --git a/web/src/app/users/UserContactMethodEditDialogDest.tsx b/web/src/app/users/UserContactMethodEditDialogDest.tsx new file mode 100644 index 0000000000..44db3c1cec --- /dev/null +++ b/web/src/app/users/UserContactMethodEditDialogDest.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import { fieldErrors, nonFieldErrors } from '../util/errutil' +import FormDialog from '../dialogs/FormDialog' +import UserContactMethodForm from './UserContactMethodFormDest' +import { gql, useMutation, useQuery } from 'urql' +import { DestinationInput, UserContactMethod } from '../../schema' + +const query = gql` + query UserContactMethod($id: ID!) { + userContactMethod(id: $id) { + id + name + dest { + type + values { + fieldID + value + } + } + statusUpdates + } + } +` + +const mutation = gql` + mutation UpdateUserContactMethod($input: UpdateUserContactMethodInput!) { + updateUserContactMethod(input: $input) + } +` + +type Value = { + name: string + dest: DestinationInput + statusUpdates: boolean +} + +export default function UserContactMethodEditDialogDest({ + onClose, + contactMethodID, +}: { + onClose: () => void + contactMethodID: string +}): React.ReactNode { + const [{ data }] = useQuery<{ + userContactMethod: UserContactMethod + }>({ + query, + variables: { id: contactMethodID }, + }) + if (!data) throw new Error('no data') // shouldn't happen since we're using suspense + const [value, setValue] = useState({ + name: data.userContactMethod.name, + dest: data.userContactMethod.dest, + statusUpdates: + data.userContactMethod.statusUpdates === 'ENABLED' || + data.userContactMethod.statusUpdates === 'ENABLED_FORCED', + }) + const [status, commit] = useMutation(mutation) + const { error, fetching } = status + if (!data) throw new Error('no data') // shouldn't happen since we're using suspense + + const fieldErrs = fieldErrors(error) + + console.log(onClose) + console.log(contactMethodID) + + return ( + { + commit( + { + input: { + name: value.name, + enableStatusUpdates: value.statusUpdates, + id: contactMethodID, + }, + }, + { additionalTypenames: ['UserContactMethod'] }, + ).then((result) => { + if (result.error) return + onClose() + }) + }} + form={ + setValue(value)} + /> + } + /> + ) +} From e8984bd7befc9108f2c08d37734f5a96963e8670 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 9 Feb 2024 14:08:55 -0600 Subject: [PATCH 02/31] fix storybook rendering --- web/src/app/dialogs/FormDialog.jsx | 4 ++++ .../UserContactMethodCreateDialogDest.stories.tsx | 14 +++++++++++++- .../users/UserContactMethodCreateDialogDest.tsx | 9 ++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/web/src/app/dialogs/FormDialog.jsx b/web/src/app/dialogs/FormDialog.jsx index 6cad50a138..501e653943 100644 --- a/web/src/app/dialogs/FormDialog.jsx +++ b/web/src/app/dialogs/FormDialog.jsx @@ -62,6 +62,7 @@ function FormDialog(props) { onBack, fullHeight, disableBackdropClose, + disablePortal, ...dialogProps } = props @@ -178,6 +179,7 @@ function FormDialog(props) { const fs = fullScreen || (!isWideScreen && !confirm) return ( + return ( + + ) }, } satisfies Meta diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index e0f0ad123a..693b56f85f 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -41,6 +41,8 @@ export default function UserContactMethodCreateDialogDest(props: { onClose: (contactMethodID?: string) => void title?: string subtitle?: string + + disablePortal?: boolean }): React.ReactNode { const defaultType = useContactMethodTypes()[0] // will be sorted by priority, and enabled first @@ -73,7 +75,11 @@ export default function UserContactMethodCreateDialogDest(props: { if (!defaultType.enabled) { // default type will be the first enabled type, so if it's not enabled, none are enabled return ( - props.onClose()}> + props.onClose()} + > No Contact Types Available @@ -115,6 +121,7 @@ export default function UserContactMethodCreateDialogDest(props: { return ( Date: Fri, 9 Feb 2024 14:13:23 -0600 Subject: [PATCH 03/31] add missing fields for status updates --- web/src/app/util/RequireConfig.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/app/util/RequireConfig.tsx b/web/src/app/util/RequireConfig.tsx index d9da7fbc92..a2556d0f72 100644 --- a/web/src/app/util/RequireConfig.tsx +++ b/web/src/app/util/RequireConfig.tsx @@ -70,6 +70,8 @@ const expDestQuery = gql` enabled disabledMessage userDisclaimer + supportsStatusUpdates + statusUpdatesRequired isContactMethod isEPTarget From 40a9cb8f2af5e8c507f3bfe25ad9fc7ef6a91201 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 9 Feb 2024 14:16:31 -0600 Subject: [PATCH 04/31] fix edit dialog stories render --- .../UserContactMethodEditDialogDest.stories.tsx | 14 +++++++++++++- .../app/users/UserContactMethodEditDialogDest.tsx | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx b/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx index 17c7291896..c73774fea3 100644 --- a/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx @@ -11,6 +11,12 @@ const meta = { component: UserContactMethodEditDialogDest, tags: ['autodocs'], parameters: { + docs: { + story: { + inline: false, + iframeHeight: 500, + }, + }, msw: { handlers: [ handleDefaultConfig, @@ -82,7 +88,13 @@ const meta = { if (args.onClose) args.onClose() setArgs({ value: '' }) } - return + return ( + + ) }, } satisfies Meta diff --git a/web/src/app/users/UserContactMethodEditDialogDest.tsx b/web/src/app/users/UserContactMethodEditDialogDest.tsx index 44db3c1cec..2c632c849b 100644 --- a/web/src/app/users/UserContactMethodEditDialogDest.tsx +++ b/web/src/app/users/UserContactMethodEditDialogDest.tsx @@ -37,9 +37,11 @@ type Value = { export default function UserContactMethodEditDialogDest({ onClose, contactMethodID, + disablePortal, }: { onClose: () => void contactMethodID: string + disablePortal?: boolean }): React.ReactNode { const [{ data }] = useQuery<{ userContactMethod: UserContactMethod @@ -68,6 +70,7 @@ export default function UserContactMethodEditDialogDest({ { From c0d436c578308637aaf72a60c53a373dafc8ef32 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 9 Feb 2024 15:38:32 -0600 Subject: [PATCH 05/31] start adding validation --- graphql2/graphqlapp/app.go | 10 +- graphql2/graphqlapp/contactmethod.go | 4 + graphql2/graphqlapp/destinationvalidation.go | 157 ++++++++++++++++++ notification/slack/channel.go | 31 ++++ notification/slack/user.go | 18 ++ notification/slack/usergroup.go | 26 +++ .../app/users/UserContactMethodFormDest.tsx | 2 +- 7 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 graphql2/graphqlapp/destinationvalidation.go diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index effe656fd7..8432189fc4 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -225,7 +225,15 @@ func (a *App) Handler() http.Handler { var multiFieldErr validation.MultiFieldError var singleFieldErr validation.FieldError - if errors.As(err, &multiFieldErr) { + var destErr *DestinationValidationError + if errors.As(err, &destErr) { + gqlErr.Message = err.Error() + gqlErr.Extensions = map[string]interface{}{ + "isFieldError": true, + "fieldName": "dest", + "details": destErr, + } + } else if errors.As(err, &multiFieldErr) { errs := make([]fieldErr, len(multiFieldErr.FieldErrors())) for i, err := range multiFieldErr.FieldErrors() { errs[i].FieldName = err.Field() diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index dbd224aae0..8384d12f8b 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -139,6 +139,10 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C cfg := config.FromContext(ctx) if input.Dest != nil { + err := (*App)(m).ValidateDestination(ctx, input.Dest) + if err != nil { + return nil, err + } t, v := CompatDestToCMTypeVal(*input.Dest) input.Type = &t input.Value = &v diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go new file mode 100644 index 0000000000..0ea182681d --- /dev/null +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -0,0 +1,157 @@ +package graphqlapp + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/target/goalert/config" + "github.com/target/goalert/graphql2" + "github.com/target/goalert/validation" + "github.com/target/goalert/validation/validate" +) + +type FieldValueError struct { + FieldID string `json:"fieldID"` + Message string `json:"message"` +} + +type DestinationValidationError struct { + Type string `json:"type"` // always "DestinationValidationError" + FieldErrors []FieldValueError `json:"fieldErrors"` +} + +func (e *DestinationValidationError) Error() string { + return "DestinationValidationError" +} +func (e *DestinationValidationError) ClientError() bool { return true } + +func newDestErr(errs ...error) error { + var destErr DestinationValidationError + destErr.Type = "DestinationValidationError" + for _, err := range errs { + if f, ok := err.(validation.FieldError); ok { + destErr.FieldErrors = append(destErr.FieldErrors, FieldValueError{ + FieldID: f.Field(), + Message: f.Reason(), + }) + continue + } + + // non-field error, just return the bunch + return errors.Join(errs...) + } + + return &destErr +} + +// ValidateDestination will validate a destination input. +// +// In the future this will be a call to the plugin system. +func (a *App) ValidateDestination(ctx context.Context, dest *graphql2.DestinationInput) error { + cfg := config.FromContext(ctx) + switch dest.Type { + case destTwilioSMS: + phone := dest.FieldValue(fieldPhoneNumber) + err := validate.Phone(fieldPhoneNumber, phone) + if err != nil { + return newDestErr(err) + } + return nil + case destTwilioVoice: + phone := dest.FieldValue(fieldPhoneNumber) + err := validate.Phone(fieldPhoneNumber, phone) + if err != nil { + return newDestErr(err) + } + return nil + case destSlackChan: + chanID := dest.FieldValue(fieldSlackChanID) + err := a.SlackStore.ValidateChannel(ctx, fieldSlackChanID, chanID) + if err != nil { + return newDestErr(err) + } + + return nil + case destSlackDM: + userID := dest.FieldValue(fieldSlackUserID) + err := a.SlackStore.ValidateUser(ctx, fieldSlackUserID, userID) + if err != nil { + return newDestErr(err) + } + return nil + case destSlackUG: + ugID := dest.FieldValue(fieldSlackUGID) + chanID := dest.FieldValue(fieldSlackChanID) + err := a.SlackStore.ValidateUserGroup(ctx, fieldSlackUGID, ugID) + if err != nil { + return newDestErr(err) + } + + err = a.SlackStore.ValidateChannel(ctx, fieldSlackChanID, chanID) + if err != nil { + return newDestErr(err) + } + + return nil + case destSMTP: + email := dest.FieldValue(fieldEmailAddress) + err := validate.Email(fieldEmailAddress, email) + if err != nil { + return newDestErr(err) + } + return nil + case destWebhook: + url := dest.FieldValue(fieldWebhookURL) + err := validate.AbsoluteURL(fieldWebhookURL, url) + if err != nil { + return newDestErr(err) + } + if !cfg.ValidWebhookURL(url) { + return newDestErr(validation.NewFieldError(fieldWebhookURL, "url is not allowed by administator")) + } + return nil + case destSchedule: // must be valid UUID and exist + _, err := validate.ParseUUID(fieldScheduleID, dest.FieldValue(fieldScheduleID)) + if err != nil { + return newDestErr(err) + } + + _, err = a.ScheduleStore.FindOne(ctx, dest.FieldValue(fieldScheduleID)) + if errors.Is(err, sql.ErrNoRows) { + return newDestErr(validation.NewFieldError(fieldScheduleID, "schedule does not exist")) + } + + return err // return any other error + case destRotation: // must be valid UUID and exist + rotID := dest.FieldValue(fieldRotationID) + _, err := validate.ParseUUID(fieldRotationID, rotID) + if err != nil { + return newDestErr(err) + } + _, err = a.RotationStore.FindRotation(ctx, rotID) + if errors.Is(err, sql.ErrNoRows) { + return newDestErr(validation.NewFieldError(fieldRotationID, "rotation does not exist")) + } + + return err // return any other error + + case destUser: // must be valid UUID and exist + userID := dest.FieldValue(fieldUserID) + uid, err := validate.ParseUUID(fieldUserID, userID) + if err != nil { + return newDestErr(err) + } + check, err := a.UserStore.UserExists(ctx) + if err != nil { + return fmt.Errorf("get user existance checker: %w", err) + } + if !check.UserExistsUUID(uid) { + return newDestErr(validation.NewFieldError(fieldUserID, "user does not exist")) + } + return nil + } + + return fmt.Errorf("unsupported destination type: %s", dest.Type) +} diff --git a/notification/slack/channel.go b/notification/slack/channel.go index 37ce074cb2..ff45550c32 100644 --- a/notification/slack/channel.go +++ b/notification/slack/channel.go @@ -75,6 +75,8 @@ type Channel struct { ID string Name string TeamID string + + IsArchived bool } // User contains information about a Slack user. @@ -133,6 +135,34 @@ func mapError(ctx context.Context, err error) error { return err } +func (s *ChannelSender) ValidateChannel(ctx context.Context, fieldID, id string) error { + err := permission.LimitCheckAny(ctx, permission.User, permission.System) + if err != nil { + return err + } + + s.chanMx.Lock() + defer s.chanMx.Unlock() + res, ok := s.chanCache.Get(id) + if !ok { + res, err = s.loadChannel(ctx, id) + if err != nil { + if rootMsg(err) == "channel_not_found" { + return validation.NewFieldError(fieldID, "Channel does not exist, is private (need to invite goalert bot).") + } + + return err + } + s.chanCache.Add(id, res) + } + + if res.IsArchived { + return validation.NewFieldError(fieldID, "Channel is archived.") + } + + return nil +} + // Channel will lookup a single Slack channel for the bot. func (s *ChannelSender) Channel(ctx context.Context, channelID string) (*Channel, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) @@ -232,6 +262,7 @@ func (s *ChannelSender) loadChannel(ctx context.Context, channelID string) (*Cha ch.ID = resp.ID ch.Name = "#" + resp.Name + ch.IsArchived = resp.IsArchived return nil }) diff --git a/notification/slack/user.go b/notification/slack/user.go index e775da2e6f..5eb5243de2 100644 --- a/notification/slack/user.go +++ b/notification/slack/user.go @@ -6,8 +6,26 @@ import ( "github.com/slack-go/slack" "github.com/target/goalert/permission" + "github.com/target/goalert/validation" ) +func (s *ChannelSender) ValidateUser(ctx context.Context, fieldID, id string) error { + err := permission.LimitCheckAny(ctx, permission.User, permission.System) + if err != nil { + return err + } + + _, err = s.User(ctx, id) + if rootMsg(err) == "user_not_found" { + return validation.NewFieldError(fieldID, "user not found") + } + if err != nil { + return fmt.Errorf("validate user: %w", err) + } + + return nil +} + // User will lookup a single Slack user. func (s *ChannelSender) User(ctx context.Context, id string) (*User, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) diff --git a/notification/slack/usergroup.go b/notification/slack/usergroup.go index 001e6ae02d..59ab44b179 100644 --- a/notification/slack/usergroup.go +++ b/notification/slack/usergroup.go @@ -16,8 +16,34 @@ type UserGroup struct { Handle string } +func (s *ChannelSender) ValidateUserGroup(ctx context.Context, fieldID, id string) error { + ug, err := s._UserGroup(ctx, id) + if err != nil { + return err + } + + if ug == nil { + return validation.NewFieldError(fieldID, "user group not found") + } + + return nil +} + // User will lookup a single Slack user group. func (s *ChannelSender) UserGroup(ctx context.Context, id string) (*UserGroup, error) { + ug, err := s._UserGroup(ctx, id) + if err != nil { + return nil, err + } + if ug == nil { + return nil, validation.NewGenericError("invalid user group id") + } + + return ug, nil +} + +// User will lookup a single Slack user group. +func (s *ChannelSender) _UserGroup(ctx context.Context, id string) (*UserGroup, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) if err != nil { return nil, err diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 5bfc69d858..430fb24ee7 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -18,7 +18,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array + errors?: Array<{ fieldID: string; message: string }> disabled?: boolean edit?: boolean From 21259cc939fcd22159268f184d01dfb1d3f2be73 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 12 Feb 2024 13:57:09 -0600 Subject: [PATCH 06/31] normalize ValidateDestination errors --- graphql2/graphqlapp/app.go | 21 +-- graphql2/graphqlapp/contactmethod.go | 3 +- graphql2/graphqlapp/destinationvalidation.go | 152 +++++++++++-------- notification/slack/channel.go | 6 +- notification/slack/user.go | 4 +- notification/slack/usergroup.go | 4 +- 6 files changed, 107 insertions(+), 83 deletions(-) diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index 8432189fc4..848f9bab0b 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -153,7 +153,16 @@ func isGQLValidation(gqlErr *gqlerror.Error) bool { return false } - return code == errcode.ValidationFailed || code == errcode.ParseFailed + switch code { + case errcode.ValidationFailed, errcode.ParseFailed: + // These are gqlgen validation errors. + return true + case ErrCodeInvalidDestType, ErrCodeInvalidDestValue: + // These are destination validation errors. + return true + } + + return false } func (a *App) Handler() http.Handler { @@ -225,15 +234,7 @@ func (a *App) Handler() http.Handler { var multiFieldErr validation.MultiFieldError var singleFieldErr validation.FieldError - var destErr *DestinationValidationError - if errors.As(err, &destErr) { - gqlErr.Message = err.Error() - gqlErr.Extensions = map[string]interface{}{ - "isFieldError": true, - "fieldName": "dest", - "details": destErr, - } - } else if errors.As(err, &multiFieldErr) { + if errors.As(err, &multiFieldErr) { errs := make([]fieldErr, len(multiFieldErr.FieldErrors())) for i, err := range multiFieldErr.FieldErrors() { errs[i].FieldName = err.Field() diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index 8384d12f8b..f811436be4 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -139,8 +139,7 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C cfg := config.FromContext(ctx) if input.Dest != nil { - err := (*App)(m).ValidateDestination(ctx, input.Dest) - if err != nil { + if ok, err := (*App)(m).ValidateDestination(ctx, "dest", input.Dest); !ok { return nil, err } t, v := CompatDestToCMTypeVal(*input.Dest) diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go index 0ea182681d..87656271fe 100644 --- a/graphql2/graphqlapp/destinationvalidation.go +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -6,152 +6,176 @@ import ( "errors" "fmt" + "github.com/99designs/gqlgen/graphql" "github.com/target/goalert/config" "github.com/target/goalert/graphql2" + "github.com/target/goalert/permission" "github.com/target/goalert/validation" "github.com/target/goalert/validation/validate" + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/gqlerror" ) -type FieldValueError struct { - FieldID string `json:"fieldID"` - Message string `json:"message"` -} - -type DestinationValidationError struct { - Type string `json:"type"` // always "DestinationValidationError" - FieldErrors []FieldValueError `json:"fieldErrors"` -} +const ( + ErrCodeInvalidDestType = "INVALID_DESTINATION_TYPE" + ErrCodeInvalidDestValue = "INVALID_DESTINATION_FIELD_VALUE" +) -func (e *DestinationValidationError) Error() string { - return "DestinationValidationError" -} -func (e *DestinationValidationError) ClientError() bool { return true } - -func newDestErr(errs ...error) error { - var destErr DestinationValidationError - destErr.Type = "DestinationValidationError" - for _, err := range errs { - if f, ok := err.(validation.FieldError); ok { - destErr.FieldErrors = append(destErr.FieldErrors, FieldValueError{ - FieldID: f.Field(), - Message: f.Reason(), - }) - continue - } - - // non-field error, just return the bunch - return errors.Join(errs...) +// addDestFieldError will add a destination field error to the current request, and return +// the original error if it is not a destination field validation error. +func addDestFieldError(ctx context.Context, parentField, fieldID string, err error) error { + if permission.IsPermissionError(err) { + // request level, return as is + return err + } + if !validation.IsClientError(err) { + // internal error, return as is + return err } - return &destErr + p := graphql.GetPath(ctx) + p = append(p, + ast.PathName(parentField), + ast.PathName("values"), // DestinationInput.Values + ast.PathName(fieldID), + ) + + graphql.AddError(ctx, &gqlerror.Error{ + Message: err.Error(), + Path: p, + Extensions: map[string]interface{}{ + "code": ErrCodeInvalidDestValue, + }, + }) + + return nil } // ValidateDestination will validate a destination input. // // In the future this will be a call to the plugin system. -func (a *App) ValidateDestination(ctx context.Context, dest *graphql2.DestinationInput) error { +func (a *App) ValidateDestination(ctx context.Context, fieldName string, dest *graphql2.DestinationInput) (ok bool, err error) { cfg := config.FromContext(ctx) switch dest.Type { case destTwilioSMS: phone := dest.FieldValue(fieldPhoneNumber) err := validate.Phone(fieldPhoneNumber, phone) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err) } - return nil + return true, nil case destTwilioVoice: phone := dest.FieldValue(fieldPhoneNumber) err := validate.Phone(fieldPhoneNumber, phone) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err) } - return nil + return true, nil case destSlackChan: chanID := dest.FieldValue(fieldSlackChanID) - err := a.SlackStore.ValidateChannel(ctx, fieldSlackChanID, chanID) + err := a.SlackStore.ValidateChannel(ctx, chanID) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, err) } - return nil + return true, nil case destSlackDM: userID := dest.FieldValue(fieldSlackUserID) - err := a.SlackStore.ValidateUser(ctx, fieldSlackUserID, userID) - if err != nil { - return newDestErr(err) + if err := a.SlackStore.ValidateUser(ctx, userID); err != nil { + return false, addDestFieldError(ctx, fieldName, fieldSlackUserID, err) } - return nil + return true, nil case destSlackUG: ugID := dest.FieldValue(fieldSlackUGID) - chanID := dest.FieldValue(fieldSlackChanID) - err := a.SlackStore.ValidateUserGroup(ctx, fieldSlackUGID, ugID) + userErr := a.SlackStore.ValidateUserGroup(ctx, ugID) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldSlackUGID, userErr) } - err = a.SlackStore.ValidateChannel(ctx, fieldSlackChanID, chanID) + chanID := dest.FieldValue(fieldSlackChanID) + chanErr := a.SlackStore.ValidateChannel(ctx, chanID) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, chanErr) } - return nil + return true, nil case destSMTP: email := dest.FieldValue(fieldEmailAddress) err := validate.Email(fieldEmailAddress, email) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldEmailAddress, err) } - return nil + return true, nil case destWebhook: url := dest.FieldValue(fieldWebhookURL) err := validate.AbsoluteURL(fieldWebhookURL, url) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, err) } if !cfg.ValidWebhookURL(url) { - return newDestErr(validation.NewFieldError(fieldWebhookURL, "url is not allowed by administator")) + return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, validation.NewGenericError("url is not allowed by administator")) } - return nil + return true, nil case destSchedule: // must be valid UUID and exist _, err := validate.ParseUUID(fieldScheduleID, dest.FieldValue(fieldScheduleID)) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err) } _, err = a.ScheduleStore.FindOne(ctx, dest.FieldValue(fieldScheduleID)) if errors.Is(err, sql.ErrNoRows) { - return newDestErr(validation.NewFieldError(fieldScheduleID, "schedule does not exist")) + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, validation.NewGenericError("schedule does not exist")) + } + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err) } - return err // return any other error + return true, nil case destRotation: // must be valid UUID and exist rotID := dest.FieldValue(fieldRotationID) _, err := validate.ParseUUID(fieldRotationID, rotID) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldRotationID, err) } _, err = a.RotationStore.FindRotation(ctx, rotID) if errors.Is(err, sql.ErrNoRows) { - return newDestErr(validation.NewFieldError(fieldRotationID, "rotation does not exist")) + return false, addDestFieldError(ctx, fieldName, fieldRotationID, validation.NewGenericError("rotation does not exist")) + } + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldRotationID, err) } - return err // return any other error - + return true, nil case destUser: // must be valid UUID and exist userID := dest.FieldValue(fieldUserID) uid, err := validate.ParseUUID(fieldUserID, userID) if err != nil { - return newDestErr(err) + return false, addDestFieldError(ctx, fieldName, fieldUserID, err) } check, err := a.UserStore.UserExists(ctx) if err != nil { - return fmt.Errorf("get user existance checker: %w", err) + return false, fmt.Errorf("get user existance checker: %w", err) } if !check.UserExistsUUID(uid) { - return newDestErr(validation.NewFieldError(fieldUserID, "user does not exist")) + return false, addDestFieldError(ctx, fieldName, fieldUserID, validation.NewGenericError("user does not exist")) } - return nil + return true, nil } - return fmt.Errorf("unsupported destination type: %s", dest.Type) + // unsupported destination type + p := graphql.GetPath(ctx) + p = append(p, + ast.PathName(fieldName), + ast.PathName("type"), + ) + + graphql.AddError(ctx, &gqlerror.Error{ + Message: "unsupported destination type", + Path: p, + Extensions: map[string]interface{}{ + "code": ErrCodeInvalidDestType, + }, + }) + + return false, nil } diff --git a/notification/slack/channel.go b/notification/slack/channel.go index ff45550c32..644df27378 100644 --- a/notification/slack/channel.go +++ b/notification/slack/channel.go @@ -135,7 +135,7 @@ func mapError(ctx context.Context, err error) error { return err } -func (s *ChannelSender) ValidateChannel(ctx context.Context, fieldID, id string) error { +func (s *ChannelSender) ValidateChannel(ctx context.Context, id string) error { err := permission.LimitCheckAny(ctx, permission.User, permission.System) if err != nil { return err @@ -148,7 +148,7 @@ func (s *ChannelSender) ValidateChannel(ctx context.Context, fieldID, id string) res, err = s.loadChannel(ctx, id) if err != nil { if rootMsg(err) == "channel_not_found" { - return validation.NewFieldError(fieldID, "Channel does not exist, is private (need to invite goalert bot).") + return validation.NewGenericError("Channel does not exist, is private (need to invite goalert bot).") } return err @@ -157,7 +157,7 @@ func (s *ChannelSender) ValidateChannel(ctx context.Context, fieldID, id string) } if res.IsArchived { - return validation.NewFieldError(fieldID, "Channel is archived.") + return validation.NewGenericError("Channel is archived.") } return nil diff --git a/notification/slack/user.go b/notification/slack/user.go index 5eb5243de2..9e299ff21e 100644 --- a/notification/slack/user.go +++ b/notification/slack/user.go @@ -9,7 +9,7 @@ import ( "github.com/target/goalert/validation" ) -func (s *ChannelSender) ValidateUser(ctx context.Context, fieldID, id string) error { +func (s *ChannelSender) ValidateUser(ctx context.Context, id string) error { err := permission.LimitCheckAny(ctx, permission.User, permission.System) if err != nil { return err @@ -17,7 +17,7 @@ func (s *ChannelSender) ValidateUser(ctx context.Context, fieldID, id string) er _, err = s.User(ctx, id) if rootMsg(err) == "user_not_found" { - return validation.NewFieldError(fieldID, "user not found") + return validation.NewGenericError("user not found") } if err != nil { return fmt.Errorf("validate user: %w", err) diff --git a/notification/slack/usergroup.go b/notification/slack/usergroup.go index 59ab44b179..984c57d289 100644 --- a/notification/slack/usergroup.go +++ b/notification/slack/usergroup.go @@ -16,14 +16,14 @@ type UserGroup struct { Handle string } -func (s *ChannelSender) ValidateUserGroup(ctx context.Context, fieldID, id string) error { +func (s *ChannelSender) ValidateUserGroup(ctx context.Context, id string) error { ug, err := s._UserGroup(ctx, id) if err != nil { return err } if ug == nil { - return validation.NewFieldError(fieldID, "user group not found") + return validation.NewGenericError("user group not found") } return nil From 7c5b4df35ca4f3c5c0932389e3d32c672609e194 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 12 Feb 2024 14:11:13 -0600 Subject: [PATCH 07/31] revert type change --- web/src/app/users/UserContactMethodFormDest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 430fb24ee7..5bfc69d858 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -18,7 +18,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array<{ fieldID: string; message: string }> + errors?: Array disabled?: boolean edit?: boolean From 39aaa04f1c9c100e6daa82ad678bace1638380d7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 09:34:52 -0600 Subject: [PATCH 08/31] pass validation errors --- graphql2/graphqlapp/contactmethod.go | 2 +- graphql2/graphqlapp/destinationvalidation.go | 6 +- web/src/app/selection/DestinationField.tsx | 17 ++-- .../UserContactMethodCreateDialogDest.tsx | 34 +++----- .../app/users/UserContactMethodFormDest.tsx | 6 +- web/src/app/util/errutil.test.ts | 41 ++++++++++ web/src/app/util/errutil.ts | 80 +++++++++++++++++++ web/src/errors.d.ts | 27 +++++++ 8 files changed, 179 insertions(+), 34 deletions(-) create mode 100644 web/src/app/util/errutil.test.ts create mode 100644 web/src/errors.d.ts diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index f811436be4..c926d7a0d6 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -139,7 +139,7 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C cfg := config.FromContext(ctx) if input.Dest != nil { - if ok, err := (*App)(m).ValidateDestination(ctx, "dest", input.Dest); !ok { + if ok, err := (*App)(m).ValidateDestination(ctx, "input.dest", input.Dest); !ok { return nil, err } t, v := CompatDestToCMTypeVal(*input.Dest) diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go index 87656271fe..dc09ba24d5 100644 --- a/graphql2/graphqlapp/destinationvalidation.go +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "github.com/99designs/gqlgen/graphql" "github.com/target/goalert/config" @@ -34,8 +35,11 @@ func addDestFieldError(ctx context.Context, parentField, fieldID string, err err } p := graphql.GetPath(ctx) + parentParts := strings.Split(parentField, ".") + for _, part := range parentParts { + p = append(p, ast.PathName(part)) + } p = append(p, - ast.PathName(parentField), ast.PathName("values"), // DestinationInput.Values ast.PathName(fieldID), ) diff --git a/web/src/app/selection/DestinationField.tsx b/web/src/app/selection/DestinationField.tsx index 0a5bcf1fc5..1ec3321e51 100644 --- a/web/src/app/selection/DestinationField.tsx +++ b/web/src/app/selection/DestinationField.tsx @@ -4,6 +4,7 @@ import DestinationInputDirect from './DestinationInputDirect' import { useDestinationType } from '../util/RequireConfig' import DestinationSearchSelect from './DestinationSearchSelect' import { Grid } from '@mui/material' +import { InputFieldError } from '../util/errutil' export type DestinationFieldProps = { value: FieldValueInput[] @@ -12,12 +13,7 @@ export type DestinationFieldProps = { disabled?: boolean - destFieldErrors?: DestFieldError[] -} - -export interface DestFieldError { - fieldID: string - message: string + destFieldErrors?: InputFieldError[] } export default function DestinationField( @@ -45,9 +41,14 @@ export default function DestinationField( props.onChange(newValues) } + // getFieldID returns the last segment of the path. + const getFieldID = (err: InputFieldError): string => + err.path.split('.').pop() || '' + const fieldErrMsg = - props.destFieldErrors?.find((err) => err.fieldID === field.fieldID) - ?.message || '' + props.destFieldErrors?.find( + (err) => getFieldID(err) === field.fieldID, + )?.message || '' if (field.isSearchSelectable) return ( diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index 693b56f85f..ba38b90cc7 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { useMutation, useQuery, gql } from 'urql' -import { fieldErrors, nonFieldErrors } from '../util/errutil' +import { useErrorsForDest } from '../util/errutil' import FormDialog from '../dialogs/FormDialog' import UserContactMethodForm from './UserContactMethodFormDest' import { useContactMethodTypes } from '../util/RequireConfig' @@ -72,6 +72,12 @@ export default function UserContactMethodCreateDialogDest(props: { const [createCMStatus, createCM] = useMutation(createMutation) + const [destTypeErr, destFieldErrs, otherErrs] = useErrorsForDest( + createCMStatus.error, + CMValue.dest.type, + 'createUserContactMethod.input.dest', + ) + if (!defaultType.enabled) { // default type will be the first enabled type, so if it's not enabled, none are enabled return ( @@ -91,29 +97,13 @@ export default function UserContactMethodCreateDialogDest(props: { ) } - const { fetching, error } = createCMStatus const { title = 'Create New Contact Method', subtitle } = props - let fieldErrs = fieldErrors(error) - if (!queryLoading && data?.users?.nodes?.length > 0) { - fieldErrs = fieldErrs.map((err) => { - if ( - err.message === 'contact method already exists for that type and value' - ) { - return { - ...err, - message: `${err.message}: ${data.users.nodes[0].name}`, - helpLink: `/users/${data.users.nodes[0].id}`, - } - } - return err - }) - } - const form = ( setCMValue(CMValue)} value={CMValue} /> @@ -125,8 +115,8 @@ export default function UserContactMethodCreateDialogDest(props: { data-cy='create-form' title={title} subTitle={subtitle} - loading={fetching} - errors={nonFieldErrors(error)} + loading={createCMStatus.fetching} + errors={otherErrs} onClose={props.onClose} // wrapped to prevent event from passing into createCM onSubmit={() => diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 5bfc69d858..4300517b50 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -5,7 +5,7 @@ import React from 'react' import { DestinationInput } from '../../schema' import { FormContainer, FormField } from '../forms' import { renderMenuItem } from '../selection/DisableableMenuItem' -import { FieldError } from '../util/errutil' +import { InputFieldError, SimpleError } from '../util/errutil' import DestinationField from '../selection/DestinationField' import { useContactMethodTypes } from '../util/RequireConfig' @@ -18,7 +18,8 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array + typeError?: SimpleError + fieldErrors?: Array disabled?: boolean edit?: boolean @@ -98,6 +99,7 @@ export default function UserContactMethodFormDest( destType={value.dest.type} component={DestinationField} disabled={edit} + destFieldErrors={props.fieldErrors} /> diff --git a/web/src/app/util/errutil.test.ts b/web/src/app/util/errutil.test.ts new file mode 100644 index 0000000000..080d116b9e --- /dev/null +++ b/web/src/app/util/errutil.test.ts @@ -0,0 +1,41 @@ +import { GraphQLError } from 'graphql' +import { getInputFieldErrors } from './errutil' + +describe('getInputFieldErrors', () => { + it('should split errors by path', () => { + const resp = { + name: 'ignored', + message: 'ignored', + graphQLErrors: [ + { + message: 'test1', + path: ['foo', 'bar', 'dest', 'type'], + extensions: { + code: 'INVALID_DESTINATION_TYPE', + }, + }, + { + message: 'test2', + path: ['foo', 'bar', 'dest', 'values', 'example-field'], + extensions: { + code: 'INVALID_DESTINATION_FIELD_VALUE', + }, + }, + ] as unknown as GraphQLError[], + } + + const [inputFieldErrors, otherErrors] = getInputFieldErrors( + ['foo.bar.dest.type', 'foo.bar.dest.values.example-field'], + resp, + ) + + expect(inputFieldErrors).toHaveLength(2) + expect(inputFieldErrors[0].message).toEqual('test1') + expect(inputFieldErrors[0].path).toEqual('foo.bar.dest.type') + expect(inputFieldErrors[1].message).toEqual('test2') + expect(inputFieldErrors[1].path).toEqual( + 'foo.bar.dest.values.example-field', + ) + expect(otherErrors).toHaveLength(0) + }) +}) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 0ad389ee6b..2bf91795f5 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -2,6 +2,11 @@ import _ from 'lodash' import { ApolloError } from '@apollo/client' import { GraphQLError } from 'graphql/error' import { CombinedError } from 'urql' +import { + INVALID_DESTINATION_FIELD_VALUE, + INVALID_DESTINATION_TYPE, +} from '../../errors.d' +import { useDestinationType } from './RequireConfig' const mapName = (name: string): string => _.camelCase(name).replace(/Id$/, 'ID') @@ -34,6 +39,81 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { ) } +export type SimpleError = { + message: string +} + +export type InputFieldError = { + message: string + path: string +} + +/** + * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. + * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. + * + * @param filterPaths - a list of paths to filter errors by + * @param err - the CombinedError to filter + */ +export function getInputFieldErrors( + filterPaths: string[], + err: CombinedError, +): [InputFieldError[], SimpleError[]] { + const inputFieldErrors = [] as InputFieldError[] + const otherErrors = [] as SimpleError[] + err.graphQLErrors.forEach((err) => { + if (!err.path) { + otherErrors.push({ message: err.message }) + return + } + const code = err.extensions?.code + if ( + // only support known error codes + code !== INVALID_DESTINATION_TYPE && + code !== INVALID_DESTINATION_FIELD_VALUE + ) { + otherErrors.push({ message: err.message }) + return + } + + const fullPath = err.path.join('.') + + if (!filterPaths.includes(fullPath)) { + otherErrors.push({ message: err.message }) + return + } + + inputFieldErrors.push({ message: err.message, path: fullPath }) + }) + + return [inputFieldErrors, otherErrors] +} + +/** + * useErrorsForDest returns the errors for a destination type and field path from a CombinedError. + * The first return value is the error for the destination type, if any. + * The second return value is a list of errors for the destination fields, if any. + * The third return value is a list of other errors, if any. + */ +export function useErrorsForDest( + err: CombinedError | undefined, + destType: string, + destFieldPath: string, // the path of the DestinationInput field +): [SimpleError | undefined, InputFieldError[], SimpleError[]] { + const cfg = useDestinationType(destType) // need to call hook before conditional return + if (!err) return [undefined, [], []] + + const [destTypeErrs] = getInputFieldErrors([destFieldPath + '.type'], err) + + const paths = cfg.requiredFields.map( + (f) => `${destFieldPath}.values.${f.fieldID}`, + ) + + const [destFieldErrs, otherErrs] = getInputFieldErrors(paths, err) + + return [destTypeErrs[0] || undefined, destFieldErrs, otherErrs] +} + export interface FieldError extends Error { field: string details: { [x: string]: string } diff --git a/web/src/errors.d.ts b/web/src/errors.d.ts new file mode 100644 index 0000000000..91971c9843 --- /dev/null +++ b/web/src/errors.d.ts @@ -0,0 +1,27 @@ +/** + * INVALID_DESTINATION_TYPE is returned when the selected destination type is not valid, or is not allowed. + */ +export const INVALID_DESTINATION_TYPE = 'INVALID_DESTINATION_TYPE' + +/** + * INVALID_DESTINATION_FIELD_VALUE is returned when the value of a field on a destination is invalid. + */ +export const INVALID_DESTINATION_FIELD_VALUE = 'INVALID_DESTINATION_FIELD_VALUE' + +type KnownErrorCode = INVALID_DESTINATION_TYPE | INVALID_DESTINATION_FIELD_VALUE + +export type InvalidDestTypeError = { + message: string + path: readonly (string | number)[] + extensions: { + code: INVALID_DESTINATION_TYPE + } +} + +export type InvalidFieldValueError = { + message: string + path: readonly (string | number)[] + extensions: { + code: INVALID_DESTINATION_FIELD_VALUE + } +} From 6bdc3fc8c39bc717d455da348087ec771a6aee9e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 10:47:19 -0600 Subject: [PATCH 09/31] cleanup error messages --- graphql2/graphqlapp/destinationvalidation.go | 11 ++++++++++- notification/slack/user.go | 2 +- web/src/app/selection/DestinationField.tsx | 10 ++++++++-- .../users/UserContactMethodCreateDialogDest.tsx | 16 ++++++++++++---- web/src/app/util/errutil.ts | 2 +- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go index dc09ba24d5..2ea11baf9c 100644 --- a/graphql2/graphqlapp/destinationvalidation.go +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -22,6 +22,15 @@ const ( ErrCodeInvalidDestValue = "INVALID_DESTINATION_FIELD_VALUE" ) +func errReason(err error) string { + var fErr validation.FieldError + if errors.As(err, &fErr) { + return fErr.Reason() + } + + return err.Error() +} + // addDestFieldError will add a destination field error to the current request, and return // the original error if it is not a destination field validation error. func addDestFieldError(ctx context.Context, parentField, fieldID string, err error) error { @@ -45,7 +54,7 @@ func addDestFieldError(ctx context.Context, parentField, fieldID string, err err ) graphql.AddError(ctx, &gqlerror.Error{ - Message: err.Error(), + Message: errReason(err), Path: p, Extensions: map[string]interface{}{ "code": ErrCodeInvalidDestValue, diff --git a/notification/slack/user.go b/notification/slack/user.go index 9e299ff21e..646846a89b 100644 --- a/notification/slack/user.go +++ b/notification/slack/user.go @@ -17,7 +17,7 @@ func (s *ChannelSender) ValidateUser(ctx context.Context, id string) error { _, err = s.User(ctx, id) if rootMsg(err) == "user_not_found" { - return validation.NewGenericError("user not found") + return validation.NewGenericError("User not found.") } if err != nil { return fmt.Errorf("validate user: %w", err) diff --git a/web/src/app/selection/DestinationField.tsx b/web/src/app/selection/DestinationField.tsx index 1ec3321e51..0e0d7e0c12 100644 --- a/web/src/app/selection/DestinationField.tsx +++ b/web/src/app/selection/DestinationField.tsx @@ -16,6 +16,11 @@ export type DestinationFieldProps = { destFieldErrors?: InputFieldError[] } +function capFirstLetter(s: string): string { + if (s.length === 0) return s + return s.charAt(0).toUpperCase() + s.slice(1) +} + export default function DestinationField( props: DestinationFieldProps, ): React.ReactNode { @@ -45,10 +50,11 @@ export default function DestinationField( const getFieldID = (err: InputFieldError): string => err.path.split('.').pop() || '' - const fieldErrMsg = + const fieldErrMsg = capFirstLetter( props.destFieldErrors?.find( (err) => getFieldID(err) === field.fieldID, - )?.message || '' + )?.message || '', + ) if (field.isSearchSelectable) return ( diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index ba38b90cc7..0c7b459e74 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { useMutation, useQuery, gql } from 'urql' +import React, { useEffect, useState } from 'react' +import { useMutation, useQuery, gql, CombinedError } from 'urql' import { useErrorsForDest } from '../util/errutil' import FormDialog from '../dialogs/FormDialog' @@ -47,7 +47,7 @@ export default function UserContactMethodCreateDialogDest(props: { const defaultType = useContactMethodTypes()[0] // will be sorted by priority, and enabled first // values for contact method form - const [CMValue, setCMValue] = useState({ + const [CMValue, _setCMValue] = useState({ name: '', dest: { type: defaultType.type, @@ -55,6 +55,11 @@ export default function UserContactMethodCreateDialogDest(props: { }, statusUpdates: false, }) + const [createErr, setCreateErr] = useState(null) + const setCMValue = (newValue: Value): void => { + _setCMValue(newValue) + setCreateErr(null) + } const [{ data, fetching: queryLoading }] = useQuery({ query: userConflictQuery, @@ -71,9 +76,12 @@ export default function UserContactMethodCreateDialogDest(props: { }) const [createCMStatus, createCM] = useMutation(createMutation) + useEffect(() => { + setCreateErr(createCMStatus.error || null) + }, [createCMStatus.error]) const [destTypeErr, destFieldErrs, otherErrs] = useErrorsForDest( - createCMStatus.error, + createErr, CMValue.dest.type, 'createUserContactMethod.input.dest', ) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 2bf91795f5..6553cad5ea 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -96,7 +96,7 @@ export function getInputFieldErrors( * The third return value is a list of other errors, if any. */ export function useErrorsForDest( - err: CombinedError | undefined, + err: CombinedError | undefined | null, destType: string, destFieldPath: string, // the path of the DestinationInput field ): [SimpleError | undefined, InputFieldError[], SimpleError[]] { From 54cb6a9b58c3310b4d064cb824b1212e151d5214 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 10:56:46 -0600 Subject: [PATCH 10/31] remove conflict query for now --- .../UserContactMethodCreateDialogDest.tsx | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index 0c7b459e74..212b070eda 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { useMutation, useQuery, gql, CombinedError } from 'urql' +import { useMutation, gql, CombinedError } from 'urql' import { useErrorsForDest } from '../util/errutil' import FormDialog from '../dialogs/FormDialog' @@ -23,19 +23,6 @@ const createMutation = gql` } ` -const userConflictQuery = gql` - query UserConflictCheck($input: UserSearchOptions) { - users(input: $input) { - nodes { - id - name - } - } - } -` - -const noSuspense = { suspense: false } - export default function UserContactMethodCreateDialogDest(props: { userID: string onClose: (contactMethodID?: string) => void @@ -61,19 +48,7 @@ export default function UserContactMethodCreateDialogDest(props: { setCreateErr(null) } - const [{ data, fetching: queryLoading }] = useQuery({ - query: userConflictQuery, - variables: { - input: { - dest: CMValue.dest, - }, - }, - pause: - !CMValue.dest || - !CMValue.dest.values.length || - !CMValue.dest.values[0].value, - context: noSuspense, - }) + // TODO: useQuery for userConflictQuery const [createCMStatus, createCM] = useMutation(createMutation) useEffect(() => { From 5da053d4aaac8b146b4b26165ecbd487968e8611 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 10:57:48 -0600 Subject: [PATCH 11/31] remove exp flag stub --- .../UserContactMethodCreateDialogDest.stories.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index 72317c2abd..0b02682018 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -31,17 +31,6 @@ const meta = { }, }) }), - graphql.query('useExpFlag', () => { - return HttpResponse.json({ - data: { - users: { - nodes: [ - { name: defaultConfig.user.name, id: defaultConfig.user.id }, - ], - }, - }, - }) - }), graphql.mutation('CreateUserContactMethodInput', () => { return HttpResponse.json({ data: { From 31ee0e27762bba2aba1cf5c2e1aa6b8d2eaf8f14 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 11:00:42 -0600 Subject: [PATCH 12/31] update exp flag handler --- .../users/UserContactMethodCreateDialogDest.stories.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index 0b02682018..f9a33959f0 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -2,7 +2,11 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' import UserContactMethodCreateDialogDest from './UserContactMethodCreateDialogDest' import { expect, userEvent, waitFor, screen } from '@storybook/test' -import { handleDefaultConfig, defaultConfig } from '../storybook/graphql' +import { + handleDefaultConfig, + defaultConfig, + handleExpFlags, +} from '../storybook/graphql' import { useArgs } from '@storybook/preview-api' import { HttpResponse, graphql } from 'msw' @@ -20,6 +24,7 @@ const meta = { msw: { handlers: [ handleDefaultConfig, + handleExpFlags('dest-types'), graphql.query('UserConflictCheck', () => { return HttpResponse.json({ data: { From 8ae4239145a5d267e11eef0cfb65a9560d7c881e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 11:24:19 -0600 Subject: [PATCH 13/31] fix dest type err --- web/src/app/forms/FormField.jsx | 2 + ...rContactMethodCreateDialogDest.stories.tsx | 68 ++++++++++++++++--- .../app/users/UserContactMethodFormDest.tsx | 11 +++ web/src/app/util/errutil.ts | 26 +++++-- 4 files changed, 92 insertions(+), 15 deletions(-) diff --git a/web/src/app/forms/FormField.jsx b/web/src/app/forms/FormField.jsx index b146b3c602..ec1daf7f2a 100644 --- a/web/src/app/forms/FormField.jsx +++ b/web/src/app/forms/FormField.jsx @@ -287,6 +287,8 @@ FormField.propTypes = { multiple: p.bool, + destFieldErrors: p.array, + destType: p.string, options: p.arrayOf( p.shape({ diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index f9a33959f0..715b6eec6a 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -36,15 +36,48 @@ const meta = { }, }) }), - graphql.mutation('CreateUserContactMethodInput', () => { - return HttpResponse.json({ - data: { - createUserContactMethod: { - id: '00000000-0000-0000-0000-000000000000', + graphql.mutation( + 'CreateUserContactMethodInput', + ({ variables: vars }) => { + if (vars.input.name === 'error-test') { + return HttpResponse.json({ + data: null, + errors: [ + { + message: 'This is a field-error', + path: [ + 'createUserContactMethod', + 'input', + 'dest', + 'values', + 'phone-number', + ], + extensions: { + code: 'INVALID_DESTINATION_FIELD_VALUE', + }, + }, + { + message: 'This indicates an invalid destination type', + path: ['createUserContactMethod', 'input', 'dest', 'type'], + extensions: { + code: 'INVALID_DESTINATION_TYPE', + }, + }, + { + message: 'This is a generic error', + }, + ], + }) + } + return HttpResponse.json({ + data: { + createUserContactMethod: { + id: '00000000-0000-0000-0000-000000000000', + }, }, - }, - }) - }), + }) + }, + ), graphql.query('ValidateDestination', ({ variables: vars }) => { return HttpResponse.json({ data: { @@ -181,3 +214,22 @@ export const DisabledField: Story = { ).toBeDisabled() }, } + +export const ErrorField: Story = { + args: { + userID: defaultConfig.user.id, + title: 'Create New Contact Method', + subtitle: 'Create New Contact Method Subtitle', + }, + + play: async () => { + await userEvent.type(await screen.findByLabelText('Name'), 'error-test') + await userEvent.type( + await screen.findByPlaceholderText('11235550123'), + '123', + ) + + const submitButton = await screen.findByText('Submit') + await userEvent.click(submitButton) + }, +} diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 4300517b50..16f0921a1b 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -50,6 +50,17 @@ export default function UserContactMethodFormDest( return ( { if (newValue.dest.type === value.dest.type) { diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 6553cad5ea..a504509da8 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -48,6 +48,10 @@ export type InputFieldError = { path: string } +function isGraphQLError(e: SimpleError | GraphQLError): e is GraphQLError { + return !!(e as GraphQLError).extensions +} + /** * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. @@ -57,13 +61,18 @@ export type InputFieldError = { */ export function getInputFieldErrors( filterPaths: string[], - err: CombinedError, + errs: (GraphQLError | SimpleError)[] | undefined | null, ): [InputFieldError[], SimpleError[]] { + if (!errs) return [[], []] const inputFieldErrors = [] as InputFieldError[] const otherErrors = [] as SimpleError[] - err.graphQLErrors.forEach((err) => { + errs.forEach((err) => { + if (!isGraphQLError(err)) { + otherErrors.push(err) + return + } if (!err.path) { - otherErrors.push({ message: err.message }) + otherErrors.push(err) return } const code = err.extensions?.code @@ -72,14 +81,14 @@ export function getInputFieldErrors( code !== INVALID_DESTINATION_TYPE && code !== INVALID_DESTINATION_FIELD_VALUE ) { - otherErrors.push({ message: err.message }) + otherErrors.push(err) return } const fullPath = err.path.join('.') if (!filterPaths.includes(fullPath)) { - otherErrors.push({ message: err.message }) + otherErrors.push(err) return } @@ -103,13 +112,16 @@ export function useErrorsForDest( const cfg = useDestinationType(destType) // need to call hook before conditional return if (!err) return [undefined, [], []] - const [destTypeErrs] = getInputFieldErrors([destFieldPath + '.type'], err) + const [destTypeErrs, nonDestTypeErrs] = getInputFieldErrors( + [destFieldPath + '.type'], + err.graphQLErrors, + ) const paths = cfg.requiredFields.map( (f) => `${destFieldPath}.values.${f.fieldID}`, ) - const [destFieldErrs, otherErrs] = getInputFieldErrors(paths, err) + const [destFieldErrs, otherErrs] = getInputFieldErrors(paths, nonDestTypeErrs) return [destTypeErrs[0] || undefined, destFieldErrs, otherErrs] } From 8505a554a4b370956451b107bd6eac71e99d17ff Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 11:24:49 -0600 Subject: [PATCH 14/31] update unit test --- web/src/app/util/errutil.test.ts | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/web/src/app/util/errutil.test.ts b/web/src/app/util/errutil.test.ts index 080d116b9e..9701e7f856 100644 --- a/web/src/app/util/errutil.test.ts +++ b/web/src/app/util/errutil.test.ts @@ -3,26 +3,22 @@ import { getInputFieldErrors } from './errutil' describe('getInputFieldErrors', () => { it('should split errors by path', () => { - const resp = { - name: 'ignored', - message: 'ignored', - graphQLErrors: [ - { - message: 'test1', - path: ['foo', 'bar', 'dest', 'type'], - extensions: { - code: 'INVALID_DESTINATION_TYPE', - }, + const resp = [ + { + message: 'test1', + path: ['foo', 'bar', 'dest', 'type'], + extensions: { + code: 'INVALID_DESTINATION_TYPE', }, - { - message: 'test2', - path: ['foo', 'bar', 'dest', 'values', 'example-field'], - extensions: { - code: 'INVALID_DESTINATION_FIELD_VALUE', - }, + }, + { + message: 'test2', + path: ['foo', 'bar', 'dest', 'values', 'example-field'], + extensions: { + code: 'INVALID_DESTINATION_FIELD_VALUE', }, - ] as unknown as GraphQLError[], - } + }, + ] as unknown as GraphQLError[] const [inputFieldErrors, otherErrors] = getInputFieldErrors( ['foo.bar.dest.type', 'foo.bar.dest.values.example-field'], From 460853083bcd7fe45f7458b2e6f4db635a97677b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 11:29:28 -0600 Subject: [PATCH 15/31] update create dialog story tests --- ...rContactMethodCreateDialogDest.stories.tsx | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index 715b6eec6a..e93c155e9a 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -1,7 +1,7 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' import UserContactMethodCreateDialogDest from './UserContactMethodCreateDialogDest' -import { expect, userEvent, waitFor, screen } from '@storybook/test' +import { expect, userEvent, waitFor, within } from '@storybook/test' import { handleDefaultConfig, defaultConfig, @@ -116,23 +116,24 @@ export const SingleField: Story = { title: 'Create New Contact Method', subtitle: 'Create New Contact Method Subtitle', }, - play: async () => { - await userEvent.clear(await screen.findByPlaceholderText('11235550123')) + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.clear(await canvas.findByPlaceholderText('11235550123')) await waitFor(async () => { await userEvent.type( - await screen.findByPlaceholderText('11235550123'), + await canvas.findByPlaceholderText('11235550123'), '12225558989', ) }) - const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) + const submitButton = await canvas.findByRole('button', { name: /SUBMIT/i }) await userEvent.click(submitButton) - await userEvent.clear(await screen.findByLabelText('Name')) - await userEvent.type(await screen.findByLabelText('Name'), 'TEST') + await userEvent.clear(await canvas.findByLabelText('Name')) + await userEvent.type(await canvas.findByLabelText('Name'), 'TEST') - const retryButton = await screen.findByRole('button', { name: /RETRY/i }) + const retryButton = await canvas.findByRole('button', { name: /RETRY/i }) await userEvent.click(retryButton) }, } @@ -143,55 +144,56 @@ export const MultiField: Story = { title: 'Create New Contact Method', subtitle: 'Create New Contact Method Subtitle', }, - play: async () => { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) // Select the next Dest Type - await userEvent.click(await screen.findByLabelText('Dest Type')) + await userEvent.click(await canvas.findByLabelText('Dest Type')) await userEvent.click( - await screen.findByText('Multi Field Destination Type'), + await canvas.findByText('Multi Field Destination Type'), ) // ensure information for phone number renders correctly - await userEvent.clear(await screen.findByLabelText('First Item')) + await userEvent.clear(await canvas.findByLabelText('First Item')) await waitFor(async () => { await userEvent.type( - await screen.findByLabelText('First Item'), + await canvas.findByLabelText('First Item'), '12225558989', ) }) await waitFor(async () => { - await expect(await screen.findByTestId('CheckIcon')).toBeVisible() + await expect(await canvas.findByTestId('CheckIcon')).toBeVisible() }) // ensure information for email renders correctly await expect( - await screen.findByPlaceholderText('foobar@example.com'), + await canvas.findByPlaceholderText('foobar@example.com'), ).toBeVisible() await userEvent.clear( - await screen.findByPlaceholderText('foobar@example.com'), + await canvas.findByPlaceholderText('foobar@example.com'), ) await userEvent.type( - await await screen.findByPlaceholderText('foobar@example.com'), + await await canvas.findByPlaceholderText('foobar@example.com'), 'valid@email.com', ) // ensure information for slack renders correctly await expect( - await screen.findByPlaceholderText('slack user ID'), + await canvas.findByPlaceholderText('slack user ID'), ).toBeVisible() - await expect(await screen.findByLabelText('Third Item')).toBeVisible() - await userEvent.clear(await screen.findByLabelText('Third Item')) - await userEvent.type(await screen.findByLabelText('Third Item'), '@slack') + await expect(await canvas.findByLabelText('Third Item')).toBeVisible() + await userEvent.clear(await canvas.findByLabelText('Third Item')) + await userEvent.type(await canvas.findByLabelText('Third Item'), '@slack') // Try to submit without all feilds complete - const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) + const submitButton = await canvas.findByRole('button', { name: /SUBMIT/i }) await userEvent.click(submitButton) // Name field - await userEvent.clear(await screen.findByLabelText('Name')) - await userEvent.type(await screen.findByLabelText('Name'), 'TEST') + await userEvent.clear(await canvas.findByLabelText('Name')) + await userEvent.type(await canvas.findByLabelText('Name'), 'TEST') - const retryButton = await screen.findByRole('button', { name: /RETRY/i }) + const retryButton = await canvas.findByRole('button', { name: /RETRY/i }) await userEvent.click(retryButton) }, } @@ -202,13 +204,14 @@ export const DisabledField: Story = { title: 'Create New Contact Method', subtitle: 'Create New Contact Method Subtitle', }, - play: async () => { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) // Open option select - await userEvent.click(await screen.findByLabelText('Dest Type')) + await userEvent.click(await canvas.findByLabelText('Dest Type')) // Ensure disabled await expect( - await screen.findByLabelText( + await canvas.findByLabelText( 'Send alert status updates (not supported for this type)', ), ).toBeDisabled() @@ -222,14 +225,28 @@ export const ErrorField: Story = { subtitle: 'Create New Contact Method Subtitle', }, - play: async () => { - await userEvent.type(await screen.findByLabelText('Name'), 'error-test') + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + await userEvent.type(await canvas.findByLabelText('Name'), 'error-test') await userEvent.type( - await screen.findByPlaceholderText('11235550123'), + await canvas.findByPlaceholderText('11235550123'), '123', ) - const submitButton = await screen.findByText('Submit') + const submitButton = await canvas.findByText('Submit') await userEvent.click(submitButton) + + await waitFor(async () => { + await expect( + await canvas.findByText('This is a field-error'), + ).toBeVisible() + await expect( + await canvas.findByText('This indicates an invalid destination type'), + ).toBeVisible() + await expect( + await canvas.findByText('This is a generic error'), + ).toBeVisible() + }) }, } From e3968db515d3055013977c2fafc7eadfd3488980 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 13 Feb 2024 11:29:36 -0600 Subject: [PATCH 16/31] use full name --- web/src/app/users/UserContactMethodFormDest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 16f0921a1b..9c3ef4b8cd 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -86,6 +86,7 @@ export default function UserContactMethodFormDest( Date: Tue, 13 Feb 2024 11:33:06 -0600 Subject: [PATCH 17/31] revert edit dialog changes for this PR --- .../app/users/UserContactMethodEditDialog.tsx | 26 +-- ...serContactMethodEditDialogDest.stories.tsx | 167 ------------------ .../users/UserContactMethodEditDialogDest.tsx | 102 ----------- 3 files changed, 5 insertions(+), 290 deletions(-) delete mode 100644 web/src/app/users/UserContactMethodEditDialogDest.stories.tsx delete mode 100644 web/src/app/users/UserContactMethodEditDialogDest.tsx diff --git a/web/src/app/users/UserContactMethodEditDialog.tsx b/web/src/app/users/UserContactMethodEditDialog.tsx index d9b6ac9aa6..b35ce91190 100644 --- a/web/src/app/users/UserContactMethodEditDialog.tsx +++ b/web/src/app/users/UserContactMethodEditDialog.tsx @@ -6,8 +6,6 @@ import UserContactMethodForm from './UserContactMethodForm' import { pick } from 'lodash' import { useQuery } from 'urql' import { ContactMethodType, StatusUpdateState } from '../../schema' -import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest' -import { useExpFlag } from '../util/useExpFlag' const query = gql` query ($id: ID!) { @@ -34,15 +32,13 @@ type Value = { statusUpdates?: StatusUpdateState } -type UserContactMethodEditDialogProps = { - onClose: () => void - contactMethodID: string -} - -function UserContactMethodEditDialog({ +export default function UserContactMethodEditDialog({ onClose, contactMethodID, -}: UserContactMethodEditDialogProps): React.ReactNode { +}: { + onClose: () => void + contactMethodID: string +}): React.ReactNode { const [value, setValue] = useState(null) const [{ data, fetching }] = useQuery({ query, @@ -100,15 +96,3 @@ 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 deleted file mode 100644 index c73774fea3..0000000000 --- a/web/src/app/users/UserContactMethodEditDialogDest.stories.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React from 'react' -import type { Meta, StoryObj } from '@storybook/react' -import UserContactMethodEditDialogDest from './UserContactMethodEditDialogDest' -import { expect, screen, userEvent, waitFor } from '@storybook/test' -import { handleDefaultConfig } from '../storybook/graphql' -import { useArgs } from '@storybook/preview-api' -import { HttpResponse, graphql } from 'msw' - -const meta = { - title: 'users/UserContactMethodEditDialogDest', - component: UserContactMethodEditDialogDest, - tags: ['autodocs'], - parameters: { - docs: { - story: { - inline: false, - iframeHeight: 500, - }, - }, - msw: { - handlers: [ - handleDefaultConfig, - graphql.query('UserContactMethod', ({ variables: vars }) => { - return HttpResponse.json({ - data: { - userContactMethod: - vars.id === '00000000-0000-0000-0000-000000000001' - ? { - id: '00000000-0000-0000-0000-000000000001', - name: 'test_cm', - dest: { - type: 'single-field', - values: [ - { - fieldID: 'phone-number', - value: '+11235555555', - __typename: 'FieldValuePair', - }, - ], - __typename: 'Destination', - }, - statusUpdates: 'DISABLED', - __typename: 'UserContactMethod', - } - : { - id: '00000000-0000-0000-0000-000000000002', - name: 'test_cm', - dest: { - type: 'triple-field', - values: [ - { - fieldID: 'first-field', - value: '+12225559999', - __typename: 'FieldValuePair', - }, - { - fieldID: 'second-field', - value: 'multiemail@target.com', - __typename: 'FieldValuePair', - }, - { - fieldID: 'third-field', - value: 'slackID', - __typename: 'FieldValuePair', - }, - ], - __typename: 'Destination', - }, - statusUpdates: 'DISABLED', - __typename: 'UserContactMethod', - }, - }, - }) - }), - graphql.mutation('UpdateUserContactMethod', () => { - return HttpResponse.json({ - data: { - updateUserContactMethod: true, - }, - }) - }), - ], - }, - }, - render: function Component(args) { - const [, setArgs] = useArgs() - const onClose = (): void => { - if (args.onClose) args.onClose() - setArgs({ value: '' }) - } - return ( - - ) - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const SingleField: Story = { - args: { - contactMethodID: '00000000-0000-0000-0000-000000000001', - }, - play: async () => { - // ensure correct values are displayed and disabled - await waitFor(async () => { - await expect(await screen.findByLabelText('Name')).toBeVisible() - await expect(await screen.findByLabelText('Dest Type')).toHaveAttribute( - 'aria-disabled', - 'true', - ) - await expect( - await screen.findByPlaceholderText('11235550123'), - ).toBeDisabled() - await expect( - await screen.findByLabelText( - 'Send alert status updates (not supported for this type)', - ), - ).toBeDisabled() - }) - }, -} - -export const MultiField: Story = { - args: { - contactMethodID: '00000000-0000-0000-0000-000000000002', - }, - play: async () => { - // ensure correct values are displayed and disabled for all fields - await waitFor(async () => { - await expect(await screen.findByLabelText('Name')).toBeVisible() - await expect(await screen.findByLabelText('Dest Type')).toBeVisible() - await expect(await screen.findByLabelText('First Item')).toBeVisible() - await expect(await screen.findByLabelText('Second Item')).toBeVisible() - await expect(await screen.findByLabelText('Third Item')).toBeVisible() - - await expect(await screen.findByLabelText('Dest Type')).toHaveAttribute( - 'aria-disabled', - 'true', - ) - await expect( - await screen.findByPlaceholderText('11235550123'), - ).toBeDisabled() - await expect( - await screen.findByPlaceholderText('foobar@example.com'), - ).toBeDisabled() - await expect( - await screen.findByPlaceholderText('slack user ID'), - ).toBeDisabled() - }) - - await expect( - await screen.findByLabelText('Send alert status updates'), - ).not.toBeDisabled() - - // ensure we can update name and submit - await userEvent.clear(await screen.findByLabelText('Name')) - await userEvent.type(await screen.findByLabelText('Name'), 'changed') - - const submitButton = await screen.findByRole('button', { name: /SUBMIT/i }) - await userEvent.click(submitButton) - }, -} diff --git a/web/src/app/users/UserContactMethodEditDialogDest.tsx b/web/src/app/users/UserContactMethodEditDialogDest.tsx deleted file mode 100644 index 2c632c849b..0000000000 --- a/web/src/app/users/UserContactMethodEditDialogDest.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from 'react' -import { fieldErrors, nonFieldErrors } from '../util/errutil' -import FormDialog from '../dialogs/FormDialog' -import UserContactMethodForm from './UserContactMethodFormDest' -import { gql, useMutation, useQuery } from 'urql' -import { DestinationInput, UserContactMethod } from '../../schema' - -const query = gql` - query UserContactMethod($id: ID!) { - userContactMethod(id: $id) { - id - name - dest { - type - values { - fieldID - value - } - } - statusUpdates - } - } -` - -const mutation = gql` - mutation UpdateUserContactMethod($input: UpdateUserContactMethodInput!) { - updateUserContactMethod(input: $input) - } -` - -type Value = { - name: string - dest: DestinationInput - statusUpdates: boolean -} - -export default function UserContactMethodEditDialogDest({ - onClose, - contactMethodID, - disablePortal, -}: { - onClose: () => void - contactMethodID: string - disablePortal?: boolean -}): React.ReactNode { - const [{ data }] = useQuery<{ - userContactMethod: UserContactMethod - }>({ - query, - variables: { id: contactMethodID }, - }) - if (!data) throw new Error('no data') // shouldn't happen since we're using suspense - const [value, setValue] = useState({ - name: data.userContactMethod.name, - dest: data.userContactMethod.dest, - statusUpdates: - data.userContactMethod.statusUpdates === 'ENABLED' || - data.userContactMethod.statusUpdates === 'ENABLED_FORCED', - }) - const [status, commit] = useMutation(mutation) - const { error, fetching } = status - if (!data) throw new Error('no data') // shouldn't happen since we're using suspense - - const fieldErrs = fieldErrors(error) - - console.log(onClose) - console.log(contactMethodID) - - return ( - { - commit( - { - input: { - name: value.name, - enableStatusUpdates: value.statusUpdates, - id: contactMethodID, - }, - }, - { additionalTypenames: ['UserContactMethod'] }, - ).then((result) => { - if (result.error) return - onClose() - }) - }} - form={ - setValue(value)} - /> - } - /> - ) -} From 9a759f841a0ff7bbec184828d3599708f2586b48 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 12:50:19 -0600 Subject: [PATCH 18/31] standardize error codes --- graphql2/generated.go | 3 +- graphql2/graph/errorcodes.graphqls | 25 ++++ graphql2/graphqlapp/app.go | 8 +- graphql2/graphqlapp/destinationvalidation.go | 14 +-- graphql2/models_gen.go | 52 +++++++++ web/src/app/util/errutil.test.ts | 8 +- web/src/app/util/errutil.ts | 117 ++++++++++++++----- web/src/errors.d.ts | 27 ----- web/src/schema.d.ts | 2 + 9 files changed, 179 insertions(+), 77 deletions(-) create mode 100644 graphql2/graph/errorcodes.graphqls delete mode 100644 web/src/errors.d.ts diff --git a/graphql2/generated.go b/graphql2/generated.go index 34deef4371..208980a2ec 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -4760,7 +4760,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er return introspection.WrapTypeFromDef(ec.Schema(), ec.Schema().Types[name]), nil } -//go:embed "schema.graphql" "graph/_Mutation.graphqls" "graph/_Query.graphqls" "graph/_directives.graphqls" "graph/destinations.graphqls" "graph/escalationpolicy.graphqls" "graph/gqlapikeys.graphqls" +//go:embed "schema.graphql" "graph/_Mutation.graphqls" "graph/_Query.graphqls" "graph/_directives.graphqls" "graph/destinations.graphqls" "graph/errorcodes.graphqls" "graph/escalationpolicy.graphqls" "graph/gqlapikeys.graphqls" var sourcesFS embed.FS func sourceData(filename string) string { @@ -4777,6 +4777,7 @@ var sources = []*ast.Source{ {Name: "graph/_Query.graphqls", Input: sourceData("graph/_Query.graphqls"), BuiltIn: false}, {Name: "graph/_directives.graphqls", Input: sourceData("graph/_directives.graphqls"), BuiltIn: false}, {Name: "graph/destinations.graphqls", Input: sourceData("graph/destinations.graphqls"), BuiltIn: false}, + {Name: "graph/errorcodes.graphqls", Input: sourceData("graph/errorcodes.graphqls"), BuiltIn: false}, {Name: "graph/escalationpolicy.graphqls", Input: sourceData("graph/escalationpolicy.graphqls"), BuiltIn: false}, {Name: "graph/gqlapikeys.graphqls", Input: sourceData("graph/gqlapikeys.graphqls"), BuiltIn: false}, } diff --git a/graphql2/graph/errorcodes.graphqls b/graphql2/graph/errorcodes.graphqls new file mode 100644 index 0000000000..dd41c7844a --- /dev/null +++ b/graphql2/graph/errorcodes.graphqls @@ -0,0 +1,25 @@ + + +""" +Known error codes that the server can return. + +These values will be returned in the `extensions.code` field of the error response. +""" +enum ErrorCode { + + """ + The input value is invalid, the `path` field will contain the exact path to the invalid input. + + A separate error will be returned for each invalid field. + """ + INVALID_INPUT_VALUE + + """ + The `path` field contains the exact path to the DestinationInput that is invalid. + + The `extensions.fieldID` field contains the ID of the input field that is invalid. + + A separate error will be returned for each invalid field. + """ + INVALID_DEST_FIELD_VALUE +} diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index 848f9bab0b..c200e629ed 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -148,6 +148,11 @@ func isGQLValidation(gqlErr *gqlerror.Error) bool { return false } + _, ok := gqlErr.Extensions["code"].(graphql2.ErrorCode) + if ok { + return true + } + code, ok := gqlErr.Extensions["code"].(string) if !ok { return false @@ -157,9 +162,6 @@ func isGQLValidation(gqlErr *gqlerror.Error) bool { case errcode.ValidationFailed, errcode.ParseFailed: // These are gqlgen validation errors. return true - case ErrCodeInvalidDestType, ErrCodeInvalidDestValue: - // These are destination validation errors. - return true } return false diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go index 2ea11baf9c..b786542997 100644 --- a/graphql2/graphqlapp/destinationvalidation.go +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -17,11 +17,6 @@ import ( "github.com/vektah/gqlparser/v2/gqlerror" ) -const ( - ErrCodeInvalidDestType = "INVALID_DESTINATION_TYPE" - ErrCodeInvalidDestValue = "INVALID_DESTINATION_FIELD_VALUE" -) - func errReason(err error) string { var fErr validation.FieldError if errors.As(err, &fErr) { @@ -48,16 +43,13 @@ func addDestFieldError(ctx context.Context, parentField, fieldID string, err err for _, part := range parentParts { p = append(p, ast.PathName(part)) } - p = append(p, - ast.PathName("values"), // DestinationInput.Values - ast.PathName(fieldID), - ) graphql.AddError(ctx, &gqlerror.Error{ Message: errReason(err), Path: p, Extensions: map[string]interface{}{ - "code": ErrCodeInvalidDestValue, + "code": graphql2.ErrorCodeInvalidDestFieldValue, + "fieldID": fieldID, }, }) @@ -186,7 +178,7 @@ func (a *App) ValidateDestination(ctx context.Context, fieldName string, dest *g Message: "unsupported destination type", Path: p, Extensions: map[string]interface{}{ - "code": ErrCodeInvalidDestType, + "code": graphql2.ErrorCodeInvalidInputValue, }, }) diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index bf47d05580..59209e648e 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -971,6 +971,58 @@ func (e ConfigType) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +// Known error codes that the server can return. +// +// These values will be returned in the `extensions.code` field of the error response. +type ErrorCode string + +const ( + // The input value is invalid, the `path` field will contain the exact path to the invalid input. + // + // A separate error will be returned for each invalid field. + ErrorCodeInvalidInputValue ErrorCode = "INVALID_INPUT_VALUE" + // The `path` field contains the exact path to the DestinationInput that is invalid. + // + // The `extensions.fieldID` field contains the ID of the input field that is invalid. + // + // A separate error will be returned for each invalid field. + ErrorCodeInvalidDestFieldValue ErrorCode = "INVALID_DEST_FIELD_VALUE" +) + +var AllErrorCode = []ErrorCode{ + ErrorCodeInvalidInputValue, + ErrorCodeInvalidDestFieldValue, +} + +func (e ErrorCode) IsValid() bool { + switch e { + case ErrorCodeInvalidInputValue, ErrorCodeInvalidDestFieldValue: + return true + } + return false +} + +func (e ErrorCode) String() string { + return string(e) +} + +func (e *ErrorCode) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ErrorCode(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ErrorCode", str) + } + return nil +} + +func (e ErrorCode) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type IntegrationKeyType string const ( diff --git a/web/src/app/util/errutil.test.ts b/web/src/app/util/errutil.test.ts index 9701e7f856..4d6ef3924a 100644 --- a/web/src/app/util/errutil.test.ts +++ b/web/src/app/util/errutil.test.ts @@ -8,14 +8,14 @@ describe('getInputFieldErrors', () => { message: 'test1', path: ['foo', 'bar', 'dest', 'type'], extensions: { - code: 'INVALID_DESTINATION_TYPE', + code: 'INVALID_INPUT_VALUE', }, }, { message: 'test2', path: ['foo', 'bar', 'dest', 'values', 'example-field'], extensions: { - code: 'INVALID_DESTINATION_FIELD_VALUE', + code: 'INVALID_INPUT_VALUE', }, }, ] as unknown as GraphQLError[] @@ -27,9 +27,9 @@ describe('getInputFieldErrors', () => { expect(inputFieldErrors).toHaveLength(2) expect(inputFieldErrors[0].message).toEqual('test1') - expect(inputFieldErrors[0].path).toEqual('foo.bar.dest.type') + expect(inputFieldErrors[0].path.join('.')).toEqual('foo.bar.dest.type') expect(inputFieldErrors[1].message).toEqual('test2') - expect(inputFieldErrors[1].path).toEqual( + expect(inputFieldErrors[1].path.join('.')).toEqual( 'foo.bar.dest.values.example-field', ) expect(otherErrors).toHaveLength(0) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index a504509da8..0f0c5560ca 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -2,11 +2,8 @@ import _ from 'lodash' import { ApolloError } from '@apollo/client' import { GraphQLError } from 'graphql/error' import { CombinedError } from 'urql' -import { - INVALID_DESTINATION_FIELD_VALUE, - INVALID_DESTINATION_TYPE, -} from '../../errors.d' import { useDestinationType } from './RequireConfig' +import { ErrorCode } from '../../schema' const mapName = (name: string): string => _.camelCase(name).replace(/Id$/, 'ID') @@ -39,17 +36,69 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { ) } -export type SimpleError = { +export interface SimpleError { message: string } -export type InputFieldError = { - message: string - path: string +export interface KnownError extends GraphQLError, SimpleError { + readonly path: ReadonlyArray + extensions: { + code: ErrorCode + } +} + +export interface InputFieldError extends KnownError { + extensions: { + code: 'INVALID_INPUT_VALUE' + } +} + +export interface InvalidDestFieldValueError extends KnownError { + extensions: { + code: 'INVALID_DEST_FIELD_VALUE' + fieldID: string + } +} + +function assertNever(x: never): void { + console.log('unhandled error code', x) +} + +function isKnownErrorCode(code: ErrorCode): code is ErrorCode { + switch (code) { + case 'INVALID_INPUT_VALUE': + return true + case 'INVALID_DEST_FIELD_VALUE': + return true + default: + assertNever(code) // ensure we handle all error codes + return false + } +} + +function isGraphQLError(err: unknown): err is GraphQLError { + if (!err) return false + if (!Object.prototype.hasOwnProperty.call(err, 'path')) return false + if (!Object.prototype.hasOwnProperty.call(err, 'extensions')) return false + return true } -function isGraphQLError(e: SimpleError | GraphQLError): e is GraphQLError { - return !!(e as GraphQLError).extensions +export function isKnownError(err: unknown): err is KnownError { + if (!isGraphQLError(err)) return false + if (!Object.prototype.hasOwnProperty.call(err.extensions, 'code')) + return false + + return isKnownErrorCode(err.extensions.code as ErrorCode) +} +export function isDestFieldError( + err: unknown, +): err is InvalidDestFieldValueError { + if (!isKnownError(err)) return false + return err.extensions.code === 'INVALID_DEST_FIELD_VALUE' +} +export function isInputFieldError(err: unknown): err is InputFieldError { + if (!isKnownError(err)) return false + return err.extensions.code === 'INVALID_INPUT_VALUE' } /** @@ -61,26 +110,13 @@ function isGraphQLError(e: SimpleError | GraphQLError): e is GraphQLError { */ export function getInputFieldErrors( filterPaths: string[], - errs: (GraphQLError | SimpleError)[] | undefined | null, + errs: SimpleError[] | undefined | null, ): [InputFieldError[], SimpleError[]] { if (!errs) return [[], []] const inputFieldErrors = [] as InputFieldError[] const otherErrors = [] as SimpleError[] errs.forEach((err) => { - if (!isGraphQLError(err)) { - otherErrors.push(err) - return - } - if (!err.path) { - otherErrors.push(err) - return - } - const code = err.extensions?.code - if ( - // only support known error codes - code !== INVALID_DESTINATION_TYPE && - code !== INVALID_DESTINATION_FIELD_VALUE - ) { + if (!isInputFieldError(err)) { otherErrors.push(err) return } @@ -92,7 +128,7 @@ export function getInputFieldErrors( return } - inputFieldErrors.push({ message: err.message, path: fullPath }) + inputFieldErrors.push(err) }) return [inputFieldErrors, otherErrors] @@ -108,7 +144,7 @@ export function useErrorsForDest( err: CombinedError | undefined | null, destType: string, destFieldPath: string, // the path of the DestinationInput field -): [SimpleError | undefined, InputFieldError[], SimpleError[]] { +): [InputFieldError | undefined, InvalidDestFieldValueError[], SimpleError[]] { const cfg = useDestinationType(destType) // need to call hook before conditional return if (!err) return [undefined, [], []] @@ -116,12 +152,31 @@ export function useErrorsForDest( [destFieldPath + '.type'], err.graphQLErrors, ) + const destFieldErrs: InvalidDestFieldValueError[] = [] + const otherErrs: SimpleError[] = [] - const paths = cfg.requiredFields.map( - (f) => `${destFieldPath}.values.${f.fieldID}`, - ) + nonDestTypeErrs.forEach((err) => { + if (!isDestFieldError(err)) { + otherErrs.push(err) + return + } + + const fullPath = err.path.join('.') + if (fullPath !== destFieldPath) { + otherErrs.push(err) + return + } + + const isReqField = cfg.requiredFields.some( + (f) => f.fieldID === err.extensions.fieldID, + ) + if (!isReqField) { + otherErrs.push(err) + return + } - const [destFieldErrs, otherErrs] = getInputFieldErrors(paths, nonDestTypeErrs) + destFieldErrs.push(err) + }) return [destTypeErrs[0] || undefined, destFieldErrs, otherErrs] } diff --git a/web/src/errors.d.ts b/web/src/errors.d.ts deleted file mode 100644 index 91971c9843..0000000000 --- a/web/src/errors.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * INVALID_DESTINATION_TYPE is returned when the selected destination type is not valid, or is not allowed. - */ -export const INVALID_DESTINATION_TYPE = 'INVALID_DESTINATION_TYPE' - -/** - * INVALID_DESTINATION_FIELD_VALUE is returned when the value of a field on a destination is invalid. - */ -export const INVALID_DESTINATION_FIELD_VALUE = 'INVALID_DESTINATION_FIELD_VALUE' - -type KnownErrorCode = INVALID_DESTINATION_TYPE | INVALID_DESTINATION_FIELD_VALUE - -export type InvalidDestTypeError = { - message: string - path: readonly (string | number)[] - extensions: { - code: INVALID_DESTINATION_TYPE - } -} - -export type InvalidFieldValueError = { - message: string - path: readonly (string | number)[] - extensions: { - code: INVALID_DESTINATION_FIELD_VALUE - } -} diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 96b9e89775..d9de9a6d53 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -406,6 +406,8 @@ export interface DestinationTypeInfo { userDisclaimer: string } +export type ErrorCode = 'INVALID_DEST_FIELD_VALUE' | 'INVALID_INPUT_VALUE' + export interface EscalationPolicy { assignedTo: Target[] description: string From ef04d143aff6852096d03a04b722d70c32af4f8b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 12:52:41 -0600 Subject: [PATCH 19/31] reorganize --- web/src/app/util/errtypes.ts | 67 +++++++++++++++++++++++++++++ web/src/app/util/errutil.ts | 83 ++++++------------------------------ 2 files changed, 79 insertions(+), 71 deletions(-) create mode 100644 web/src/app/util/errtypes.ts diff --git a/web/src/app/util/errtypes.ts b/web/src/app/util/errtypes.ts new file mode 100644 index 0000000000..dc6663a2f8 --- /dev/null +++ b/web/src/app/util/errtypes.ts @@ -0,0 +1,67 @@ +import { GraphQLError } from 'graphql' +import { ErrorCode } from '../../schema' + +export interface BaseError { + message: string +} + +export interface KnownError extends GraphQLError, BaseError { + readonly path: ReadonlyArray + extensions: { + code: ErrorCode + } +} + +export interface InputFieldError extends KnownError { + extensions: { + code: 'INVALID_INPUT_VALUE' + } +} + +export interface InvalidDestFieldValueError extends KnownError { + extensions: { + code: 'INVALID_DEST_FIELD_VALUE' + fieldID: string + } +} + +function assertNever(x: never): void { + console.log('unhandled error code', x) +} + +function isKnownErrorCode(code: ErrorCode): code is ErrorCode { + switch (code) { + case 'INVALID_INPUT_VALUE': + return true + case 'INVALID_DEST_FIELD_VALUE': + return true + default: + assertNever(code) // ensure we handle all error codes + return false + } +} + +function isGraphQLError(err: unknown): err is GraphQLError { + if (!err) return false + if (!Object.prototype.hasOwnProperty.call(err, 'path')) return false + if (!Object.prototype.hasOwnProperty.call(err, 'extensions')) return false + return true +} + +export function isKnownError(err: unknown): err is KnownError { + if (!isGraphQLError(err)) return false + if (!Object.prototype.hasOwnProperty.call(err.extensions, 'code')) + return false + + return isKnownErrorCode(err.extensions.code as ErrorCode) +} +export function isDestFieldError( + err: unknown, +): err is InvalidDestFieldValueError { + if (!isKnownError(err)) return false + return err.extensions.code === 'INVALID_DEST_FIELD_VALUE' +} +export function isInputFieldError(err: unknown): err is InputFieldError { + if (!isKnownError(err)) return false + return err.extensions.code === 'INVALID_INPUT_VALUE' +} diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 0f0c5560ca..4f6f74b457 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -3,7 +3,13 @@ import { ApolloError } from '@apollo/client' import { GraphQLError } from 'graphql/error' import { CombinedError } from 'urql' import { useDestinationType } from './RequireConfig' -import { ErrorCode } from '../../schema' +import { + BaseError, + InputFieldError, + InvalidDestFieldValueError, + isDestFieldError, + isInputFieldError, +} from './errtypes' const mapName = (name: string): string => _.camelCase(name).replace(/Id$/, 'ID') @@ -36,71 +42,6 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { ) } -export interface SimpleError { - message: string -} - -export interface KnownError extends GraphQLError, SimpleError { - readonly path: ReadonlyArray - extensions: { - code: ErrorCode - } -} - -export interface InputFieldError extends KnownError { - extensions: { - code: 'INVALID_INPUT_VALUE' - } -} - -export interface InvalidDestFieldValueError extends KnownError { - extensions: { - code: 'INVALID_DEST_FIELD_VALUE' - fieldID: string - } -} - -function assertNever(x: never): void { - console.log('unhandled error code', x) -} - -function isKnownErrorCode(code: ErrorCode): code is ErrorCode { - switch (code) { - case 'INVALID_INPUT_VALUE': - return true - case 'INVALID_DEST_FIELD_VALUE': - return true - default: - assertNever(code) // ensure we handle all error codes - return false - } -} - -function isGraphQLError(err: unknown): err is GraphQLError { - if (!err) return false - if (!Object.prototype.hasOwnProperty.call(err, 'path')) return false - if (!Object.prototype.hasOwnProperty.call(err, 'extensions')) return false - return true -} - -export function isKnownError(err: unknown): err is KnownError { - if (!isGraphQLError(err)) return false - if (!Object.prototype.hasOwnProperty.call(err.extensions, 'code')) - return false - - return isKnownErrorCode(err.extensions.code as ErrorCode) -} -export function isDestFieldError( - err: unknown, -): err is InvalidDestFieldValueError { - if (!isKnownError(err)) return false - return err.extensions.code === 'INVALID_DEST_FIELD_VALUE' -} -export function isInputFieldError(err: unknown): err is InputFieldError { - if (!isKnownError(err)) return false - return err.extensions.code === 'INVALID_INPUT_VALUE' -} - /** * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. @@ -110,11 +51,11 @@ export function isInputFieldError(err: unknown): err is InputFieldError { */ export function getInputFieldErrors( filterPaths: string[], - errs: SimpleError[] | undefined | null, -): [InputFieldError[], SimpleError[]] { + errs: BaseError[] | undefined | null, +): [InputFieldError[], BaseError[]] { if (!errs) return [[], []] const inputFieldErrors = [] as InputFieldError[] - const otherErrors = [] as SimpleError[] + const otherErrors = [] as BaseError[] errs.forEach((err) => { if (!isInputFieldError(err)) { otherErrors.push(err) @@ -144,7 +85,7 @@ export function useErrorsForDest( err: CombinedError | undefined | null, destType: string, destFieldPath: string, // the path of the DestinationInput field -): [InputFieldError | undefined, InvalidDestFieldValueError[], SimpleError[]] { +): [InputFieldError | undefined, InvalidDestFieldValueError[], BaseError[]] { const cfg = useDestinationType(destType) // need to call hook before conditional return if (!err) return [undefined, [], []] @@ -153,7 +94,7 @@ export function useErrorsForDest( err.graphQLErrors, ) const destFieldErrs: InvalidDestFieldValueError[] = [] - const otherErrs: SimpleError[] = [] + const otherErrs: BaseError[] = [] nonDestTypeErrs.forEach((err) => { if (!isDestFieldError(err)) { From f653c3c6fe76a77c9d62433cf744a7771530b915 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 13:50:01 -0600 Subject: [PATCH 20/31] cleanup errors on other components --- .../selection/DestinationField.stories.tsx | 12 +++++-- web/src/app/selection/DestinationField.tsx | 10 ++---- .../UserContactMethodFormDest.stories.tsx | 15 ++++++--- .../app/users/UserContactMethodFormDest.tsx | 32 +++++++------------ web/src/app/util/errtypes.ts | 8 ++--- web/src/app/util/errutil.ts | 17 +++++++--- 6 files changed, 50 insertions(+), 44 deletions(-) diff --git a/web/src/app/selection/DestinationField.stories.tsx b/web/src/app/selection/DestinationField.stories.tsx index 86e3cbb3d6..eb6507c620 100644 --- a/web/src/app/selection/DestinationField.stories.tsx +++ b/web/src/app/selection/DestinationField.stories.tsx @@ -135,12 +135,20 @@ export const FieldError: Story = { disabled: false, destFieldErrors: [ { - path: 'third-field', + path: ['input', 'dest'], message: 'This is an error message (third)', + extensions: { + code: 'INVALID_DEST_FIELD_VALUE', + fieldID: 'third-field', + }, }, { - path: 'first-field', + path: ['input', 'dest'], message: 'This is an error message (first)', + extensions: { + code: 'INVALID_DEST_FIELD_VALUE', + fieldID: 'first-field', + }, }, ], }, diff --git a/web/src/app/selection/DestinationField.tsx b/web/src/app/selection/DestinationField.tsx index 0e0d7e0c12..5334c652c2 100644 --- a/web/src/app/selection/DestinationField.tsx +++ b/web/src/app/selection/DestinationField.tsx @@ -4,7 +4,7 @@ import DestinationInputDirect from './DestinationInputDirect' import { useDestinationType } from '../util/RequireConfig' import DestinationSearchSelect from './DestinationSearchSelect' import { Grid } from '@mui/material' -import { InputFieldError } from '../util/errutil' +import { DestFieldValueError } from '../util/errtypes' export type DestinationFieldProps = { value: FieldValueInput[] @@ -13,7 +13,7 @@ export type DestinationFieldProps = { disabled?: boolean - destFieldErrors?: InputFieldError[] + destFieldErrors?: DestFieldValueError[] } function capFirstLetter(s: string): string { @@ -46,13 +46,9 @@ export default function DestinationField( props.onChange(newValues) } - // getFieldID returns the last segment of the path. - const getFieldID = (err: InputFieldError): string => - err.path.split('.').pop() || '' - const fieldErrMsg = capFirstLetter( props.destFieldErrors?.find( - (err) => getFieldID(err) === field.fieldID, + (err) => err.extensions.fieldID === field.fieldID, )?.message || '', ) diff --git a/web/src/app/users/UserContactMethodFormDest.stories.tsx b/web/src/app/users/UserContactMethodFormDest.stories.tsx index d58554a600..7edc8e1dbe 100644 --- a/web/src/app/users/UserContactMethodFormDest.stories.tsx +++ b/web/src/app/users/UserContactMethodFormDest.stories.tsx @@ -110,10 +110,14 @@ export const ErrorSingleField: Story = { statusUpdates: false, }, disabled: false, - fieldErrors: [ + errors: [ { message: 'number is too short', // note: the 'n' is lowercase - path: 'dest.values.phone-number', + path: ['input', 'dest'], + extensions: { + code: 'INVALID_DEST_FIELD_VALUE', + fieldID: 'phone-number', + }, }, ], }, @@ -153,10 +157,13 @@ export const ErrorMultiField: Story = { statusUpdates: false, }, disabled: false, - fieldErrors: [ + errors: [ { - path: 'name', + path: ['input', 'name'], message: 'must begin with a letter', + extensions: { + code: 'INVALID_INPUT_VALUE', + }, }, ], }, diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index d7c4c849b0..175e6df084 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -5,9 +5,10 @@ import React from 'react' import { DestinationInput } from '../../schema' import { FormContainer, FormField } from '../forms' import { renderMenuItem } from '../selection/DisableableMenuItem' -import { InputFieldError, SimpleError } from '../util/errutil' import DestinationField from '../selection/DestinationField' import { useContactMethodTypes } from '../util/RequireConfig' +import { InputFieldError, DestFieldValueError } from '../util/errtypes' +import { getInputFieldErrors } from '../util/errutil' export type Value = { name: string @@ -18,8 +19,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - typeError?: SimpleError - fieldErrors?: Array + errors?: Array disabled?: boolean edit?: boolean @@ -47,28 +47,18 @@ export default function UserContactMethodFormDest( statusUpdateChecked = false } - const formErrors = [] - if (props.typeError) { - formErrors.push({ - // the old form code requires this shape to map errors to the correct field - message: props.typeError.message, - field: 'dest.type', - }) - } - const nameErr = props.fieldErrors?.find( - (err) => err.path.split('.').pop() === 'name', + const [formErrors, destErrors] = getInputFieldErrors( + ['*.name', '*.dest.type'], + props.errors, ) - if (nameErr) { - formErrors.push({ - message: nameErr.message, - field: 'name', - }) - } return ( ({ + message: e.message, + field: e.path[e.path.length - 1].toString(), + }))} value={value} mapOnChangeValue={(newValue: Value): Value => { if (newValue.dest.type === value.dest.type) { @@ -119,7 +109,7 @@ export default function UserContactMethodFormDest( destType={value.dest.type} component={DestinationField} disabled={edit} - destFieldErrors={props.fieldErrors} + destFieldErrors={destErrors} /> diff --git a/web/src/app/util/errtypes.ts b/web/src/app/util/errtypes.ts index dc6663a2f8..f1c3eaf64f 100644 --- a/web/src/app/util/errtypes.ts +++ b/web/src/app/util/errtypes.ts @@ -5,7 +5,7 @@ export interface BaseError { message: string } -export interface KnownError extends GraphQLError, BaseError { +export interface KnownError extends BaseError { readonly path: ReadonlyArray extensions: { code: ErrorCode @@ -18,7 +18,7 @@ export interface InputFieldError extends KnownError { } } -export interface InvalidDestFieldValueError extends KnownError { +export interface DestFieldValueError extends KnownError { extensions: { code: 'INVALID_DEST_FIELD_VALUE' fieldID: string @@ -55,9 +55,7 @@ export function isKnownError(err: unknown): err is KnownError { return isKnownErrorCode(err.extensions.code as ErrorCode) } -export function isDestFieldError( - err: unknown, -): err is InvalidDestFieldValueError { +export function isDestFieldError(err: unknown): err is DestFieldValueError { if (!isKnownError(err)) return false return err.extensions.code === 'INVALID_DEST_FIELD_VALUE' } diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 4f6f74b457..71c15400eb 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -6,7 +6,7 @@ import { useDestinationType } from './RequireConfig' import { BaseError, InputFieldError, - InvalidDestFieldValueError, + DestFieldValueError, isDestFieldError, isInputFieldError, } from './errtypes' @@ -46,7 +46,7 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. * - * @param filterPaths - a list of paths to filter errors by + * @param filterPaths - a list of paths to filter errors by, paths can be exact or begin with a wildcard (*) * @param err - the CombinedError to filter */ export function getInputFieldErrors( @@ -64,7 +64,14 @@ export function getInputFieldErrors( const fullPath = err.path.join('.') - if (!filterPaths.includes(fullPath)) { + const matches = filterPaths.some((p) => { + if (p.startsWith('*')) { + return fullPath.endsWith(p.slice(1)) + } + return fullPath === p + }) + + if (!matches) { otherErrors.push(err) return } @@ -85,7 +92,7 @@ export function useErrorsForDest( err: CombinedError | undefined | null, destType: string, destFieldPath: string, // the path of the DestinationInput field -): [InputFieldError | undefined, InvalidDestFieldValueError[], BaseError[]] { +): [InputFieldError | undefined, DestFieldValueError[], BaseError[]] { const cfg = useDestinationType(destType) // need to call hook before conditional return if (!err) return [undefined, [], []] @@ -93,7 +100,7 @@ export function useErrorsForDest( [destFieldPath + '.type'], err.graphQLErrors, ) - const destFieldErrs: InvalidDestFieldValueError[] = [] + const destFieldErrs: DestFieldValueError[] = [] const otherErrs: BaseError[] = [] nonDestTypeErrs.forEach((err) => { From 3f38b9060195a67467e759687be7b1150eacfb25 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:09:51 -0600 Subject: [PATCH 21/31] remove special case for dest type err --- web/src/app/util/errutil.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 71c15400eb..bdc0bd7e59 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -84,26 +84,21 @@ export function getInputFieldErrors( /** * useErrorsForDest returns the errors for a destination type and field path from a CombinedError. - * The first return value is the error for the destination type, if any. - * The second return value is a list of errors for the destination fields, if any. - * The third return value is a list of other errors, if any. + * The first return value is a list of errors for the destination fields, if any. + * The second return value is a list of other errors, if any. */ export function useErrorsForDest( err: CombinedError | undefined | null, destType: string, destFieldPath: string, // the path of the DestinationInput field -): [InputFieldError | undefined, DestFieldValueError[], BaseError[]] { +): [DestFieldValueError[], BaseError[]] { const cfg = useDestinationType(destType) // need to call hook before conditional return - if (!err) return [undefined, [], []] + if (!err) return [[], []] - const [destTypeErrs, nonDestTypeErrs] = getInputFieldErrors( - [destFieldPath + '.type'], - err.graphQLErrors, - ) const destFieldErrs: DestFieldValueError[] = [] const otherErrs: BaseError[] = [] - nonDestTypeErrs.forEach((err) => { + err.graphQLErrors.forEach((err) => { if (!isDestFieldError(err)) { otherErrs.push(err) return @@ -126,7 +121,7 @@ export function useErrorsForDest( destFieldErrs.push(err) }) - return [destTypeErrs[0] || undefined, destFieldErrs, otherErrs] + return [destFieldErrs, otherErrs] } export interface FieldError extends Error { From cd19bc663dbab5f944dd7e857819a8e4f72d1cb7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:46:48 -0600 Subject: [PATCH 22/31] fix error split --- .../UserContactMethodCreateDialogDest.tsx | 12 +++--- .../app/users/UserContactMethodFormDest.tsx | 27 +++++++------ web/src/app/util/errutil.ts | 39 +++++++++++-------- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index 212b070eda..d05dc29e30 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react' import { useMutation, gql, CombinedError } from 'urql' -import { useErrorsForDest } from '../util/errutil' +import { splitErrorsByPath } from '../util/errutil' import FormDialog from '../dialogs/FormDialog' -import UserContactMethodForm from './UserContactMethodFormDest' +import UserContactMethodForm, { errorPaths } from './UserContactMethodFormDest' import { useContactMethodTypes } from '../util/RequireConfig' import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material' import DialogContentError from '../dialogs/components/DialogContentError' @@ -55,10 +55,9 @@ export default function UserContactMethodCreateDialogDest(props: { setCreateErr(createCMStatus.error || null) }, [createCMStatus.error]) - const [destTypeErr, destFieldErrs, otherErrs] = useErrorsForDest( + const [formErrors, otherErrs] = splitErrorsByPath( createErr, - CMValue.dest.type, - 'createUserContactMethod.input.dest', + errorPaths('createUserContactMethod.input'), ) if (!defaultType.enabled) { @@ -85,8 +84,7 @@ export default function UserContactMethodCreateDialogDest(props: { const form = ( setCMValue(CMValue)} value={CMValue} /> diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 175e6df084..146440fb0b 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -7,8 +7,11 @@ import { FormContainer, FormField } from '../forms' import { renderMenuItem } from '../selection/DisableableMenuItem' import DestinationField from '../selection/DestinationField' import { useContactMethodTypes } from '../util/RequireConfig' -import { InputFieldError, DestFieldValueError } from '../util/errtypes' -import { getInputFieldErrors } from '../util/errutil' +import { + isInputFieldError, + isDestFieldError, + KnownError, +} from '../util/errtypes' export type Value = { name: string @@ -19,7 +22,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array + errors?: Array disabled?: boolean edit?: boolean @@ -27,10 +30,16 @@ export type UserContactMethodFormProps = { onChange?: (CMValue: Value) => void } +export const errorPaths = (prefix = '*'): string[] => [ + `${prefix}.name`, + `${prefix}.dest.type`, + `${prefix}.dest`, +] + export default function UserContactMethodFormDest( props: UserContactMethodFormProps, ): JSX.Element { - const { value, edit = false, ...other } = props + const { value, edit = false, errors = [], ...other } = props const destinationTypes = useContactMethodTypes() const currentType = destinationTypes.find((d) => d.type === value.dest.type) @@ -47,15 +56,11 @@ export default function UserContactMethodFormDest( statusUpdateChecked = false } - const [formErrors, destErrors] = getInputFieldErrors( - ['*.name', '*.dest.type'], - props.errors, - ) - return ( ({ + errors={errors?.filter(isInputFieldError).map((e) => ({ + // need to convert to FormContainer's error format message: e.message, field: e.path[e.path.length - 1].toString(), }))} @@ -109,7 +114,7 @@ export default function UserContactMethodFormDest( destType={value.dest.type} component={DestinationField} disabled={edit} - destFieldErrors={destErrors} + destFieldErrors={errors.filter(isDestFieldError)} /> diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index bdc0bd7e59..83adbda801 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -9,6 +9,8 @@ import { DestFieldValueError, isDestFieldError, isInputFieldError, + KnownError, + isKnownError, } from './errtypes' const mapName = (name: string): string => _.camelCase(name).replace(/Id$/, 'ID') @@ -43,28 +45,33 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { } /** - * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. - * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. + * splitErrorsByPath returns a list of known errors and other errors from a CombinedError or array of errors. + * + * Any errors that are not known errors (or are not in the filterPaths list) will be returned as other errors. * - * @param filterPaths - a list of paths to filter errors by, paths can be exact or begin with a wildcard (*) * @param err - the CombinedError to filter + * @param paths - a list of paths to filter errors by, paths can be exact or begin with a wildcard (*) + * @param prefix - a prefix to add to the paths (replaces the wildcard * if used in the paths list) + * @returns a tuple of known errors and other errors */ -export function getInputFieldErrors( - filterPaths: string[], - errs: BaseError[] | undefined | null, -): [InputFieldError[], BaseError[]] { - if (!errs) return [[], []] - const inputFieldErrors = [] as InputFieldError[] - const otherErrors = [] as BaseError[] - errs.forEach((err) => { - if (!isInputFieldError(err)) { +export function splitErrorsByPath( + err: CombinedError | BaseError[] | undefined | null, + paths: string[], +): [KnownError[], BaseError[]] { + if (!err) return [[], []] + const knownErrors: KnownError[] = [] + const otherErrors: BaseError[] = [] + + const errors = Array.isArray(err) ? err : err.graphQLErrors + + errors.forEach((err) => { + if (!isKnownError(err)) { otherErrors.push(err) return } const fullPath = err.path.join('.') - - const matches = filterPaths.some((p) => { + const matches = paths.some((p) => { if (p.startsWith('*')) { return fullPath.endsWith(p.slice(1)) } @@ -76,10 +83,10 @@ export function getInputFieldErrors( return } - inputFieldErrors.push(err) + knownErrors.push(err) }) - return [inputFieldErrors, otherErrors] + return [knownErrors, otherErrors] } /** From b0003c3f42e2042aba6e004b8622c32b7529bcd4 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:48:17 -0600 Subject: [PATCH 23/31] cleanup imports --- web/src/app/util/errutil.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index 83adbda801..aa30a6ae30 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -5,10 +5,8 @@ import { CombinedError } from 'urql' import { useDestinationType } from './RequireConfig' import { BaseError, - InputFieldError, DestFieldValueError, isDestFieldError, - isInputFieldError, KnownError, isKnownError, } from './errtypes' From 9e5bd28b9df80788a4308aed8ca1a02d19a9ec0c Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:50:26 -0600 Subject: [PATCH 24/31] rename/simplify split helper --- web/src/app/util/errutil.ts | 87 +++++++++---------------------------- 1 file changed, 21 insertions(+), 66 deletions(-) diff --git a/web/src/app/util/errutil.ts b/web/src/app/util/errutil.ts index bdc0bd7e59..dc1a63d4d2 100644 --- a/web/src/app/util/errutil.ts +++ b/web/src/app/util/errutil.ts @@ -2,14 +2,7 @@ import _ from 'lodash' import { ApolloError } from '@apollo/client' import { GraphQLError } from 'graphql/error' import { CombinedError } from 'urql' -import { useDestinationType } from './RequireConfig' -import { - BaseError, - InputFieldError, - DestFieldValueError, - isDestFieldError, - isInputFieldError, -} from './errtypes' +import { BaseError, isKnownError, KnownError } from './errtypes' const mapName = (name: string): string => _.camelCase(name).replace(/Id$/, 'ID') @@ -43,28 +36,32 @@ export function nonFieldErrors(err?: ApolloError | CombinedError): Error[] { } /** - * getInputFieldErrors returns a list of input field errors and other errors from a CombinedError. - * Any errors that are not input field errors (or are not in the filterPaths list) will be returned as other errors. + * splitErrorsByPath returns a list of known errors and other errors from a CombinedError or array of errors. + * + * Any errors that are not known errors (or are not in the filterPaths list) will be returned as other errors. * - * @param filterPaths - a list of paths to filter errors by, paths can be exact or begin with a wildcard (*) * @param err - the CombinedError to filter + * @param paths - a list of paths to filter errors by, paths can be exact or begin with a wildcard (*) + * @returns a tuple of known errors and other errors */ -export function getInputFieldErrors( - filterPaths: string[], - errs: BaseError[] | undefined | null, -): [InputFieldError[], BaseError[]] { - if (!errs) return [[], []] - const inputFieldErrors = [] as InputFieldError[] - const otherErrors = [] as BaseError[] - errs.forEach((err) => { - if (!isInputFieldError(err)) { +export function splitErrorsByPath( + err: CombinedError | BaseError[] | undefined | null, + paths: string[], +): [KnownError[], BaseError[]] { + if (!err) return [[], []] + const knownErrors: KnownError[] = [] + const otherErrors: BaseError[] = [] + + const errors = Array.isArray(err) ? err : err.graphQLErrors + + errors.forEach((err) => { + if (!isKnownError(err)) { otherErrors.push(err) return } const fullPath = err.path.join('.') - - const matches = filterPaths.some((p) => { + const matches = paths.some((p) => { if (p.startsWith('*')) { return fullPath.endsWith(p.slice(1)) } @@ -76,52 +73,10 @@ export function getInputFieldErrors( return } - inputFieldErrors.push(err) - }) - - return [inputFieldErrors, otherErrors] -} - -/** - * useErrorsForDest returns the errors for a destination type and field path from a CombinedError. - * The first return value is a list of errors for the destination fields, if any. - * The second return value is a list of other errors, if any. - */ -export function useErrorsForDest( - err: CombinedError | undefined | null, - destType: string, - destFieldPath: string, // the path of the DestinationInput field -): [DestFieldValueError[], BaseError[]] { - const cfg = useDestinationType(destType) // need to call hook before conditional return - if (!err) return [[], []] - - const destFieldErrs: DestFieldValueError[] = [] - const otherErrs: BaseError[] = [] - - err.graphQLErrors.forEach((err) => { - if (!isDestFieldError(err)) { - otherErrs.push(err) - return - } - - const fullPath = err.path.join('.') - if (fullPath !== destFieldPath) { - otherErrs.push(err) - return - } - - const isReqField = cfg.requiredFields.some( - (f) => f.fieldID === err.extensions.fieldID, - ) - if (!isReqField) { - otherErrs.push(err) - return - } - - destFieldErrs.push(err) + knownErrors.push(err) }) - return [destFieldErrs, otherErrs] + return [knownErrors, otherErrors] } export interface FieldError extends Error { From fc9a0223b8083a7e1ae372aa377905ad423d5363 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:52:17 -0600 Subject: [PATCH 25/31] update form --- .../app/users/UserContactMethodFormDest.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 175e6df084..146440fb0b 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -7,8 +7,11 @@ import { FormContainer, FormField } from '../forms' import { renderMenuItem } from '../selection/DisableableMenuItem' import DestinationField from '../selection/DestinationField' import { useContactMethodTypes } from '../util/RequireConfig' -import { InputFieldError, DestFieldValueError } from '../util/errtypes' -import { getInputFieldErrors } from '../util/errutil' +import { + isInputFieldError, + isDestFieldError, + KnownError, +} from '../util/errtypes' export type Value = { name: string @@ -19,7 +22,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array + errors?: Array disabled?: boolean edit?: boolean @@ -27,10 +30,16 @@ export type UserContactMethodFormProps = { onChange?: (CMValue: Value) => void } +export const errorPaths = (prefix = '*'): string[] => [ + `${prefix}.name`, + `${prefix}.dest.type`, + `${prefix}.dest`, +] + export default function UserContactMethodFormDest( props: UserContactMethodFormProps, ): JSX.Element { - const { value, edit = false, ...other } = props + const { value, edit = false, errors = [], ...other } = props const destinationTypes = useContactMethodTypes() const currentType = destinationTypes.find((d) => d.type === value.dest.type) @@ -47,15 +56,11 @@ export default function UserContactMethodFormDest( statusUpdateChecked = false } - const [formErrors, destErrors] = getInputFieldErrors( - ['*.name', '*.dest.type'], - props.errors, - ) - return ( ({ + errors={errors?.filter(isInputFieldError).map((e) => ({ + // need to convert to FormContainer's error format message: e.message, field: e.path[e.path.length - 1].toString(), }))} @@ -109,7 +114,7 @@ export default function UserContactMethodFormDest( destType={value.dest.type} component={DestinationField} disabled={edit} - destFieldErrors={destErrors} + destFieldErrors={errors.filter(isDestFieldError)} /> From ad59fc29cea06de6350c124bde3b836fdb171cb1 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:54:20 -0600 Subject: [PATCH 26/31] update test --- web/src/app/util/errutil.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/app/util/errutil.test.ts b/web/src/app/util/errutil.test.ts index 4d6ef3924a..a79c6465d1 100644 --- a/web/src/app/util/errutil.test.ts +++ b/web/src/app/util/errutil.test.ts @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql' -import { getInputFieldErrors } from './errutil' +import { splitErrorsByPath } from './errutil' -describe('getInputFieldErrors', () => { +describe('splitErrorsByPath', () => { it('should split errors by path', () => { const resp = [ { @@ -20,10 +20,10 @@ describe('getInputFieldErrors', () => { }, ] as unknown as GraphQLError[] - const [inputFieldErrors, otherErrors] = getInputFieldErrors( - ['foo.bar.dest.type', 'foo.bar.dest.values.example-field'], - resp, - ) + const [inputFieldErrors, otherErrors] = splitErrorsByPath(resp, [ + 'foo.bar.dest.type', + 'foo.bar.dest.values.example-field', + ]) expect(inputFieldErrors).toHaveLength(2) expect(inputFieldErrors[0].message).toEqual('test1') From 66180fd110a82faa8ab7dd553a3c8e82a65b7660 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 14:57:30 -0600 Subject: [PATCH 27/31] fix type issue --- web/src/app/users/UserContactMethodFormDest.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 146440fb0b..62b8629835 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -11,6 +11,7 @@ import { isInputFieldError, isDestFieldError, KnownError, + DestFieldValueError, } from '../util/errtypes' export type Value = { @@ -22,7 +23,7 @@ export type Value = { export type UserContactMethodFormProps = { value: Value - errors?: Array + errors?: Array disabled?: boolean edit?: boolean From 6b7bb82c79f1af6d9cf8e8e9de33e2b9c61c991b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 20 Feb 2024 15:12:56 -0600 Subject: [PATCH 28/31] add pre-check for name when using dest --- graphql2/graphqlapp/contactmethod.go | 5 +++++ graphql2/graphqlapp/destinationvalidation.go | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index b2fdbb8b11..63a3a464b7 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -139,6 +139,11 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C cfg := config.FromContext(ctx) if input.Dest != nil { + err := validate.IDName("input.name", input.Name) + if err != nil { + addInputError(ctx, err) + return nil, nil + } if ok, err := (*App)(m).ValidateDestination(ctx, "input.dest", input.Dest); !ok { return nil, err } diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go index b786542997..9e03eb260c 100644 --- a/graphql2/graphqlapp/destinationvalidation.go +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -56,6 +56,24 @@ func addDestFieldError(ctx context.Context, parentField, fieldID string, err err return nil } +func addInputError(ctx context.Context, err error) { + field := err.(validation.FieldError).Field() + + p := graphql.GetPath(ctx) + parentParts := strings.Split(field, ".") + for _, part := range parentParts { + p = append(p, ast.PathName(part)) + } + + graphql.AddError(ctx, &gqlerror.Error{ + Message: errReason(err), + Path: p, + Extensions: map[string]interface{}{ + "code": graphql2.ErrorCodeInvalidInputValue, + }, + }) +} + // ValidateDestination will validate a destination input. // // In the future this will be a call to the plugin system. From cb2544170369c6b367728fd8d2ea8e5ffa8fb6bf Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 22 Feb 2024 11:33:27 -0600 Subject: [PATCH 29/31] fix error rendering and story --- package.json | 22 +- web/src/app/forms/FormField.jsx | 10 + web/src/app/storybook/defaultDestTypes.ts | 8 +- ...rContactMethodCreateDialogDest.stories.tsx | 165 ++--- .../UserContactMethodCreateDialogDest.tsx | 1 + .../app/users/UserContactMethodFormDest.tsx | 17 +- yarn.lock | 629 +++++++++--------- 7 files changed, 441 insertions(+), 411 deletions(-) diff --git a/package.json b/package.json index 1f1b0370c1..9fe7ace5ed 100644 --- a/package.json +++ b/package.json @@ -48,17 +48,17 @@ "@mui/system": "5.15.6", "@mui/x-data-grid": "6.19.3", "@playwright/test": "1.41.2", - "@storybook/addon-essentials": "7.6.13", - "@storybook/addon-interactions": "7.6.13", - "@storybook/addon-links": "7.6.13", - "@storybook/addons": "7.6.13", - "@storybook/blocks": "7.6.13", - "@storybook/preview-api": "7.6.13", - "@storybook/react": "7.6.13", - "@storybook/react-vite": "7.6.13", - "@storybook/test": "7.6.13", + "@storybook/addon-essentials": "7.6.17", + "@storybook/addon-interactions": "7.6.17", + "@storybook/addon-links": "7.6.17", + "@storybook/addons": "7.6.17", + "@storybook/blocks": "7.6.17", + "@storybook/preview-api": "7.6.17", + "@storybook/react": "7.6.17", + "@storybook/react-vite": "7.6.17", + "@storybook/test": "7.6.17", "@storybook/test-runner": "0.16.0", - "@storybook/types": "7.6.13", + "@storybook/types": "7.6.17", "@types/chance": "1.1.4", "@types/diff": "5.0.8", "@types/glob": "8.1.0", @@ -133,7 +133,7 @@ "remark-breaks": "4.0.0", "remark-gfm": "4.0.0", "semver": "7.6.0", - "storybook": "7.6.13", + "storybook": "7.6.17", "storybook-addon-mock": "4.3.0", "stylelint": "16.2.1", "stylelint-config-standard": "34.0.0", diff --git a/web/src/app/forms/FormField.jsx b/web/src/app/forms/FormField.jsx index ec1daf7f2a..06cf726628 100644 --- a/web/src/app/forms/FormField.jsx +++ b/web/src/app/forms/FormField.jsx @@ -167,6 +167,15 @@ export function FormField(props) { return null } + if (props.select) { + fieldProps.SelectProps = { + ...fieldProps.SelectProps, + MenuProps: { + ...fieldProps.SelectProps?.MenuProps, + disablePortal: props.disablePortal, + }, + } + } if (render) return render(fieldProps) return ( @@ -301,4 +310,5 @@ FormField.propTypes = { clientSideFilter: p.bool, disableCloseOnSelect: p.bool, optionsLimit: p.number, + disablePortal: p.bool, } diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts index 3a583ae75d..77f086c97b 100644 --- a/web/src/app/storybook/defaultDestTypes.ts +++ b/web/src/app/storybook/defaultDestTypes.ts @@ -3,7 +3,7 @@ import { DestinationTypeInfo } from '../../schema' export const destTypes: DestinationTypeInfo[] = [ { type: 'single-field', - name: 'Single Field Destination Type', + name: 'Single Field', enabled: true, disabledMessage: 'Single field destination type must be configured.', userDisclaimer: '', @@ -31,7 +31,7 @@ export const destTypes: DestinationTypeInfo[] = [ }, { type: 'triple-field', - name: 'Multi Field Destination Type', + name: 'Multi Field', enabled: true, disabledMessage: 'Multi field destination type must be configured.', userDisclaimer: '', @@ -111,7 +111,7 @@ export const destTypes: DestinationTypeInfo[] = [ }, { type: 'supports-status', - name: 'Single Field Destination Type', + name: 'Single With Status', enabled: true, disabledMessage: 'Single field destination type must be configured.', userDisclaimer: '', @@ -139,7 +139,7 @@ export const destTypes: DestinationTypeInfo[] = [ }, { type: 'required-status', - name: 'Single Field Destination Type', + name: 'Single With Required Status', enabled: true, disabledMessage: 'Single field destination type must be configured.', userDisclaimer: '', diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index e93c155e9a..743e0fe44b 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -9,6 +9,7 @@ import { } from '../storybook/graphql' import { useArgs } from '@storybook/preview-api' import { HttpResponse, graphql } from 'msw' +import { DestFieldValueError, InputFieldError } from '../util/errtypes' const meta = { title: 'users/UserContactMethodCreateDialogDest', @@ -44,25 +45,27 @@ const meta = { data: null, errors: [ { - message: 'This is a field-error', - path: [ - 'createUserContactMethod', - 'input', - 'dest', - 'values', - 'phone-number', - ], + message: 'This is a dest field-error', + path: ['createUserContactMethod', 'input', 'dest'], extensions: { - code: 'INVALID_DESTINATION_FIELD_VALUE', + code: 'INVALID_DEST_FIELD_VALUE', + fieldID: 'phone-number', }, - }, + } satisfies DestFieldValueError, { message: 'This indicates an invalid destination type', path: ['createUserContactMethod', 'input', 'dest', 'type'], extensions: { - code: 'INVALID_DESTINATION_TYPE', + code: 'INVALID_INPUT_VALUE', }, - }, + } satisfies InputFieldError, + { + message: 'Name error', + path: ['createUserContactMethod', 'input', 'name'], + extensions: { + code: 'INVALID_INPUT_VALUE', + }, + } satisfies InputFieldError, { message: 'This is a generic error', }, @@ -118,23 +121,30 @@ export const SingleField: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement) - await userEvent.clear(await canvas.findByPlaceholderText('11235550123')) - - await waitFor(async () => { - await userEvent.type( - await canvas.findByPlaceholderText('11235550123'), - '12225558989', - ) - }) - - const submitButton = await canvas.findByRole('button', { name: /SUBMIT/i }) - await userEvent.click(submitButton) - - await userEvent.clear(await canvas.findByLabelText('Name')) - await userEvent.type(await canvas.findByLabelText('Name'), 'TEST') + await userEvent.click(await canvas.findByLabelText('Destination Type')) - const retryButton = await canvas.findByRole('button', { name: /RETRY/i }) - await userEvent.click(retryButton) + // incorrectly believes that the following fields are not visible + expect( + await canvas.findByRole('option', { hidden: true, name: 'Single Field' }), + ).toBeInTheDocument() + expect( + await canvas.findByRole('option', { hidden: true, name: 'Multi Field' }), + ).toBeInTheDocument() + expect( + await canvas.findByText('This is disabled'), // does not register as an option + ).toBeInTheDocument() + expect( + await canvas.findByRole('option', { + hidden: true, + name: 'Single With Status', + }), + ).toBeInTheDocument() + expect( + await canvas.findByRole('option', { + hidden: true, + name: 'Single With Required Status', + }), + ).toBeInTheDocument() }, } @@ -146,59 +156,21 @@ export const MultiField: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Select the next Dest Type - await userEvent.click(await canvas.findByLabelText('Dest Type')) + // Select the multi-field Dest Type + await userEvent.click(await canvas.findByLabelText('Destination Type')) await userEvent.click( - await canvas.findByText('Multi Field Destination Type'), + await canvas.findByRole('option', { hidden: true, name: 'Multi Field' }), ) - // ensure information for phone number renders correctly - await userEvent.clear(await canvas.findByLabelText('First Item')) - await waitFor(async () => { - await userEvent.type( - await canvas.findByLabelText('First Item'), - '12225558989', - ) - }) - - await waitFor(async () => { - await expect(await canvas.findByTestId('CheckIcon')).toBeVisible() - }) - - // ensure information for email renders correctly - await expect( - await canvas.findByPlaceholderText('foobar@example.com'), - ).toBeVisible() - await userEvent.clear( - await canvas.findByPlaceholderText('foobar@example.com'), - ) - await userEvent.type( - await await canvas.findByPlaceholderText('foobar@example.com'), - 'valid@email.com', - ) - - // ensure information for slack renders correctly - await expect( - await canvas.findByPlaceholderText('slack user ID'), - ).toBeVisible() + 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 userEvent.clear(await canvas.findByLabelText('Third Item')) - await userEvent.type(await canvas.findByLabelText('Third Item'), '@slack') - - // Try to submit without all feilds complete - const submitButton = await canvas.findByRole('button', { name: /SUBMIT/i }) - await userEvent.click(submitButton) - - // Name field - await userEvent.clear(await canvas.findByLabelText('Name')) - await userEvent.type(await canvas.findByLabelText('Name'), 'TEST') - - const retryButton = await canvas.findByRole('button', { name: /RETRY/i }) - await userEvent.click(retryButton) }, } -export const DisabledField: Story = { +export const StatusUpdates: Story = { args: { userID: defaultConfig.user.id, title: 'Create New Contact Method', @@ -207,14 +179,39 @@ export const DisabledField: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) // Open option select - await userEvent.click(await canvas.findByLabelText('Dest Type')) - - // Ensure disabled + await userEvent.click(await canvas.findByLabelText('Destination Type')) + await userEvent.click( + await canvas.findByRole('option', { hidden: true, name: 'Single Field' }), + ) await expect( await canvas.findByLabelText( 'Send alert status updates (not supported for this type)', ), ).toBeDisabled() + + await userEvent.click(await canvas.findByLabelText('Destination Type')) + await userEvent.click( + await canvas.findByRole('option', { + hidden: true, + name: 'Single With Status', + }), + ) + await expect( + await canvas.findByLabelText('Send alert status updates'), + ).not.toBeDisabled() + + await userEvent.click(await canvas.findByLabelText('Destination Type')) + await userEvent.click( + await canvas.findByRole('option', { + hidden: true, + name: 'Single With Required Status', + }), + ) + await expect( + await canvas.findByLabelText( + 'Send alert status updates (cannot be disabled for this type)', + ), + ).toBeDisabled() }, } @@ -237,13 +234,21 @@ export const ErrorField: Story = { 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')).toBeInvalid() + + await expect(await canvas.findByText('Name error')).toBeVisible() + await expect( - await canvas.findByText('This is a field-error'), - ).toBeVisible() + // mui puts aria-invalid on the input, but not the combobox (which the label points to) + canvasElement.querySelector('input[name="dest.type"]'), + ).toBeInvalid() + await expect(await canvas.findByLabelText('Phone Number')).toBeInvalid() await expect( - await canvas.findByText('This indicates an invalid destination type'), + await canvas.findByText('This is a dest field-error'), ).toBeVisible() + await expect( await canvas.findByText('This is a generic error'), ).toBeVisible() diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.tsx index d05dc29e30..1a7e6019cb 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.tsx @@ -87,6 +87,7 @@ export default function UserContactMethodCreateDialogDest(props: { errors={formErrors} onChange={(CMValue: Value) => setCMValue(CMValue)} value={CMValue} + disablePortal={props.disablePortal} /> ) diff --git a/web/src/app/users/UserContactMethodFormDest.tsx b/web/src/app/users/UserContactMethodFormDest.tsx index 62b8629835..3dd7277f2c 100644 --- a/web/src/app/users/UserContactMethodFormDest.tsx +++ b/web/src/app/users/UserContactMethodFormDest.tsx @@ -28,6 +28,8 @@ export type UserContactMethodFormProps = { disabled?: boolean edit?: boolean + disablePortal?: boolean // for testing, disable portal on select menu + onChange?: (CMValue: Value) => void } @@ -60,11 +62,15 @@ export default function UserContactMethodFormDest( return ( ({ - // need to convert to FormContainer's error format - message: e.message, - field: e.path[e.path.length - 1].toString(), - }))} + errors={errors?.filter(isInputFieldError).map((e) => { + let field = e.path[e.path.length - 1].toString() + if (field === 'type') field = 'dest.type' + return { + // need to convert to FormContainer's error format + message: e.message, + field, + } + })} value={value} mapOnChangeValue={(newValue: Value): Value => { if (newValue.dest.type === value.dest.type) { @@ -93,6 +99,7 @@ export default function UserContactMethodFormDest( label='Destination Type' required select + disablePortal={props.disablePortal} disabled={edit} component={TextField} > diff --git a/yarn.lock b/yarn.lock index 99e3cda61b..20f408818f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4442,61 +4442,61 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-actions@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-actions@npm:7.6.13" +"@storybook/addon-actions@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-actions@npm:7.6.17" dependencies: - "@storybook/core-events": 7.6.13 + "@storybook/core-events": 7.6.17 "@storybook/global": ^5.0.0 "@types/uuid": ^9.0.1 dequal: ^2.0.2 polished: ^4.2.2 uuid: ^9.0.0 - checksum: 9f76a8fce6396d47a244e7107759fcbdf5605f7c3d6d666d5a444ea92c3e4f210855e0d8f8083ad92f3405e43e753e3953ea23ac65642af40dab4a23887eacfb + checksum: bc512cb1664b614b39fe00340f4eb6bd3311fd26828a5fd6f02448427c6b20bebe17d1f17de2fff1f2a16195b277945920208b924f0a7cca6f4155eec70b79d9 languageName: node linkType: hard -"@storybook/addon-backgrounds@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-backgrounds@npm:7.6.13" +"@storybook/addon-backgrounds@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-backgrounds@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 memoizerific: ^1.11.3 ts-dedent: ^2.0.0 - checksum: 189183000b4e5558981d7c19c24c285d6d99f94ee970668c3daa5304f4b1bd1a14370faa32f08376c868714a6856a8674edb3b3f9656c244830e1fe94c1668f0 + checksum: 7198cf392638b94e7b7e40ee18155ea742f70937ebe3ac38fe6ec2208d4568112d5a80d1bbc636c466c8b182aa93bad139f57287008d6026133fc976a441cace languageName: node linkType: hard -"@storybook/addon-controls@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-controls@npm:7.6.13" +"@storybook/addon-controls@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-controls@npm:7.6.17" dependencies: - "@storybook/blocks": 7.6.13 + "@storybook/blocks": 7.6.17 lodash: ^4.17.21 ts-dedent: ^2.0.0 - checksum: a8425890ed89d74da8c344bef3f1846bba120ddca23fa680e92f9e4e198467d1df54cdafeae0bbec52992338d58425f42ae681e4d5bf30a4e33e62104d3802c5 + checksum: a50d281d8c3a39d411b6a997f16cbd001db431f4c5e27bdb0da10fb211c83fb8671d74b851563caa2a13afca7f26f08cba16bc50c53bd629c9883c2214c6aacd languageName: node linkType: hard -"@storybook/addon-docs@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-docs@npm:7.6.13" +"@storybook/addon-docs@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-docs@npm:7.6.17" dependencies: "@jest/transform": ^29.3.1 "@mdx-js/react": ^2.1.5 - "@storybook/blocks": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/components": 7.6.13 - "@storybook/csf-plugin": 7.6.13 - "@storybook/csf-tools": 7.6.13 + "@storybook/blocks": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/components": 7.6.17 + "@storybook/csf-plugin": 7.6.17 + "@storybook/csf-tools": 7.6.17 "@storybook/global": ^5.0.0 "@storybook/mdx2-csf": ^1.0.0 - "@storybook/node-logger": 7.6.13 - "@storybook/postinstall": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/react-dom-shim": 7.6.13 - "@storybook/theming": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/node-logger": 7.6.17 + "@storybook/postinstall": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/react-dom-shim": 7.6.17 + "@storybook/theming": 7.6.17 + "@storybook/types": 7.6.17 fs-extra: ^11.1.0 remark-external-links: ^8.0.0 remark-slug: ^6.0.0 @@ -4504,60 +4504,60 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 29bfb824ec71e7f671e838ef368843660883e1c404ea9bec6907a8c0416b3f3687135c039aba8b31be92d46236ec4e234c997ae74869cb2fc1c762f786c1ea7a - languageName: node - linkType: hard - -"@storybook/addon-essentials@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-essentials@npm:7.6.13" - dependencies: - "@storybook/addon-actions": 7.6.13 - "@storybook/addon-backgrounds": 7.6.13 - "@storybook/addon-controls": 7.6.13 - "@storybook/addon-docs": 7.6.13 - "@storybook/addon-highlight": 7.6.13 - "@storybook/addon-measure": 7.6.13 - "@storybook/addon-outline": 7.6.13 - "@storybook/addon-toolbars": 7.6.13 - "@storybook/addon-viewport": 7.6.13 - "@storybook/core-common": 7.6.13 - "@storybook/manager-api": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/preview-api": 7.6.13 + checksum: df42569b89d0d466b8a4a23c02e8c15a874ebc13315e4868c7532731595342b53245075792814dbe7dd02d70c667ea4648d2889a5577e52184e326b6cbbe176e + languageName: node + linkType: hard + +"@storybook/addon-essentials@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-essentials@npm:7.6.17" + dependencies: + "@storybook/addon-actions": 7.6.17 + "@storybook/addon-backgrounds": 7.6.17 + "@storybook/addon-controls": 7.6.17 + "@storybook/addon-docs": 7.6.17 + "@storybook/addon-highlight": 7.6.17 + "@storybook/addon-measure": 7.6.17 + "@storybook/addon-outline": 7.6.17 + "@storybook/addon-toolbars": 7.6.17 + "@storybook/addon-viewport": 7.6.17 + "@storybook/core-common": 7.6.17 + "@storybook/manager-api": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/preview-api": 7.6.17 ts-dedent: ^2.0.0 peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: c39e1370a755cf5f0dc0845ff227f4e1795bc0430c1087be347b8b69476a0746e5d96b6290331199dac35b3d1ff9691d34bcdd69f9822acf22c3aa4b1e92619c + checksum: d63a5359c8cb3fb69d120bb75582db66d32e9d350048dea20ab5cc5ad2107db813db8212f63c7d31e58f918dca22cd45ca615fb0d11fbc3562fa4f63675a7966 languageName: node linkType: hard -"@storybook/addon-highlight@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-highlight@npm:7.6.13" +"@storybook/addon-highlight@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-highlight@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 - checksum: 6d391a62c7a2774ff01ef1c7571a6019dba01ac78a5a4899777f249e3d30c38728424955d07f4d653c11193745db639c2a15d78af2f40a0d478950e3d6fdd63b + checksum: 0b5f5dae9aae48b9386b167b4941f7b790a9e2502c352f21b1e114379cda7f4897f0e61babe9967fb258c551004cade1a0cbeddbc2e917c6a18b65617b503b09 languageName: node linkType: hard -"@storybook/addon-interactions@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-interactions@npm:7.6.13" +"@storybook/addon-interactions@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-interactions@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 - "@storybook/types": 7.6.13 + "@storybook/types": 7.6.17 jest-mock: ^27.0.6 polished: ^4.2.2 ts-dedent: ^2.2.0 - checksum: b3e0f28d745c9b9e52f3fdb2eaf0c0360c1f55343731717fdaa737118b6738935c2d74278c4e406df494bc71106d2c0794c471e9e4ce9d1a001d2e9ddf2f1864 + checksum: 8274ecec01ad379f53f7f2bf4383d2345013dd327bd6f1fd9515b8401c83f0a36a913dd8751b5a8bbb73fcecb51f62b5c26eb6d16dd73b91be1751f6d3712e8a languageName: node linkType: hard -"@storybook/addon-links@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-links@npm:7.6.13" +"@storybook/addon-links@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-links@npm:7.6.17" dependencies: "@storybook/csf": ^0.1.2 "@storybook/global": ^5.0.0 @@ -4567,54 +4567,54 @@ __metadata: peerDependenciesMeta: react: optional: true - checksum: 8f15704c7052afb9366339971163461ea5e9ddcd23e24b9ce936867e51d4dda47afd0115e8c97fee6030eef8410e50d73d990f400dd5f95a7df03fd25b18f75a + checksum: 7738870c04e65213140bca680158fde866351d09ac8bd7415423a1ad29054aecb21ff7971fa13a945a44d969c8d5b5e27599ecad8dbe22aa95f23ed59be33945 languageName: node linkType: hard -"@storybook/addon-measure@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-measure@npm:7.6.13" +"@storybook/addon-measure@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-measure@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 tiny-invariant: ^1.3.1 - checksum: 0e63f0b4a6f7ac08642ee86698e96e891c2095bfdd4a9776742bf1d6d2a2a71d00a03fce969fe3d8c3a5646d7f74e9fb99e57d0be0e0283c8e86d4995cfcb41c + checksum: c6db620a92e09ef5780e897f4b119e1efbe781b424706d1de55b71f6a3805c0c4620bfb3ab33998a317c246e4383f62769082b47bbd2f1aec4962eed812b952a languageName: node linkType: hard -"@storybook/addon-outline@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-outline@npm:7.6.13" +"@storybook/addon-outline@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-outline@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 ts-dedent: ^2.0.0 - checksum: 40fc43b0677495ab7cf739d5e5e8c857f4706c7c7e51068fbdfd27195f069d0ec57cba7106316e5c5fc04dd69c4fd8b991340ae43fcb5e92bd6e41547fcda596 + checksum: f85b2c41d02faafd37507ad52d6626dc078fa72ef6b915e5996b3b9f1fe4eb820a00f76bb9818bc3c20eeec9767b2bd942c27a5fea54cadaa526ac319e5355e5 languageName: node linkType: hard -"@storybook/addon-toolbars@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-toolbars@npm:7.6.13" - checksum: 225c9828ca96b793aa79fc7756e5d3fed2f6adfc910c441193ab38348c31a9fe840b10f67f0e34bf0637c884d2bce503c8081073b01c0df802d71de6ef31e8a3 +"@storybook/addon-toolbars@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-toolbars@npm:7.6.17" + checksum: 7e10d346e78ac5b9d8a653a6ab942cea8809b9b544d7e986246b742e65817ec1f475294ff581516781aab287df556b84676186b4a7c38a885fd64e80ecd2b846 languageName: node linkType: hard -"@storybook/addon-viewport@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addon-viewport@npm:7.6.13" +"@storybook/addon-viewport@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addon-viewport@npm:7.6.17" dependencies: memoizerific: ^1.11.3 - checksum: f621942f40fb698e369b3c0add20552ed2a99f1696337f01b90e3997f01bec8d58f118431e3a86afdfdef5368eee0fb3c6f1aa8dc384367d9d91140e40a64468 + checksum: 26ff73639c47d8363fcfe7ba84bebb327c54309480e8499109d7e319e31e22fe7b18e9bf7246961dd625c14e53d7e5a1e724ad6efd3623d54bad38221f20c1f9 languageName: node linkType: hard -"@storybook/addons@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/addons@npm:7.6.13" +"@storybook/addons@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/addons@npm:7.6.17" dependencies: - "@storybook/manager-api": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/types": 7.6.13 - checksum: 458fc5a7df3b181f9da05270fc93e839a8c7ea34261e688a4158211aa34dddba5375315c90adff5a9f6057dd83b71b35bd68f2d239faf5e968f37e712c004e1f + "@storybook/manager-api": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/types": 7.6.17 + checksum: d93befc3af9fc6de12ef2088d5d78dfcba40253bec9fb3294a4f597ae5d1149156646f45e2dae354ac8c4723e2f9bbb555e6c82936d6deee7e7dffbdc4d54289 languageName: node linkType: hard @@ -4629,21 +4629,21 @@ __metadata: languageName: node linkType: hard -"@storybook/blocks@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/blocks@npm:7.6.13" +"@storybook/blocks@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/blocks@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/components": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/channels": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/components": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/csf": ^0.1.2 - "@storybook/docs-tools": 7.6.13 + "@storybook/docs-tools": 7.6.17 "@storybook/global": ^5.0.0 - "@storybook/manager-api": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/theming": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/manager-api": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/theming": 7.6.17 + "@storybook/types": 7.6.17 "@types/lodash": ^4.14.167 color-convert: ^2.0.1 dequal: ^2.0.2 @@ -4659,7 +4659,7 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: a612c04d83a48cdba2ca3b59622dd5c9e895326fe5953435f71d92a077d71f47717e2bb5699a7a9adaa6ecc84a9f272489d34cee70ca09ce9cd72a5a0351a721 + checksum: 5db4092f77a073997586641b97f84ce78f03fc16fcfe50444b8ac7583a9169b8729be285ee5f84fa3eb8544fb1d6f1beceaedff3041ed4f9bfb43512520266cc languageName: node linkType: hard @@ -4721,14 +4721,14 @@ __metadata: languageName: node linkType: hard -"@storybook/builder-manager@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/builder-manager@npm:7.6.13" +"@storybook/builder-manager@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/builder-manager@npm:7.6.17" dependencies: "@fal-works/esbuild-plugin-global-externals": ^2.1.2 - "@storybook/core-common": 7.6.13 - "@storybook/manager": 7.6.13 - "@storybook/node-logger": 7.6.13 + "@storybook/core-common": 7.6.17 + "@storybook/manager": 7.6.17 + "@storybook/node-logger": 7.6.17 "@types/ejs": ^3.1.1 "@types/find-cache-dir": ^3.2.1 "@yarnpkg/esbuild-plugin-pnp": ^3.0.0-rc.10 @@ -4741,22 +4741,22 @@ __metadata: fs-extra: ^11.1.0 process: ^0.11.10 util: ^0.12.4 - checksum: 739aca9eb29174a52f56db8fdd8a337198b10a496a589b57ba3c4c376c7a48925976c03cc0def42810911fe322fd69f5e7213703235aa9bd12c3ba5a2d23844e + checksum: 6f2fd8d5cc8dac3fc50c60c514b5ee2b06efa57902b1d5120364673f80a2c43730b8f84b6473b108df38827a8e3f33f7a58b657e7a85bd85962b0f2a41e8c5c3 languageName: node linkType: hard -"@storybook/builder-vite@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/builder-vite@npm:7.6.13" +"@storybook/builder-vite@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/builder-vite@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/core-common": 7.6.13 - "@storybook/csf-plugin": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/preview": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/channels": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/core-common": 7.6.17 + "@storybook/csf-plugin": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/preview": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/types": 7.6.17 "@types/find-cache-dir": ^3.2.1 browser-assert: ^1.2.1 es-module-lexer: ^0.9.3 @@ -4777,7 +4777,7 @@ __metadata: optional: true vite-plugin-glimmerx: optional: true - checksum: 85533e420a2c0c640fde5acb93d8a36c7460a8c66161eb514e02f68e619718e9c0ec2bd0132cfc762c06389f55406a46527cf4e4710052c147c6f9418d3a1f00 + checksum: 106ea15c8fcfe98accaff82f8d72622e7a391beee7c73a757017bcf3311f62695d88f65731f792e9c63b143436f7f17dd24d1172f15ddf8ddcf7d8868bf9b448 languageName: node linkType: hard @@ -4795,17 +4795,17 @@ __metadata: languageName: node linkType: hard -"@storybook/channels@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/channels@npm:7.6.13" +"@storybook/channels@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/channels@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/client-logger": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/global": ^5.0.0 qs: ^6.10.0 telejson: ^7.2.0 tiny-invariant: ^1.3.1 - checksum: 1b50f6511e3819f4da13efaaabb70aeb46503623229e7ba6fed4b77063e0a94d81e1e55b81852b657ea5cb82964c943e9a8eb546574951a0ac1242c5bf2610e4 + checksum: b1c1a9ce0bcca16659eb8372394a2f0965ebae26e2add44c7db5f869a00141ab59763917761c7fa1feb81bd1244225e8bcd6f8144f7432ade16e2c868b300926 languageName: node linkType: hard @@ -4860,22 +4860,22 @@ __metadata: languageName: node linkType: hard -"@storybook/cli@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/cli@npm:7.6.13" +"@storybook/cli@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/cli@npm:7.6.17" dependencies: "@babel/core": ^7.23.2 "@babel/preset-env": ^7.23.2 "@babel/types": ^7.23.0 "@ndelangen/get-tarball": ^3.0.7 - "@storybook/codemod": 7.6.13 - "@storybook/core-common": 7.6.13 - "@storybook/core-events": 7.6.13 - "@storybook/core-server": 7.6.13 - "@storybook/csf-tools": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/telemetry": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/codemod": 7.6.17 + "@storybook/core-common": 7.6.17 + "@storybook/core-events": 7.6.17 + "@storybook/core-server": 7.6.17 + "@storybook/csf-tools": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/telemetry": 7.6.17 + "@storybook/types": 7.6.17 "@types/semver": ^7.3.4 "@yarnpkg/fslib": 2.10.3 "@yarnpkg/libzip": 2.3.0 @@ -4907,7 +4907,7 @@ __metadata: bin: getstorybook: ./bin/index.js sb: ./bin/index.js - checksum: 54afbfa204bcbf560571d2358ee340cf9c76de83a9d152741ed88ce965f239cdf2f64278b856cfdaa0952be8791620ed9ddf54e5a91153c5f1a3d6efb022b4f5 + checksum: 81787acc86220313038461c4d0a8c414b91a8c945ec185dd7e074dcf83a7b41d698dac481ed81fad045a2b9b364549eb38f0d8284520caa4cd61de9a9f876a24 languageName: node linkType: hard @@ -4920,12 +4920,12 @@ __metadata: languageName: node linkType: hard -"@storybook/client-logger@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/client-logger@npm:7.6.13" +"@storybook/client-logger@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/client-logger@npm:7.6.17" dependencies: "@storybook/global": ^5.0.0 - checksum: 89aa3fe9dbbc3b98c56eb270665693dd28ffab9505245d401fd849fa847a7deb7ed4dea458e6839c08c86377ef387604db1c6a0f4a78292505cd39abdd5c0be1 + checksum: 216feb7dcc5778d9b39c9deba1eeda0f7253cd0fe2515a7e99a49d2abd6ca6d697a70162c8b34b92ab14531910dd8671200725fd016c09d769893023031c6080 languageName: node linkType: hard @@ -4951,17 +4951,17 @@ __metadata: languageName: node linkType: hard -"@storybook/codemod@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/codemod@npm:7.6.13" +"@storybook/codemod@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/codemod@npm:7.6.17" dependencies: "@babel/core": ^7.23.2 "@babel/preset-env": ^7.23.2 "@babel/types": ^7.23.0 "@storybook/csf": ^0.1.2 - "@storybook/csf-tools": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/csf-tools": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/types": 7.6.17 "@types/cross-spawn": ^6.0.2 cross-spawn: ^7.0.3 globby: ^11.0.2 @@ -4969,7 +4969,7 @@ __metadata: lodash: ^4.17.21 prettier: ^2.8.0 recast: ^0.23.1 - checksum: 76c13ece7d43a2bf2b510860812103470ba601f7e8b27820e64557862388b46bbb85efb09f87802aacdf96801066eca597133af5c1e93133029d5d9917de122d + checksum: 7cd89a7dcf66acd5c102053df4cdc93b6c407a014f653d7c1f0bb1b010e83d006c7d8ab8d0feb52ee09f120b0336e9df12fc8f8c52c20144dd790f49627d865b languageName: node linkType: hard @@ -4994,34 +4994,34 @@ __metadata: languageName: node linkType: hard -"@storybook/components@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/components@npm:7.6.13" +"@storybook/components@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/components@npm:7.6.17" dependencies: "@radix-ui/react-select": ^1.2.2 "@radix-ui/react-toolbar": ^1.0.4 - "@storybook/client-logger": 7.6.13 + "@storybook/client-logger": 7.6.17 "@storybook/csf": ^0.1.2 "@storybook/global": ^5.0.0 - "@storybook/theming": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/theming": 7.6.17 + "@storybook/types": 7.6.17 memoizerific: ^1.11.3 use-resize-observer: ^9.1.0 util-deprecate: ^1.0.2 peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 2e041f69762dc60f8c469a5cb1bf7f5ffeee9a6fdfd1ca87ea445f10213472a176c7cd0d74bc1d6f393f37a979641de44daf7c568de622a7c3c3f36eac6d764e + checksum: eb56530745b8561239d210accff71b2eff73ff3b169dc60948f8d6c2c37a01bc4f44b49514ed45d13411dde6a50f04869a6589f3c480b588d7a450972550e446 languageName: node linkType: hard -"@storybook/core-client@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/core-client@npm:7.6.13" +"@storybook/core-client@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/core-client@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 - "@storybook/preview-api": 7.6.13 - checksum: 59e1014ce4c969322a0f30993dd43b9d1a85e9a01f8db38cabf12cf1404b8cc0ad24b4e48e8214858d384fcdbf4a16548538de1a9867cf6dd367f2a9f5d4fb9e + "@storybook/client-logger": 7.6.17 + "@storybook/preview-api": 7.6.17 + checksum: 5bc150d8c636c5ffd40a4a8f72e60d689580508bb41aa6b544c9ff8b20860afd000498bdd77c6de62f56651f99ef88af67e4b47caba41ec27e5a748099f690f0 languageName: node linkType: hard @@ -5056,13 +5056,13 @@ __metadata: languageName: node linkType: hard -"@storybook/core-common@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/core-common@npm:7.6.13" +"@storybook/core-common@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/core-common@npm:7.6.17" dependencies: - "@storybook/core-events": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/core-events": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/types": 7.6.17 "@types/find-cache-dir": ^3.2.1 "@types/node": ^18.0.0 "@types/node-fetch": ^2.6.4 @@ -5083,7 +5083,7 @@ __metadata: pretty-hrtime: ^1.0.3 resolve-from: ^5.0.0 ts-dedent: ^2.0.0 - checksum: 69692ecab9c2fdf2dbeb80cf54e4bd877a158b3c1115f5a2ff17a6bd583d8d468f9b89e45f70e07f6034c645ac6c310013cf5de7be11f790f05a321a4f62298c + checksum: 28d881453228237d3d653f5e5b62499520864ba733ccfa480e4e7bb72c37be0ee1711b0b6060720f10172b113a5243c7e73187c867567f0c677c88466935b5ab languageName: node linkType: hard @@ -5096,12 +5096,12 @@ __metadata: languageName: node linkType: hard -"@storybook/core-events@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/core-events@npm:7.6.13" +"@storybook/core-events@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/core-events@npm:7.6.17" dependencies: ts-dedent: ^2.0.0 - checksum: 43096118c0bdd0dc633f57640eb369eef021e14061bb3a4751c762e5fb623d7618fe1b00697964783b966431b695b14a862b90333fe80ae157f15f88b73b0995 + checksum: 7463d8349211f23e9a25e08d85b04b9f6b24ee8747c775a8ec41ac4ff208e06f5183487d0f92af1e820f9c5044393a28e2065e5183a43b758f65deaab3ac3b61 languageName: node linkType: hard @@ -5154,25 +5154,25 @@ __metadata: languageName: node linkType: hard -"@storybook/core-server@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/core-server@npm:7.6.13" +"@storybook/core-server@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/core-server@npm:7.6.17" dependencies: "@aw-web-design/x-default-browser": 1.4.126 "@discoveryjs/json-ext": ^0.5.3 - "@storybook/builder-manager": 7.6.13 - "@storybook/channels": 7.6.13 - "@storybook/core-common": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/builder-manager": 7.6.17 + "@storybook/channels": 7.6.17 + "@storybook/core-common": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/csf": ^0.1.2 - "@storybook/csf-tools": 7.6.13 + "@storybook/csf-tools": 7.6.17 "@storybook/docs-mdx": ^0.1.0 "@storybook/global": ^5.0.0 - "@storybook/manager": 7.6.13 - "@storybook/node-logger": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/telemetry": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/manager": 7.6.17 + "@storybook/node-logger": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/telemetry": 7.6.17 + "@storybook/types": 7.6.17 "@types/detect-port": ^1.3.0 "@types/node": ^18.0.0 "@types/pretty-hrtime": ^1.0.0 @@ -5185,7 +5185,7 @@ __metadata: express: ^4.17.3 fs-extra: ^11.1.0 globby: ^11.0.2 - ip: ^2.0.0 + ip: ^2.0.1 lodash: ^4.17.21 open: ^8.4.0 pretty-hrtime: ^1.0.3 @@ -5199,17 +5199,17 @@ __metadata: util-deprecate: ^1.0.2 watchpack: ^2.2.0 ws: ^8.2.3 - checksum: fbcaeed46a105f7825a1c4dea9d66ed57f5a380822474eb7ccb3f0ec75df70e1827ccc15d40a8057896935e5a71e88b9e7e41e59e251a4db241907bf9b9f67e6 + checksum: 47dc08900a682a77ed2cc4e842586c66a800d3feb3644429d8048ee57f9c0fe26606f017862992121408695f65ee85ad907c2635b40dc24f44f27873277ce380 languageName: node linkType: hard -"@storybook/csf-plugin@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/csf-plugin@npm:7.6.13" +"@storybook/csf-plugin@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/csf-plugin@npm:7.6.17" dependencies: - "@storybook/csf-tools": 7.6.13 + "@storybook/csf-tools": 7.6.17 unplugin: ^1.3.1 - checksum: b93ee3a5276c07991ab6f5978c477aa192289a3f146a7d32956fdbcc9aebcee9260f2fe93b2bbfcfae1ed5b47042f28278aaa54de4ec55efc3e6a0be790a6b6d + checksum: d3689b7a4d22f4b06f889a20e3d54c9f72bf1a6e5aa732cba7d60068b468745c099dbf333f7750a34309d9fcbada15fb895961f92c5e4e1279e60055df4cfef5 languageName: node linkType: hard @@ -5230,20 +5230,20 @@ __metadata: languageName: node linkType: hard -"@storybook/csf-tools@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/csf-tools@npm:7.6.13" +"@storybook/csf-tools@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/csf-tools@npm:7.6.17" dependencies: "@babel/generator": ^7.23.0 "@babel/parser": ^7.23.0 "@babel/traverse": ^7.23.2 "@babel/types": ^7.23.0 "@storybook/csf": ^0.1.2 - "@storybook/types": 7.6.13 + "@storybook/types": 7.6.17 fs-extra: ^11.1.0 recast: ^0.23.1 ts-dedent: ^2.0.0 - checksum: 82394efcf048b9f1f14348410ba906cf4f42bee7d703200b875af6dc38b2d19974300dcdc33fd81dbb9250cf4871458a73567c769ba23ca75e09cc593ed131b7 + checksum: d1f92f08a559dbbd09302364da1ec570a57278322523c9e8ce577fb2fa768b84ade3733a93eaec83f1b13f64eb37be2c079e8b7820b29dd929482ddf0855bf68 languageName: node linkType: hard @@ -5287,18 +5287,18 @@ __metadata: languageName: node linkType: hard -"@storybook/docs-tools@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/docs-tools@npm:7.6.13" +"@storybook/docs-tools@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/docs-tools@npm:7.6.17" dependencies: - "@storybook/core-common": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/core-common": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/types": 7.6.17 "@types/doctrine": ^0.0.3 assert: ^2.1.0 doctrine: ^3.0.0 lodash: ^4.17.21 - checksum: 71631318529e58da3a0a1c5ecc135a786b1294db431e4734b22b9c13cd386852a0771525cafb3e33eff2ac8c3424c803573d34d4f5a97a1341d57f45706054cd + checksum: 62700508d74ab40717095e1684c036c4b2b9e104c397cd2ffcf455e116b90ba8e51cda3f501934eb31c5bc8646a99cd4c46b362bb833772cd0898ba2bd8e2544 languageName: node linkType: hard @@ -5309,18 +5309,18 @@ __metadata: languageName: node linkType: hard -"@storybook/instrumenter@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/instrumenter@npm:7.6.13" +"@storybook/instrumenter@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/instrumenter@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/channels": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/global": ^5.0.0 - "@storybook/preview-api": 7.6.13 + "@storybook/preview-api": 7.6.17 "@vitest/utils": ^0.34.6 util: ^0.12.4 - checksum: f109abb570a1517a955dee2b0456cd7215e516782ab37df689818d7525a7d16646328cc053ec847c6d54d8376cc839a2a02baef3a2fc63351e76618fecedcb87 + checksum: 62c24e8c4bf6acedc53f51de22585d9ceebbae5a3d2715cbd559c8c1c6ec8f127006494eac3781bfa2d63e5b1a89a144afba8bf091c8ca221be0f7f74223807a languageName: node linkType: hard @@ -5346,25 +5346,25 @@ __metadata: languageName: node linkType: hard -"@storybook/manager-api@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/manager-api@npm:7.6.13" +"@storybook/manager-api@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/manager-api@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/channels": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/csf": ^0.1.2 "@storybook/global": ^5.0.0 - "@storybook/router": 7.6.13 - "@storybook/theming": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/router": 7.6.17 + "@storybook/theming": 7.6.17 + "@storybook/types": 7.6.17 dequal: ^2.0.2 lodash: ^4.17.21 memoizerific: ^1.11.3 store2: ^2.14.2 telejson: ^7.2.0 ts-dedent: ^2.0.0 - checksum: 227f5b86afd49dcb3a4706c3bcb6eb6b302387a3fa56b66401ba10cdbe35cc6794c1dbc5ee725ed0a6fb12cfa45bdbf79999e4d34c0decb7413afc1944da93cb + checksum: 54c0b7a703fe928c93cbe4230b2d7e30317c064f4c34339bcf41c1d638892c47b33dc6b7fd5aaf4c559a4170e9eb442b49cb6144f2f9085bc4a999b6cc1b2028 languageName: node linkType: hard @@ -5375,10 +5375,10 @@ __metadata: languageName: node linkType: hard -"@storybook/manager@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/manager@npm:7.6.13" - checksum: 9ca47b6c40276693bd4bdfe8a627e46ed6a2d8f4fbb674eeec44b78998ce9d9eab4065b2b5475c199c5bd865269f78c0107e633243b0ceadbe58baf9bf15179b +"@storybook/manager@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/manager@npm:7.6.17" + checksum: f961367cabc088bad2942fe6a34e6ca1f801068d5f7d63f0387b2bb6eb0a216d3a5d813195994f4704f7a9fabf09f0bd85373c7df960769775bfe21029655216 languageName: node linkType: hard @@ -5396,17 +5396,17 @@ __metadata: languageName: node linkType: hard -"@storybook/node-logger@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/node-logger@npm:7.6.13" - checksum: 27d45899ddabd078e693dc90be8d63f5506f7a97004fbf810af14dac1fbfa30a697b11b76e9c05d5440f4aa0da9ea93dedc9f1167d910c5f28e13a53306c8caa +"@storybook/node-logger@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/node-logger@npm:7.6.17" + checksum: cb39fa5a93b84a52251e324000a0cad7df1e56553542d06ebc50f3aea0b790b7b3774f7c4a6bb4d3bf6764eb7951caa82decd8e091ef4c73aa5c09c9fa446f40 languageName: node linkType: hard -"@storybook/postinstall@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/postinstall@npm:7.6.13" - checksum: 35394d7a361423c14a2e79cab49147ab7cdb357b10b9683ba8d2856e9fb04a1e3feb0eac8c825b8da9f3fef131aa84a944f749ecf4ee091b5e849022bfa7f136 +"@storybook/postinstall@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/postinstall@npm:7.6.17" + checksum: d33f6a0e1ed2596fe29e91d835ec0b3c92ef68703ca7f709b191b5236af34f85d9b97c587509e2e614228c2f8b6cf8c41c5f869b902e1661a59a81fb7a54b0d4 languageName: node linkType: hard @@ -5432,16 +5432,16 @@ __metadata: languageName: node linkType: hard -"@storybook/preview-api@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/preview-api@npm:7.6.13" +"@storybook/preview-api@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/preview-api@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 - "@storybook/client-logger": 7.6.13 - "@storybook/core-events": 7.6.13 + "@storybook/channels": 7.6.17 + "@storybook/client-logger": 7.6.17 + "@storybook/core-events": 7.6.17 "@storybook/csf": ^0.1.2 "@storybook/global": ^5.0.0 - "@storybook/types": 7.6.13 + "@storybook/types": 7.6.17 "@types/qs": ^6.9.5 dequal: ^2.0.2 lodash: ^4.17.21 @@ -5450,35 +5450,35 @@ __metadata: synchronous-promise: ^2.0.15 ts-dedent: ^2.0.0 util-deprecate: ^1.0.2 - checksum: 0e6da3feb53b8fbda27b4a2d6158105fa9460e8b06b8d558be844fe17bb3919cccc8e98bb142e4df5bf971e0a156092d182f5922b312895620ae3c2a33745789 + checksum: f448058f6f8b9d5a88083454d8296df79effc2f6500f4cb3406d18914ca2f972623a77fafc7f7c35bba077fe8ea4fa73965007bd130484dfa6be95a7c7a0e863 languageName: node linkType: hard -"@storybook/preview@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/preview@npm:7.6.13" - checksum: cc29bebd2c8a1803166e2b6b4457ca6e572faf59a4e5fdf85cffa7e87f0deb650ff1496e4eb1d3db2e02088c7e63ee1c55775402292aacd1c7d0a6ec6f73bfd1 +"@storybook/preview@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/preview@npm:7.6.17" + checksum: 05433836892b553db29ae3e3e7fbcbfab02db2538032c24180990aee8a99b7cd225176d6c48b4123da74d41cd8dc42d1f782c5bb33c3fb4d61177e1ceef1754e languageName: node linkType: hard -"@storybook/react-dom-shim@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/react-dom-shim@npm:7.6.13" +"@storybook/react-dom-shim@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/react-dom-shim@npm:7.6.17" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: ad0fa512e433ee29d95de415df26fc588d1e0bab46b8e1d2b8c42a29334bba9e0e9770f0249bbb962c6e1a6f9514c7e5465300e5ae651ff1957d78b53b7f5fd6 + checksum: 24c26515785542b7ad4602f89164413c48b2863e24536e29a30f23e9afad19262e5bdb6b4319a1bda47d7651aa0fa439451ace45ff89966dbbfc0eb9ff32566f languageName: node linkType: hard -"@storybook/react-vite@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/react-vite@npm:7.6.13" +"@storybook/react-vite@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/react-vite@npm:7.6.17" dependencies: "@joshwooding/vite-plugin-react-docgen-typescript": 0.3.0 "@rollup/pluginutils": ^5.0.2 - "@storybook/builder-vite": 7.6.13 - "@storybook/react": 7.6.13 + "@storybook/builder-vite": 7.6.17 + "@storybook/react": 7.6.17 "@vitejs/plugin-react": ^3.0.1 magic-string: ^0.30.0 react-docgen: ^7.0.0 @@ -5486,21 +5486,21 @@ __metadata: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 - checksum: 61437956e91c7bc9e77d545bb8581f1a9320eba31af9a4d905f001a2685f93147223eb06503e42156ec167068514be346bc963acb13b420f020cb34106ea1448 + checksum: cf1b71d44f53cf0da9c5383ed1ca407e0f639b439361d5ee5bf8bef4e1f39469b6b9af7fc632c5628d68890422409b3051c0296b8c873f5f7d31ea70529827b4 languageName: node linkType: hard -"@storybook/react@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/react@npm:7.6.13" +"@storybook/react@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/react@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 - "@storybook/core-client": 7.6.13 - "@storybook/docs-tools": 7.6.13 + "@storybook/client-logger": 7.6.17 + "@storybook/core-client": 7.6.17 + "@storybook/docs-tools": 7.6.17 "@storybook/global": ^5.0.0 - "@storybook/preview-api": 7.6.13 - "@storybook/react-dom-shim": 7.6.13 - "@storybook/types": 7.6.13 + "@storybook/preview-api": 7.6.17 + "@storybook/react-dom-shim": 7.6.17 + "@storybook/types": 7.6.17 "@types/escodegen": ^0.0.6 "@types/estree": ^0.0.51 "@types/node": ^18.0.0 @@ -5522,7 +5522,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 7b51d3873155b22b2a15ac079602a89988f2bf7632c7cc1c2f251023a431f1e4c1b86f322440f0fec807aa8d7ea304c5787d21a498ab81cacb4eb5faa95bac77 + checksum: 4c2bc6a26208e06004a212e485c2a8f24ddec1a53777c04ded415bd2eb32981209f641fe72fb5f951760b0018d4cc2639d74f9997f4967852b03e29292c3ce73 languageName: node linkType: hard @@ -5537,14 +5537,14 @@ __metadata: languageName: node linkType: hard -"@storybook/router@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/router@npm:7.6.13" +"@storybook/router@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/router@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 + "@storybook/client-logger": 7.6.17 memoizerific: ^1.11.3 qs: ^6.10.0 - checksum: aadd1d2d48e1d849bd06b308fe00d833502442b4a080ebc1aff8589098b8bdf09e06f776e2f2cc853796aa1625c0ec231386d4cef63fe12fc8bff0987b883269 + checksum: a4baaaaf5c04d6d2c9d3e3675c3c00356fc1e48089fc398c1a65922a53607ddcd278cc555caa30e96dfa8296262fc9618dc20c06825dea86884ce02df30420c4 languageName: node linkType: hard @@ -5564,19 +5564,19 @@ __metadata: languageName: node linkType: hard -"@storybook/telemetry@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/telemetry@npm:7.6.13" +"@storybook/telemetry@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/telemetry@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 - "@storybook/core-common": 7.6.13 - "@storybook/csf-tools": 7.6.13 + "@storybook/client-logger": 7.6.17 + "@storybook/core-common": 7.6.17 + "@storybook/csf-tools": 7.6.17 chalk: ^4.1.0 detect-package-manager: ^2.0.1 fetch-retry: ^5.0.2 fs-extra: ^11.1.0 read-pkg-up: ^7.0.1 - checksum: 391df809b8ee1b075b7e316dc52a8fa41198832e572122f76561d71025841edbb482a97f2fc0473a51450151cd2b68f9d8b8e3d55642bcc02aa635cc420c039d + checksum: 95fe05aed56a3e5898802f32e89eac4422a65411bf00bfcc4c79f7a5a115786e94efecb9d4f324f25af8a214d9e106fd64467f60ff486ff92f43c61a5242713a languageName: node linkType: hard @@ -5618,14 +5618,14 @@ __metadata: languageName: node linkType: hard -"@storybook/test@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/test@npm:7.6.13" +"@storybook/test@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/test@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.13 - "@storybook/core-events": 7.6.13 - "@storybook/instrumenter": 7.6.13 - "@storybook/preview-api": 7.6.13 + "@storybook/client-logger": 7.6.17 + "@storybook/core-events": 7.6.17 + "@storybook/instrumenter": 7.6.17 + "@storybook/preview-api": 7.6.17 "@testing-library/dom": ^9.3.1 "@testing-library/jest-dom": ^6.1.3 "@testing-library/user-event": 14.3.0 @@ -5634,7 +5634,7 @@ __metadata: "@vitest/spy": ^0.34.1 chai: ^4.3.7 util: ^0.12.4 - checksum: f1d6983fe3b4215bac6c6bdd0e184e65e4105579b7939fadb51ccf9ddfc1940b0f046c4ec3e81da7ff34faa8ea71d21a5f42d6bb23f066f100496ab83c36e902 + checksum: cdf28ba3d59f837a5e62d12a8d05258f87f2dc16f2cce1d0c89478ddeadf0eb277759095264b151066e6b169d867ce9ef593ded858aefdc62a181ed4d4744e5c languageName: node linkType: hard @@ -5653,18 +5653,18 @@ __metadata: languageName: node linkType: hard -"@storybook/theming@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/theming@npm:7.6.13" +"@storybook/theming@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/theming@npm:7.6.17" dependencies: "@emotion/use-insertion-effect-with-fallbacks": ^1.0.0 - "@storybook/client-logger": 7.6.13 + "@storybook/client-logger": 7.6.17 "@storybook/global": ^5.0.0 memoizerific: ^1.11.3 peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: a05e4410a2c0a1d127de033e8761aecbc5853f0f4c8f00d40cfc17eb16c38d2c67b77bc4dc818eaf26f6651e8e1b5136c4d303f45dc76d131966589afe60ecf4 + checksum: 0b0e910678166ee720db3c257558b7f787e883032e23d25e0cf35ce00e8eea390b4cc5471e6b247e02cddc8263de780ec7ba7ddee6b64a3c36ae54087128668b languageName: node linkType: hard @@ -5680,15 +5680,15 @@ __metadata: languageName: node linkType: hard -"@storybook/types@npm:7.6.13": - version: 7.6.13 - resolution: "@storybook/types@npm:7.6.13" +"@storybook/types@npm:7.6.17": + version: 7.6.17 + resolution: "@storybook/types@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.13 + "@storybook/channels": 7.6.17 "@types/babel__core": ^7.0.0 "@types/express": ^4.7.0 file-system-cache: 2.3.0 - checksum: 3880746f80efbd64fa9a7e5cb30a82ca8ca30294edda95d0d2baefcb0f4815ec84ef8f988af628d24c0611efdf9d032371b444abe712b450de20c2db061419f4 + checksum: 7ba71e3a8a15078a098cec35d78f37293fb01dba9d37dd9d040584531100c34811ba80b72b7b192d1e41f197ffb1bc20818ce72e9f348602f104d972def6ac51 languageName: node linkType: hard @@ -12559,6 +12559,13 @@ __metadata: languageName: node linkType: hard +"ip@npm:^2.0.1": + version: 2.0.1 + resolution: "ip@npm:2.0.1" + checksum: d765c9fd212b8a99023a4cde6a558a054c298d640fec1020567494d257afd78ca77e37126b1a3ef0e053646ced79a816bf50621d38d5e768cdde0431fa3b0d35 + languageName: node + linkType: hard + "ipaddr.js@npm:1.9.1": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -18041,17 +18048,17 @@ __metadata: "@mui/system": 5.15.6 "@mui/x-data-grid": 6.19.3 "@playwright/test": 1.41.2 - "@storybook/addon-essentials": 7.6.13 - "@storybook/addon-interactions": 7.6.13 - "@storybook/addon-links": 7.6.13 - "@storybook/addons": 7.6.13 - "@storybook/blocks": 7.6.13 - "@storybook/preview-api": 7.6.13 - "@storybook/react": 7.6.13 - "@storybook/react-vite": 7.6.13 - "@storybook/test": 7.6.13 + "@storybook/addon-essentials": 7.6.17 + "@storybook/addon-interactions": 7.6.17 + "@storybook/addon-links": 7.6.17 + "@storybook/addons": 7.6.17 + "@storybook/blocks": 7.6.17 + "@storybook/preview-api": 7.6.17 + "@storybook/react": 7.6.17 + "@storybook/react-vite": 7.6.17 + "@storybook/test": 7.6.17 "@storybook/test-runner": 0.16.0 - "@storybook/types": 7.6.13 + "@storybook/types": 7.6.17 "@types/chance": 1.1.4 "@types/diff": 5.0.8 "@types/glob": 8.1.0 @@ -18126,7 +18133,7 @@ __metadata: remark-breaks: 4.0.0 remark-gfm: 4.0.0 semver: 7.6.0 - storybook: 7.6.13 + storybook: 7.6.17 storybook-addon-mock: 4.3.0 stylelint: 16.2.1 stylelint-config-standard: 34.0.0 @@ -18835,15 +18842,15 @@ __metadata: languageName: node linkType: hard -"storybook@npm:7.6.13": - version: 7.6.13 - resolution: "storybook@npm:7.6.13" +"storybook@npm:7.6.17": + version: 7.6.17 + resolution: "storybook@npm:7.6.17" dependencies: - "@storybook/cli": 7.6.13 + "@storybook/cli": 7.6.17 bin: sb: ./index.js storybook: ./index.js - checksum: bda87cf64f7028a1a7b167e0839405ed82ca8091a64b08caf116fbad57a7290878e08e8b348d79cc1dfccdcd970137613851119a27511753cfb4ab33d337cd4b + checksum: 8809d72714322b32c4b03724b6dd4e01d0a282e7b8dca590bfc89e79e34c6bb82869d9dbb6c6c6c39381144e508f6b74ff8a605480fb4d914e30d5116a51f5ef languageName: node linkType: hard From 48deb3c95ea167c546ae841313be791e44a1b74a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 22 Feb 2024 13:30:43 -0600 Subject: [PATCH 30/31] update list stories --- .../app/users/UserContactMethodListDest.stories.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/web/src/app/users/UserContactMethodListDest.stories.tsx b/web/src/app/users/UserContactMethodListDest.stories.tsx index 440874e597..5afa0f19de 100644 --- a/web/src/app/users/UserContactMethodListDest.stories.tsx +++ b/web/src/app/users/UserContactMethodListDest.stories.tsx @@ -112,9 +112,7 @@ export const SingleContactMethod: Story = { const canvas = within(canvasElement) // ensure correct info is displayed for single-field CM - await expect( - await canvas.findByText('Josiah (Single Field Destination Type)'), - ).toBeVisible() + await expect(await canvas.findByText('Josiah (Single Field)')).toBeVisible() await expect(await canvas.findByText('+1 555-555-5555')).toBeVisible() // ensure CM is editable await expect( @@ -147,15 +145,11 @@ export const MultiContactMethods: Story = { // ensure correct info is displayed for single field CM await expect( - await canvas.findByText( - 'single field CM (Single Field Destination Type)', - ), + await canvas.findByText('single field CM (Single Field)'), ).toBeVisible() // ensure correct info is displayed for triple-field CM await expect( - await canvas.findByText( - 'triple contact method (Multi Field Destination Type)', - ), + await canvas.findByText('triple contact method (Multi Field)'), ).toBeVisible() await expect(await canvas.findByText('+1 555-555-5556')).toBeVisible() await expect(await canvas.findByText('test_user@target.com')).toBeVisible() From 80ccb2303f20831280edebb1a77a9953915b700b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 22 Feb 2024 13:33:39 -0600 Subject: [PATCH 31/31] user conflict out of scope for this PR --- .../UserContactMethodCreateDialogDest.stories.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx index 743e0fe44b..39fbe9cd61 100644 --- a/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx +++ b/web/src/app/users/UserContactMethodCreateDialogDest.stories.tsx @@ -26,17 +26,6 @@ const meta = { handlers: [ handleDefaultConfig, handleExpFlags('dest-types'), - graphql.query('UserConflictCheck', () => { - return HttpResponse.json({ - data: { - users: { - nodes: [ - { name: defaultConfig.user.name, id: defaultConfig.user.id }, - ], - }, - }, - }) - }), graphql.mutation( 'CreateUserContactMethodInput', ({ variables: vars }) => {