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

Add Universal Key Actions management forms #3945

Merged
merged 4 commits into from
Jun 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
3 changes: 3 additions & 0 deletions web/src/app/selection/DynamicActionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type DynamicActionFormProps = {
destTypeError?: string
staticParamErrors?: Readonly<Record<string, string>>
dynamicParamErrors?: Readonly<Record<string, string>>

disablePortal?: boolean
}

export function defaults(destTypeInfo: DestinationTypeInfo): Value {
Expand Down Expand Up @@ -89,6 +91,7 @@ export default function DynamicActionForm(
<TextField
select
fullWidth
SelectProps={{ MenuProps: { disablePortal: props.disablePortal } }}
value={selectedDest?.type || ''}
label='Destination Type'
name='dest.type'
Expand Down
167 changes: 167 additions & 0 deletions web/src/app/services/UniversalKey/UniversalKeyActionsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react'
import { Add } from '@mui/icons-material'
import { Button, Grid, Typography } from '@mui/material'
import { ActionInput } from '../../../schema'
import DynamicActionForm, {
actionInputToValue,
valueToActionInput,
} from '../../selection/DynamicActionForm'
import { CombinedError, gql, useClient } from 'urql'
import { useErrorConsumer } from '../../util/ErrorConsumer'
import UniversalKeyActionsList from './UniversalKeyActionsList'

type FormValue = React.ComponentProps<typeof DynamicActionForm>['value']

export type UniversalKeyActionsFormProps = {
value: Array<ActionInput>
onChange: (value: Array<ActionInput>) => void

showList?: boolean
editActionId?: string
onChipClick?: (action: ActionInput) => void

disablePortal?: boolean
}

const query = gql`
query ValidateActionInput($input: ActionInput!) {
actionInputValidate(input: $input)
}
`

const getAction = (actions: ActionInput[], id: string): FormValue => {
let input
if ((input = actions.find((v) => v.dest.type === id))) {
return actionInputToValue(input)
}
return null
}

/** Manages a set of actions. */
export default function UniversalKeyActionsForm(
props: UniversalKeyActionsFormProps,
): React.ReactNode {
const [currentAction, setCurrentAction] = useState<FormValue>(
props.editActionId ? getAction(props.value, props.editActionId) : null,
)
const [addError, setAddError] = useState<CombinedError | null>(null)
const valClient = useClient()
const errs = useErrorConsumer(addError)
let actions = props.value

useEffect(() => {
if (props.editActionId) {
setCurrentAction(getAction(props.value, props.editActionId))
}
}, [props.editActionId])

return (
<Grid item xs={12} container spacing={2}>
{props.showList && (
<UniversalKeyActionsList
actions={props.value}
onDelete={(a) => props.onChange(props.value.filter((v) => v !== a))}
onChipClick={(a) => {
if (props.onChipClick) {
props.onChipClick(a)
}
setCurrentAction(getAction(props.value, a.dest.type))
}}
/>
)}

<Grid item xs={12} container spacing={2}>
<DynamicActionForm
disablePortal={props.disablePortal}
value={currentAction}
onChange={setCurrentAction}
destTypeError={errs.getErrorByPath(
'actionInputValidate.input.dest.type',
)}
staticParamErrors={errs.getErrorMap(
'actionInputValidate.input.dest.args',
)}
dynamicParamErrors={errs.getErrorMap(
'actionInputValidate.input.params',
)}
/>

{errs.hasErrors() && (
<Grid item xs={12}>
{errs.remainingLegacy().map((e) => (
<Typography key={e.message} color='error'>
{e.message}
</Typography>
))}
</Grid>
)}

<Grid
item
xs={12}
sx={{
display: 'flex',
alignItems: 'flex-end',
}}
>
<Button
fullWidth
startIcon={<Add />}
variant='contained'
color='secondary'
sx={{ height: 'fit-content' }}
onClick={() => {
const input = valueToActionInput(currentAction)

if (props.editActionId !== '') {
actions = props.value.filter(
(v) => v.dest.type !== props.editActionId,
)
}

let cancel = ''
actions.forEach((_a) => {
const a = JSON.stringify(_a.dest.args)
const cur = JSON.stringify(input.dest.args)
if (a === cur) {
cancel = 'Cannot add same destination twice'
}
})

if (cancel !== '') {
setAddError({
message: cancel,
} as CombinedError)
return
}

setAddError(null)
valClient
.query(query, { input })
.toPromise()
.then((res) => {
if (res.error) {
setAddError(res.error)
return
}

// clear the current action
setCurrentAction(null)
props.onChange(actions.concat(input))

if (props.onChipClick) {
props.onChipClick({
dest: { type: '', args: {} },
params: {},
})
}
})
}}
>
{props.editActionId ? 'Save Action' : 'Add Action'}
</Button>
</Grid>
</Grid>
</Grid>
)
}
65 changes: 65 additions & 0 deletions web/src/app/services/UniversalKey/UniversalKeyActionsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react'
import { ActionInput } from '../../../schema'
import DestinationInputChip from '../../util/DestinationInputChip'
import { Grid, Typography } from '@mui/material'

export type UniversalKeyActionsListProps = {
actions: ReadonlyArray<ActionInput>

noHeader?: boolean
onDelete?: (action: ActionInput) => void
onChipClick?: (action: ActionInput) => void
}

export default function UniversalKeyActionsList(
props: UniversalKeyActionsListProps,
): React.ReactNode {
return (
<React.Fragment>
{!props.noHeader && (
<Grid item xs={12}>
<Typography variant='h6' color='textPrimary'>
Actions
</Typography>
</Grid>
)}
<Grid
item
xs={12}
container
spacing={1}
sx={{ p: 1 }}
data-testid='actions-list'
>
{props.actions.map((a) => (
<Grid item key={JSON.stringify(a.dest)}>
<DestinationInputChip
value={a.dest}
onDelete={
props.onDelete
? () => props.onDelete && props.onDelete(a)
: undefined
}
onChipClick={
props.onChipClick
? () => props.onChipClick && props.onChipClick(a)
: undefined
}
/>
</Grid>
))}
{props.actions.length === 0 && (
<Grid item xs={12}>
<Typography
variant='body2'
color='textSecondary'
data-testid='no-actions'
>
No actions
</Typography>
</Grid>
)}
</Grid>
</React.Fragment>
)
}
105 changes: 105 additions & 0 deletions web/src/app/services/UniversalKey/UniversalKeysActionsForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { expect, userEvent, waitFor, within, fn } from '@storybook/test'
import { useArgs } from '@storybook/preview-api'
import UniversalKeyActionsForm from './UniversalKeyActionsForm'
import { ActionInput, DestinationDisplayInfo } from '../../../schema'

const meta = {
title: 'UIK/Actions Form',
component: UniversalKeyActionsForm,
args: {
onChange: fn(),
},
render: function Component(args) {
const [, setArgs] = useArgs()
const onChange = (newValue: Array<ActionInput>): void => {
if (args.onChange) args.onChange(newValue)
setArgs({ value: newValue })
}
return (
<UniversalKeyActionsForm {...args} onChange={onChange} disablePortal />
)
},
tags: ['autodocs'],
} satisfies Meta<typeof UniversalKeyActionsForm>

export default meta
type Story = StoryObj<typeof meta>
export const Empty: Story = {
args: {
value: [],
},
}

export const WithList: Story = {
args: {
showList: true,
value: [{ dest: { type: 'foo', args: {} }, params: {} }],
},
parameters: {
graphql: {
DestDisplayInfo: {
data: {
destinationDisplayInfo: {
text: 'VALID_CHIP_1',
iconURL: 'builtin://rotation',
linkURL: '',
iconAltText: 'Rotation',
} satisfies DestinationDisplayInfo,
},
},
},
},
}

export const ValidationError: Story = {
args: {
showList: true,
value: [],
},
parameters: {
graphql: {
ValidateActionInput: {
errors: [
{ message: 'generic error' },
{
path: ['actionInputValidate', 'input', 'dest', 'type'],
message: 'invalid type',
},
{
path: ['actionInputValidate', 'input', 'dest', 'args'],
extensions: {
key: 'phone-number',
},
message: 'invalid number',
},
{
path: ['actionInputValidate', 'input', 'params'],
extensions: {
key: 'example-param',
},
message: 'invalid param',
},
],
},
},
},

play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
userEvent.click(canvas.getByLabelText('Destination Type'))
userEvent.click(await canvas.findByText('Single Field'))
await canvas.findByLabelText('Phone Number')
userEvent.click(await canvas.findByRole('button', { name: /add/i }))

waitFor(async () => {
expect(await canvas.getByLabelText('Phone Number')).toBeInvalid()
expect(
await canvas.getByLabelText('Example Param (Expr syntax)'),
).toBeInvalid()
expect(canvas.getByText('generic error')).toBeVisible()
expect(canvas.getByText('invalid type')).toBeVisible()
})
},
}
13 changes: 10 additions & 3 deletions web/src/app/storybook/defaultDestTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ export const destTypes: DestinationTypeInfo[] = [
isContactMethod: true,
isEPTarget: true,
isSchedOnCallNotify: true,
isDynamicAction: false,
isDynamicAction: true,
iconURL: '',
iconAltText: '',
supportsStatusUpdates: false,
statusUpdatesRequired: false,
dynamicParams: [],
dynamicParams: [
{
paramID: 'example-param',
label: 'Example Param',
hint: 'Some hint text',
hintURL: '',
},
],
requiredFields: [
{
fieldID: 'phone-number',
Expand Down Expand Up @@ -143,7 +150,7 @@ export const destTypes: DestinationTypeInfo[] = [
isContactMethod: true,
isEPTarget: true,
isSchedOnCallNotify: true,
isDynamicAction: false,
isDynamicAction: true,
iconURL: '',
iconAltText: '',
supportsStatusUpdates: false,
Expand Down
Loading
Loading