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 7403527c..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,25 +51,20 @@ export default defineAction({ value: false, }, ], - }, - { - label: 'Bot name', - key: 'botName', - type: 'string' as const, - required: true, - value: 'Automatisch', - description: - 'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', - variables: true, - }, - { - label: 'Bot icon', - key: 'botIcon', - type: 'string' as const, - required: false, - description: - 'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:', - variables: true, + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listFieldsAfterSendAsBot', + }, + { + name: 'parameters.sendAsBot', + value: '{parameters.sendAsBot}', + }, + ], + }, }, ], diff --git a/packages/backend/src/apps/slack/dynamic-fields/index.ts b/packages/backend/src/apps/slack/dynamic-fields/index.ts new file mode 100644 index 00000000..220e3ef3 --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-fields/index.ts @@ -0,0 +1,3 @@ +import listFieldsAfterSendAsBot from './send-as-bot'; + +export default [listFieldsAfterSendAsBot]; diff --git a/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.ts b/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.ts new file mode 100644 index 00000000..75c6c5e0 --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.ts @@ -0,0 +1,32 @@ +import { IGlobalVariable } from '@automatisch/types'; + +export default { + name: 'List fields after send as bot', + key: 'listFieldsAfterSendAsBot', + + async run($: IGlobalVariable) { + if ($.step.parameters.sendAsBot) { + return [ + { + label: 'Bot name', + key: 'botName', + type: 'string' as const, + required: true, + value: 'Automatisch', + description: + 'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', + variables: true, + }, + { + label: 'Bot icon', + key: 'botIcon', + type: 'string' as const, + required: false, + description: + 'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:', + variables: true, + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/slack/index.ts b/packages/backend/src/apps/slack/index.ts index 9869e5ea..1f2570b7 100644 --- a/packages/backend/src/apps/slack/index.ts +++ b/packages/backend/src/apps/slack/index.ts @@ -3,6 +3,7 @@ import addAuthHeader from './common/add-auth-header'; import actions from './actions'; import auth from './auth'; import dynamicData from './dynamic-data'; +import dynamicFields from './dynamic-fields'; export default defineApp({ name: 'Slack', @@ -17,4 +18,5 @@ export default defineApp({ auth, actions, dynamicData, + dynamicFields, }); diff --git a/packages/backend/src/graphql/queries/get-dynamic-fields.ts b/packages/backend/src/graphql/queries/get-dynamic-fields.ts new file mode 100644 index 00000000..cd72ba12 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-dynamic-fields.ts @@ -0,0 +1,48 @@ +import { IDynamicFields, IJSONObject } from '@automatisch/types'; +import Context from '../../types/express/context'; +import App from '../../models/app'; +import globalVariable from '../../helpers/global-variable'; + +type Params = { + stepId: string; + key: string; + parameters: IJSONObject; +}; + +const getDynamicFields = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const step = await context.currentUser + .$relatedQuery('steps') + .withGraphFetched({ + connection: true, + flow: true, + }) + .findById(params.stepId); + + if (!step) return null; + + const connection = step.connection; + + if (!connection || !step.appKey) return null; + + const app = await App.findOneByKey(step.appKey); + const $ = await globalVariable({ connection, app, flow: step.flow, step }); + + const command = app.dynamicFields.find( + (data: IDynamicFields) => data.key === params.key + ); + + for (const parameterKey in params.parameters) { + const parameterValue = params.parameters[parameterKey]; + $.step.parameters[parameterKey] = parameterValue; + } + + const additionalFields = await command.run($) || []; + + return additionalFields; +}; + +export default getDynamicFields; diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 35b6280a..48617d99 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -9,6 +9,7 @@ import getExecution from './queries/get-execution'; import getExecutions from './queries/get-executions'; import getExecutionSteps from './queries/get-execution-steps'; import getDynamicData from './queries/get-dynamic-data'; +import getDynamicFields from './queries/get-dynamic-fields'; import getCurrentUser from './queries/get-current-user'; import getLicense from './queries/get-license.ee'; import healthcheck from './queries/healthcheck'; @@ -25,6 +26,7 @@ const queryResolvers = { getExecutions, getExecutionSteps, getDynamicData, + getDynamicFields, getCurrentUser, getLicense, healthcheck, diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index e60add0a..5598932f 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -28,6 +28,11 @@ type Query { key: String! parameters: JSONObject ): JSONObject + getDynamicFields( + stepId: String! + key: String! + parameters: JSONObject + ): [SubstepArgument] getCurrentUser: User getLicense: GetLicense healthcheck: AppHealth @@ -65,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 } @@ -198,7 +229,7 @@ type Field { description: String docUrl: String clickToCopy: Boolean - options: [ArgumentOption] + options: [SubstepArgumentOption] } type FlowConnection { @@ -394,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/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 294671af..2815df7b 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -149,6 +149,22 @@ class Step extends Base { return command; } + + async getSetupFields() { + let setupSupsteps; + + if (this.isTrigger) { + setupSupsteps = (await this.getTriggerCommand()).substeps; + } else { + setupSupsteps = (await this.getActionCommand()).substeps; + } + + const existingArguments = setupSupsteps.find( + (substep) => substep.key === 'chooseTrigger' + ).arguments; + + return existingArguments; + } } export default Step; diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index e559ca78..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; @@ -167,6 +176,7 @@ export interface IApp { flowCount?: number; beforeRequest?: TBeforeRequest[]; dynamicData?: IDynamicData; + dynamicFields?: IDynamicFields; triggers?: ITrigger[]; actions?: IAction[]; connections?: IConnection[]; @@ -180,6 +190,10 @@ export interface IDynamicData { [index: string]: any; } +export interface IDynamicFields { + [index: string]: any; +} + export interface IAuth { generateAuthUrl?($: IGlobalVariable): Promise; verifyCredentials?($: IGlobalVariable): Promise; 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;