Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dest: use destinations for EP step edit dialog #3772

Merged
merged 9 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion graphql2/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions graphql2/graph/escalationpolicy.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ extend type EscalationPolicyStep {
extend input CreateEscalationPolicyStepInput {
actions: [DestinationInput!] @goField(forceResolver: true) # force resolver for initial compat
}

extend input UpdateEscalationPolicyStepInput {
actions: [DestinationInput!] @goField(forceResolver: true) # force resolver for initial compat
}
21 changes: 21 additions & 0 deletions graphql2/graphqlapp/escalationpolicy.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
type EscalationPolicy App
type EscalationPolicyStep App
type CreateEscalationPolicyStepInput App
type UpdateEscalationPolicyStepInput App

func (a *App) EscalationPolicy() graphql2.EscalationPolicyResolver { return (*EscalationPolicy)(a) }
func (a *App) EscalationPolicyStep() graphql2.EscalationPolicyStepResolver {
Expand Down Expand Up @@ -47,6 +48,26 @@ func (a *CreateEscalationPolicyStepInput) Actions(ctx context.Context, input *gr
return nil
}

func (a *App) UpdateEscalationPolicyStepInput() graphql2.UpdateEscalationPolicyStepInputResolver {
return (*UpdateEscalationPolicyStepInput)(a)
}

func (a *UpdateEscalationPolicyStepInput) Actions(ctx context.Context, input *graphql2.UpdateEscalationPolicyStepInput, actions []graphql2.DestinationInput) error {
tgts := make([]assignment.RawTarget, len(actions))
var err error
for i, action := range actions {
if err := (*App)(a).ValidateDestination(ctx, fmt.Sprintf("%d.dest", i), &action); err != nil {
return err
}
tgts[i], err = CompatDestToTarget(action)
if err != nil {
return validation.NewFieldError("actions", "invalid DestInput")
}
}
input.Targets = tgts
return nil
}

func contains(ids []string, id string) bool {
for _, x := range ids {
if x == id {
Expand Down
1 change: 1 addition & 0 deletions graphql2/models_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

167 changes: 167 additions & 0 deletions web/src/app/escalation-policies/PolicyStepEditDialogDest.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import PolicyStepEditDialogDest from './PolicyStepEditDialogDest'
import { expect, fn, userEvent, waitFor, within } from '@storybook/test'
import { handleDefaultConfig, handleExpFlags } from '../storybook/graphql'
import { HttpResponse, graphql } from 'msw'
import { DestFieldValueError } from '../util/errtypes'
import { EscalationPolicyStep } from '../../schema'

const meta = {
title: 'Escalation Policies/Steps/Edit Dialog',
component: PolicyStepEditDialogDest,
render: function Component(args) {
return <PolicyStepEditDialogDest {...args} disablePortal />
},
tags: ['autodocs'],
args: {
onClose: fn(),
escalationPolicyID: 'policy1',
stepID: 'step1',
},
parameters: {
docs: {
story: {
inline: false,
iframeHeight: 600,
},
},
msw: {
handlers: [
handleDefaultConfig,
handleExpFlags('dest-types'),
graphql.query('ValidateDestination', ({ variables: vars }) => {
return HttpResponse.json({
data: {
destinationFieldValidate: vars.input.value.length === 12,
},
})
}),
graphql.query('DestDisplayInfo', ({ variables: vars }) => {
if (vars.input.values[0].value.length !== 12) {
return HttpResponse.json({
errors: [
{ message: 'generic error' },
{
message: 'Invalid number',
path: ['destinationDisplayInfo', 'input'],
extensions: {
code: 'INVALID_DEST_FIELD_VALUE',
fieldID: 'phone-number',
},
} satisfies DestFieldValueError,
],
})
}

return HttpResponse.json({
data: {
destinationDisplayInfo: {
text: vars.input.values[0].value,
iconURL: 'builtin://phone-voice',
iconAltText: 'Voice Call',
},
},
})
}),

graphql.query('GetEPStep', () => {
return HttpResponse.json({
data: {
escalationPolicy: {
id: 'policy1',
steps: [
{
id: 'step1',
delayMinutes: 17,
actions: [
{
type: 'single-field',
values: [
{ fieldID: 'phone-number', value: '+19995550123' },
],
},
],
} as EscalationPolicyStep,
],
},
},
})
}),

graphql.mutation('UpdateEPStep', ({ variables: vars }) => {
if (vars.input.delayMinutes === 999) {
return HttpResponse.json({
errors: [{ message: 'generic dialog error' }],
})
}

return HttpResponse.json({
data: {
updateEscalationPolicyStep: true,
},
})
}),
],
},
},
} satisfies Meta<typeof PolicyStepEditDialogDest>

export default meta
type Story = StoryObj<typeof meta>

export const UpdatePolicyStep: Story = {
argTypes: {
onClose: { action: 'onClose' },
},
args: {
escalationPolicyID: '1',
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement)

// validate existing step data
// 1. delay should be 17
// 2. phone number should be +19995550123

await waitFor(async function ExistingChip() {
await expect(await canvas.findByLabelText('Delay (minutes)')).toHaveValue(
17,
)
await expect(await canvas.findByText('+19995550123')).toBeVisible()
})

const phoneInput = await canvas.findByLabelText('Phone Number')
await userEvent.clear(phoneInput)
await userEvent.type(phoneInput, '1222')
await userEvent.click(await canvas.findByText('Add Action'))

await expect(await canvas.findByText('Invalid number')).toBeVisible()
await expect(await canvas.findByText('generic error')).toBeVisible()

await userEvent.clear(phoneInput)
await userEvent.type(phoneInput, '12225550123')
await userEvent.click(await canvas.findByText('Add Action'))

await waitFor(async function Icon() {
await userEvent.click(await canvas.findByTestId('destination-chip'))
})

const delayField = await canvas.findByLabelText('Delay (minutes)')
await userEvent.clear(delayField)
await userEvent.type(delayField, '999')
await userEvent.click(await canvas.findByText('Submit'))

await expect(await canvas.findByText('generic dialog error')).toBeVisible()

await expect(args.onClose).not.toHaveBeenCalled() // should not close on error

await userEvent.clear(delayField)
await userEvent.type(delayField, '15')
await userEvent.click(await canvas.findByText('Retry'))

await waitFor(async function Close() {
await expect(args.onClose).toHaveBeenCalled()
})
},
}
112 changes: 112 additions & 0 deletions web/src/app/escalation-policies/PolicyStepEditDialogDest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useState } from 'react'
import { gql, useMutation, useQuery } from 'urql'
import { splitErrorsByPath } from '../util/errutil'
import FormDialog from '../dialogs/FormDialog'
import PolicyStepFormDest, { FormValue } from './PolicyStepFormDest'
import {
Destination,
EscalationPolicy,
FieldValuePair,
UpdateEscalationPolicyStepInput,
} from '../../schema'

interface PolicyStepEditDialogDestProps {
escalationPolicyID: string
onClose: () => void
stepID: string
disablePortal?: boolean
}

const mutation = gql`
mutation UpdateEPStep($input: UpdateEscalationPolicyStepInput!) {
updateEscalationPolicyStep(input: $input)
}
`

const query = gql`
query GetEPStep($id: ID!) {
escalationPolicy(id: $id) {
id
steps {
id
delayMinutes
actions {
type
values {
fieldID
value
}
}
}
}
}
`

function PolicyStepEditDialogDest(
props: PolicyStepEditDialogDestProps,
): React.ReactNode {
const [stepQ] = useQuery<{ escalationPolicy: EscalationPolicy }>({
query,
variables: { id: props.escalationPolicyID },
})
const step = stepQ.data?.escalationPolicy.steps.find(
(s) => s.id === props.stepID,
)

if (!step) throw new Error('Step not found')

const [value, setValue] = useState<FormValue>({
actions: (step.actions || []).map((a: Destination) => ({
// remove extraneous fields
type: a.type,
values: a.values.map((v: FieldValuePair) => ({
fieldID: v.fieldID,
value: v.value,
})),
})),
delayMinutes: step.delayMinutes,
})

const [editStepStatus, editStep] = useMutation(mutation)

// Edit dialog has no errors to be handled by the form:
// - actions field has it's own validation
// - errors on existing actions are not handled specially, and just display in the dialog (i.e., duplicates)
// - the delay field has no validation, and is automatically clamped to the min/max values by the backend
const [a, errs] = splitErrorsByPath(editStepStatus.error, [])
console.log(a, errs, editStepStatus.error)

return (
<FormDialog
title='Edit Step'
loading={editStepStatus.fetching}
errors={errs}
disablePortal={props.disablePortal}
maxWidth='sm'
onClose={props.onClose}
onSubmit={() =>
editStep(
{
input: {
id: props.stepID,
delayMinutes: +value.delayMinutes,
actions: value.actions,
} satisfies UpdateEscalationPolicyStepInput,
},
{ additionalTypenames: ['EscalationPolicy'] },
).then((result) => {
if (!result.error) props.onClose()
})
}
form={
<PolicyStepFormDest
disabled={editStepStatus.fetching}
value={value}
onChange={(value: FormValue) => setValue(value)}
/>
}
/>
)
}

export default PolicyStepEditDialogDest
Loading
Loading