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 {