From c6b8f12f9a20e8d240728a6390c5d5b4f7b27169 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Tue, 28 Feb 2023 22:22:08 +0000 Subject: [PATCH] feat(Editor): implement dynamic fields --- .../send-a-message-to-channel/index.ts | 2 +- .../src/graphql/queries/get-dynamic-fields.ts | 3 +- packages/backend/src/graphql/schema.graphql | 95 ++++++------- packages/types/index.d.ts | 9 ++ .../ControlledAutocomplete/index.tsx | 2 +- .../web/src/components/InputCreator/index.tsx | 125 +++++++++++++----- .../web/src/components/PowerInput/index.tsx | 2 +- .../web/src/components/TextField/index.tsx | 15 +-- packages/web/src/graphql/queries/get-apps.ts | 8 ++ .../src/graphql/queries/get-dynamic-fields.ts | 21 +++ packages/web/src/hooks/useDynamicFields.ts | 112 ++++++++++++++++ 11 files changed, 289 insertions(+), 105 deletions(-) create mode 100644 packages/web/src/graphql/queries/get-dynamic-fields.ts create mode 100644 packages/web/src/hooks/useDynamicFields.ts diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts index 39d92f70..70315501 100644 --- a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts @@ -51,7 +51,7 @@ export default defineAction({ value: false, }, ], - source: { + additionalFields: { type: 'query', name: 'getDynamicFields', arguments: [ diff --git a/packages/backend/src/graphql/queries/get-dynamic-fields.ts b/packages/backend/src/graphql/queries/get-dynamic-fields.ts index 755ef842..386f66b8 100644 --- a/packages/backend/src/graphql/queries/get-dynamic-fields.ts +++ b/packages/backend/src/graphql/queries/get-dynamic-fields.ts @@ -40,14 +40,13 @@ const getDynamicFields = async ( $.step.parameters[parameterKey] = parameterValue; } - const existingArguments = await step.getSetupFields(); const remainingArguments = await command.run($); if (remainingArguments.error) { throw new Error(JSON.stringify(remainingArguments.error)); } - return [...existingArguments, ...remainingArguments.data]; + return remainingArguments.data; }; export default getDynamicFields; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index be0d2936..5598932f 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -32,7 +32,7 @@ type Query { stepId: String! key: String! parameters: JSONObject - ): [Field] + ): [SubstepArgument] getCurrentUser: User getLicense: GetLicense healthcheck: AppHealth @@ -70,38 +70,64 @@ directive @specifiedBy( url: String! ) on SCALAR +type Trigger { + name: String + key: String + description: String + pollInterval: Int + type: String + substeps: [Substep] +} + type Action { name: String key: String description: String - substeps: [ActionSubstep] + substeps: [Substep] } -type ActionSubstep { +type Substep { key: String name: String - arguments: [ActionSubstepArgument] + arguments: [SubstepArgument] } -type ActionSubstepArgument { +type SubstepArgument { label: String key: String type: String description: String required: Boolean variables: Boolean - options: [ArgumentOption] - source: ActionSubstepArgumentSource + options: [SubstepArgumentOption] + source: SubstepArgumentSource + additionalFields: SubstepArgumentAdditionalFields dependsOn: [String] } -type ActionSubstepArgumentSource { - type: String - name: String - arguments: [ActionSubstepArgumentSourceArgument] +type SubstepArgumentOption { + label: String + value: JSONObject } -type ActionSubstepArgumentSourceArgument { +type SubstepArgumentSource { + type: String + name: String + arguments: [SubstepArgumentSourceArgument] +} + +type SubstepArgumentSourceArgument { + name: String + value: String +} + +type SubstepArgumentAdditionalFields { + type: String + name: String + arguments: [SubstepArgumentAdditionalFieldsArgument] +} + +type SubstepArgumentAdditionalFieldsArgument { name: String value: String } @@ -203,7 +229,7 @@ type Field { description: String docUrl: String clickToCopy: Boolean - options: [ArgumentOption] + options: [SubstepArgumentOption] } type FlowConnection { @@ -399,49 +425,6 @@ input StepInput { previousStep: PreviousStepInput } -type Trigger { - name: String - key: String - description: String - pollInterval: Int - type: String - substeps: [TriggerSubstep] -} - -type TriggerSubstep { - key: String - name: String - arguments: [TriggerSubstepArgument] -} - -type TriggerSubstepArgument { - label: String - key: String - type: String - description: String - required: Boolean - variables: Boolean - source: TriggerSubstepArgumentSource - dependsOn: [String] - options: [ArgumentOption] -} - -type TriggerSubstepArgumentSource { - type: String - name: String - arguments: [TriggerSubstepArgumentSourceArgument] -} - -type ArgumentOption { - label: String - value: JSONObject -} - -type TriggerSubstepArgumentSourceArgument { - name: String - value: String -} - type User { id: String email: String diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 842b5798..fc5be730 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -104,6 +104,7 @@ export interface IFieldDropdown { dependsOn?: string[]; options?: IFieldDropdownOption[]; source?: IFieldDropdownSource; + additionalFields?: IFieldDropdownAdditionalFields; } export interface IFieldDropdownSource { @@ -114,6 +115,14 @@ export interface IFieldDropdownSource { value: string; }[]; } +export interface IFieldDropdownAdditionalFields { + type: string; + name: string; + arguments: { + name: string; + value: string; + }[]; +} export interface IFieldDropdownOption { label: string; diff --git a/packages/web/src/components/ControlledAutocomplete/index.tsx b/packages/web/src/components/ControlledAutocomplete/index.tsx index d0a3b997..bad45fc0 100644 --- a/packages/web/src/components/ControlledAutocomplete/index.tsx +++ b/packages/web/src/components/ControlledAutocomplete/index.tsx @@ -27,7 +27,7 @@ function ControlledAutocomplete( required = false, name, defaultValue, - shouldUnregister, + shouldUnregister = true, onBlur, onChange, description, diff --git a/packages/web/src/components/InputCreator/index.tsx b/packages/web/src/components/InputCreator/index.tsx index 0cf0d641..e75e9bb8 100644 --- a/packages/web/src/components/InputCreator/index.tsx +++ b/packages/web/src/components/InputCreator/index.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; import MuiTextField from '@mui/material/TextField'; +import CircularProgress from '@mui/material/CircularProgress'; import type { IField, IFieldDropdownOption } from '@automatisch/types'; +import useDynamicFields from 'hooks/useDynamicFields'; import useDynamicData from 'hooks/useDynamicData'; import PowerInput from 'components/PowerInput'; import TextField from 'components/TextField'; @@ -52,56 +54,111 @@ export default function InputCreator( } = schema; const { data, loading } = useDynamicData(stepId, schema); + const { + data: additionalFields, + loading: additionalFieldsLoading + } = useDynamicFields(stepId, schema); const computedName = namePrefix ? `${namePrefix}.${name}` : name; if (type === 'dropdown') { const preparedOptions = schema.options || optionGenerator(data); return ( - } - defaultValue={value as string} - description={description} - loading={loading} - disabled={disabled} - showOptionValue={showOptionValue} - /> + + } + defaultValue={value as string} + description={description} + loading={loading} + disabled={disabled} + showOptionValue={showOptionValue} + /> + + {(additionalFieldsLoading && !additionalFields?.length) &&
+ +
} + + {additionalFields?.map((field) => ( + + ))} +
); } if (type === 'string') { if (variables) { return ( - + + + + {(additionalFieldsLoading && !additionalFields?.length) &&
+ +
} + + {additionalFields?.map((field) => ( + + ))} +
); } return ( - + + + + {(additionalFieldsLoading && !additionalFields?.length) &&
+ +
} + + {additionalFields?.map((field) => ( + + ))} +
); } diff --git a/packages/web/src/components/PowerInput/index.tsx b/packages/web/src/components/PowerInput/index.tsx index ed6213e5..2bdfa6a4 100644 --- a/packages/web/src/components/PowerInput/index.tsx +++ b/packages/web/src/components/PowerInput/index.tsx @@ -81,7 +81,7 @@ const PowerInput = (props: PowerInputProps) => { name={name} control={control} defaultValue={defaultValue} - shouldUnregister={false} + shouldUnregister={true} render={({ field: { value, diff --git a/packages/web/src/components/TextField/index.tsx b/packages/web/src/components/TextField/index.tsx index 8d13f773..9686740b 100644 --- a/packages/web/src/components/TextField/index.tsx +++ b/packages/web/src/components/TextField/index.tsx @@ -38,9 +38,10 @@ export default function TextField(props: TextFieldProps): React.ReactElement { required, name, defaultValue, - shouldUnregister, - clickToCopy, - readOnly, + shouldUnregister = true, + clickToCopy = false, + readOnly = false, + disabled = false, onBlur, onChange, ...textFieldProps @@ -64,6 +65,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement { { controllerOnChange(...args); onChange?.(...args); @@ -85,10 +87,3 @@ export default function TextField(props: TextFieldProps): React.ReactElement { /> ); } - -TextField.defaultProps = { - readOnly: false, - disabled: false, - clickToCopy: false, - shouldUnregister: false, -}; diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index c6d19682..821ba1c5 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -122,6 +122,14 @@ export const GET_APPS = gql` value } } + additionalFields { + type + name + arguments { + name + value + } + } } } } diff --git a/packages/web/src/graphql/queries/get-dynamic-fields.ts b/packages/web/src/graphql/queries/get-dynamic-fields.ts new file mode 100644 index 00000000..5bf5b651 --- /dev/null +++ b/packages/web/src/graphql/queries/get-dynamic-fields.ts @@ -0,0 +1,21 @@ +import { gql } from '@apollo/client'; + +export const GET_DYNAMIC_FIELDS = gql` + query GetDynamicFields( + $stepId: String! + $key: String! + $parameters: JSONObject + ) { + getDynamicFields(stepId: $stepId, key: $key, parameters: $parameters) { + label + key + type + required + description + options { + label + value + } + } + } +`; diff --git a/packages/web/src/hooks/useDynamicFields.ts b/packages/web/src/hooks/useDynamicFields.ts new file mode 100644 index 00000000..c5a3f431 --- /dev/null +++ b/packages/web/src/hooks/useDynamicFields.ts @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { useLazyQuery } from '@apollo/client'; +import type { UseFormReturn } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import set from 'lodash/set'; +import isEqual from 'lodash/isEqual'; +import type { + IField, + IFieldDropdownAdditionalFields, + IJSONObject, +} from '@automatisch/types'; + +import { GET_DYNAMIC_FIELDS } from 'graphql/queries/get-dynamic-fields'; + +const variableRegExp = /({.*?})/g; + +// TODO: extract this function to a separate file +function computeArguments( + args: IFieldDropdownAdditionalFields['arguments'], + getValues: UseFormReturn['getValues'] +): IJSONObject { + const initialValue = {}; + return args.reduce((result, { name, value }) => { + const isVariable = variableRegExp.test(value); + + if (isVariable) { + const sanitizedFieldPath = value.replace(/{|}/g, ''); + const computedValue = getValues(sanitizedFieldPath); + + if (computedValue === undefined) + throw new Error(`The ${sanitizedFieldPath} field is required.`); + + set(result, name, computedValue); + + return result; + } + + set(result, name, value); + + return result; + }, initialValue); +} + +/** + * Fetch the dynamic fields for the given step. + * This hook must be within a react-hook-form context. + * + * @param stepId - the id of the step + * @param schema - the field schema that needs the dynamic fields + */ +function useDynamicFields(stepId: string | undefined, schema: IField) { + const lastComputedVariables = React.useRef({}); + const [getDynamicFields, { called, data, loading }] = + useLazyQuery(GET_DYNAMIC_FIELDS); + const { getValues } = useFormContext(); + const formValues = getValues(); + + /** + * Return `null` when even a field is missing value. + * + * This must return the same reference if no computed variable is changed. + * Otherwise, it causes redundant network request! + */ + const computedVariables = React.useMemo(() => { + if (schema.type === 'dropdown' && schema.additionalFields) { + try { + const variables = computeArguments(schema.additionalFields.arguments, getValues); + + // if computed variables are the same, return the last computed variables. + if (isEqual(variables, lastComputedVariables.current)) { + return lastComputedVariables.current; + } + + lastComputedVariables.current = variables; + + return variables; + } catch (err) { + return null; + } + } + + return null; + /** + * `formValues` is to trigger recomputation when form is updated. + * `getValues` is for convenience as it supports paths for fields like `getValues('foo.bar.baz')`. + */ + }, [schema, formValues, getValues]); + + React.useEffect(() => { + if ( + schema.type === 'dropdown' && + stepId && + schema.additionalFields && + computedVariables + ) { + getDynamicFields({ + variables: { + stepId, + ...computedVariables, + }, + }); + } + }, [getDynamicFields, stepId, schema, computedVariables]); + + return { + called, + data: data?.getDynamicFields as IField[] | undefined, + loading, + }; +} + +export default useDynamicFields;