diff --git a/web/src/app/selection/DestinationField.stories.tsx b/web/src/app/selection/DestinationField.stories.tsx new file mode 100644 index 0000000000..6bfad42b17 --- /dev/null +++ b/web/src/app/selection/DestinationField.stories.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import DestinationField from './DestinationField' +import { expect } from '@storybook/jest' +import { within } from '@storybook/testing-library' +import { handleDefaultConfig } from '../storybook/graphql' +import { useArgs } from '@storybook/preview-api' +import { FieldValueInput } from '../../schema' + +const meta = { + title: 'util/DestinationField', + component: DestinationField, + tags: ['autodocs'], + argTypes: { + destType: { + control: 'select', + options: ['single-field', 'triple-field', 'disabled-destination'], + }, + }, + parameters: { + msw: { + handlers: [handleDefaultConfig], + }, + }, + render: function Component(args) { + const [, setArgs] = useArgs() + const onChange = (newValue: FieldValueInput[]): void => { + if (args.onChange) args.onChange(newValue) + setArgs({ value: newValue }) + } + return + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SingleField: Story = { + args: { + destType: 'single-field', + value: [ + { + fieldID: 'phone-number', + value: '', + }, + ], + disabled: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // ensure information renders correctly + await expect(canvas.getByLabelText('Phone Number')).toBeVisible() + await expect( + canvas.getByText( + 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)', + ), + ).toBeVisible() + await expect(canvas.getByText('+')).toBeVisible() + await expect(canvas.getByPlaceholderText('11235550123')).toBeVisible() + }, +} + +export const MultiField: Story = { + args: { + destType: 'triple-field', + value: [ + { + fieldID: 'first-field', + value: '', + }, + { + fieldID: 'second-field', + value: 'test@example.com', + }, + { + fieldID: 'third-field', + value: '', + }, + ], + disabled: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // ensure information for phone number renders correctly + await expect(canvas.getByLabelText('First Item')).toBeVisible() + await expect(canvas.getByText('Some hint text')).toBeVisible() + await expect(canvas.getByText('+')).toBeVisible() + await expect(canvas.getByPlaceholderText('11235550123')).toBeVisible() + + // ensure information for email renders correctly + await expect( + canvas.getByPlaceholderText('foobar@example.com'), + ).toBeVisible() + await expect(canvas.getByLabelText('Second Item')).toBeVisible() + + // ensure information for slack renders correctly + await expect(canvas.getByPlaceholderText('slack user ID')).toBeVisible() + await expect(canvas.getByLabelText('Third Item')).toBeVisible() + }, +} + +export const DisabledField: Story = { + args: { + destType: 'disabled-destination', + value: [ + { + fieldID: 'disabled', + value: '', + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // ensure information renders correctly + await expect( + canvas.getByPlaceholderText('This field is disabled.'), + ).toBeVisible() + }, +} + +export const FieldError: Story = { + args: { + destType: 'triple-field', + value: [ + { + fieldID: 'first-field', + value: '', + }, + { + fieldID: 'second-field', + value: 'test@example.com', + }, + { + fieldID: 'third-field', + value: '', + }, + ], + disabled: false, + destFieldErrors: [ + { + fieldID: 'third-field', + message: 'This is an error message (third)', + }, + { + fieldID: 'first-field', + message: 'This is an error message (first)', + }, + ], + }, +} diff --git a/web/src/app/selection/DestinationField.tsx b/web/src/app/selection/DestinationField.tsx new file mode 100644 index 0000000000..0a5bcf1fc5 --- /dev/null +++ b/web/src/app/selection/DestinationField.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { DestinationType, FieldValueInput } from '../../schema' +import DestinationInputDirect from './DestinationInputDirect' +import { useDestinationType } from '../util/RequireConfig' +import DestinationSearchSelect from './DestinationSearchSelect' +import { Grid } from '@mui/material' + +export type DestinationFieldProps = { + value: FieldValueInput[] + onChange?: (value: FieldValueInput[]) => void + destType: DestinationType + + disabled?: boolean + + destFieldErrors?: DestFieldError[] +} + +export interface DestFieldError { + fieldID: string + message: string +} + +export default function DestinationField( + props: DestinationFieldProps, +): React.ReactNode { + const dest = useDestinationType(props.destType) + + return ( + + {dest.requiredFields.map((field) => { + const fieldValue = + (props.value || []).find((v) => v.fieldID === field.fieldID)?.value || + '' + + function handleChange(newValue: string): void { + if (!props.onChange) return + + const newValues = (props.value || []) + .filter((v) => v.fieldID !== field.fieldID) + .concat({ + fieldID: field.fieldID, + value: newValue, + }) + + props.onChange(newValues) + } + + const fieldErrMsg = + props.destFieldErrors?.find((err) => err.fieldID === field.fieldID) + ?.message || '' + + if (field.isSearchSelectable) + return ( + + handleChange(val)} + error={!!fieldErrMsg} + hint={fieldErrMsg || field.hint} + hintURL={fieldErrMsg ? '' : field.hintURL} + /> + + ) + + return ( + + handleChange(e.target.value)} + error={!!fieldErrMsg} + hint={fieldErrMsg || field.hint} + hintURL={fieldErrMsg ? '' : field.hintURL} + /> + + ) + })} + + ) + return +} diff --git a/web/src/app/selection/DestinationInputDirect.tsx b/web/src/app/selection/DestinationInputDirect.tsx index b9bdac2c74..81e96d0c37 100644 --- a/web/src/app/selection/DestinationInputDirect.tsx +++ b/web/src/app/selection/DestinationInputDirect.tsx @@ -30,6 +30,7 @@ export type DestinationInputDirectProps = DestinationFieldConfig & { destType: DestinationType disabled?: boolean + error?: boolean } /** @@ -132,6 +133,7 @@ export default function DestinationInputDirect( } onChange={handleChange} value={trimPrefix(props.value, props.prefix)} + error={props.error} /> ) } diff --git a/web/src/app/storybook/defaultDestTypes.ts b/web/src/app/storybook/defaultDestTypes.ts new file mode 100644 index 0000000000..62c1acfa65 --- /dev/null +++ b/web/src/app/storybook/defaultDestTypes.ts @@ -0,0 +1,106 @@ +import { DestinationTypeInfo } from '../../schema' + +export const destTypes: DestinationTypeInfo[] = [ + { + type: 'single-field', + name: 'Single Field Destination Type', + enabled: true, + disabledMessage: 'Single field destination type must be configured.', + userDisclaimer: '', + isContactMethod: true, + isEPTarget: false, + isSchedOnCallNotify: false, + iconURL: '', + iconAltText: '', + requiredFields: [ + { + fieldID: 'phone-number', + labelSingular: 'Phone Number', + labelPlural: 'Phone Numbers', + hint: 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)', + hintURL: '', + placeholderText: '11235550123', + prefix: '+', + inputType: 'tel', + isSearchSelectable: false, + supportsValidation: true, + }, + ], + }, + { + type: 'triple-field', + name: 'Multi Field Destination Type', + enabled: true, + disabledMessage: 'Multi field destination type must be configured.', + userDisclaimer: '', + isContactMethod: true, + isEPTarget: false, + isSchedOnCallNotify: false, + iconURL: '', + iconAltText: '', + requiredFields: [ + { + fieldID: 'first-field', + labelSingular: 'First Item', + labelPlural: 'First Items', + hint: 'Some hint text', + hintURL: '', + placeholderText: '11235550123', + prefix: '+', + inputType: 'tel', + isSearchSelectable: false, + supportsValidation: true, + }, + { + fieldID: 'second-field', + labelSingular: 'Second Item', + labelPlural: 'Second Items', + hint: '', + hintURL: '', + placeholderText: 'foobar@example.com', + prefix: '', + inputType: 'email', + isSearchSelectable: false, + supportsValidation: true, + }, + { + fieldID: 'third-field', + labelSingular: 'Third Item', + labelPlural: 'Third Items', + hint: '', + hintURL: '', + placeholderText: 'slack user ID', + prefix: '', + inputType: 'string', + isSearchSelectable: false, + supportsValidation: true, + }, + ], + }, + { + type: 'disabled-destination', + name: 'This is disabled', + enabled: false, + disabledMessage: 'This field is disabled.', + userDisclaimer: '', + isContactMethod: true, + isEPTarget: true, + isSchedOnCallNotify: true, + iconURL: '', + iconAltText: '', + requiredFields: [ + { + fieldID: 'disabled', + labelSingular: '', + labelPlural: '', + hint: '', + hintURL: '', + placeholderText: 'This field is disabled.', + prefix: '', + inputType: 'url', + isSearchSelectable: false, + supportsValidation: false, + }, + ], + }, +] diff --git a/web/src/app/storybook/graphql.ts b/web/src/app/storybook/graphql.ts index 8f7459988e..abdf68b40a 100644 --- a/web/src/app/storybook/graphql.ts +++ b/web/src/app/storybook/graphql.ts @@ -2,9 +2,11 @@ import { GraphQLHandler, HttpResponse, graphql } from 'msw' import { ConfigID, ConfigType, + DestinationTypeInfo, IntegrationKeyTypeInfo, UserRole, } from '../../schema' +import { destTypes } from './defaultDestTypes' export type ConfigItem = { id: ConfigID @@ -20,6 +22,7 @@ export type RequireConfigDoc = { } config: ConfigItem[] integrationKeyTypes: IntegrationKeyTypeInfo[] + destinationTypes: DestinationTypeInfo[] } export function handleConfig(doc: RequireConfigDoc): GraphQLHandler { @@ -57,6 +60,7 @@ export const defaultConfig: RequireConfigDoc = { enabled: false, }, ], + destinationTypes: destTypes, } export const handleDefaultConfig = handleConfig(defaultConfig) diff --git a/web/src/app/util/RequireConfig.tsx b/web/src/app/util/RequireConfig.tsx index 439c3bbddf..ba92753cf4 100644 --- a/web/src/app/util/RequireConfig.tsx +++ b/web/src/app/util/RequireConfig.tsx @@ -5,7 +5,10 @@ import { ConfigValue, ConfigID, IntegrationKeyTypeInfo, + DestinationTypeInfo, + DestinationType, } from '../../schema' +import { useExpFlag } from './useExpFlag' type Value = boolean | number | string | string[] | null export type ConfigData = Record @@ -16,6 +19,7 @@ const ConfigContext = React.createContext({ isAdmin: false as boolean, userID: '' as string, userName: null as string | null, + destTypes: [] as DestinationTypeInfo[], }) ConfigContext.displayName = 'ConfigContext' @@ -40,12 +44,62 @@ const query = gql` } ` +// expDestQuery will be used when "dest-types" experimental flag is enabled. +// expDestQuery should replace "query" when new components have been fully integrated into master branch for https://github.com/target/goalert/issues/2639 +const expDestQuery = gql` + query RequireConfig { + user { + id + name + role + } + config { + id + type + value + } + integrationKeyTypes { + id + name + label + enabled + } + destinationTypes { + type + name + enabled + disabledMessage + userDisclaimer + + isContactMethod + isEPTarget + isSchedOnCallNotify + + requiredFields { + fieldID + labelSingular + labelPlural + hint + hintURL + placeholderText + prefix + inputType + isSearchSelectable + supportsValidation + } + } + } +` + type ConfigProviderProps = { children: ReactChild | ReactChild[] } export function ConfigProvider(props: ConfigProviderProps): React.ReactNode { - const [{ data }] = useQuery({ query }) + const hasDestTypesFlag = useExpFlag('dest-types') + const [{ data }] = useQuery({ + query: hasDestTypesFlag ? expDestQuery : query, + }) return ( {props.children} @@ -154,6 +209,16 @@ export function useConfigValue(...fields: ConfigID[]): Value[] { return fields.map((f) => config[f]) } +// useDestinationType returns information about the given destination type. +export function useDestinationType(type: DestinationType): DestinationTypeInfo { + const ctx = React.useContext(ConfigContext) + const typeInfo = ctx.destTypes.find((t) => t.type === type) + + if (!typeInfo) throw new Error(`unknown destination type '${type}'`) + + return typeInfo +} + export function Config(props: { children: (x: ConfigData, s?: SessionInfo) => JSX.Element }): JSX.Element {