Merge pull request #889 from automatisch/feature/dynamic-fields

feat: add dynamic fields support
This commit is contained in:
Ali BARIN
2023-03-01 19:31:23 +01:00
committed by GitHub
16 changed files with 413 additions and 120 deletions

View File

@@ -51,25 +51,20 @@ export default defineAction({
value: false, value: false,
}, },
], ],
}, additionalFields: {
{ type: 'query',
label: 'Bot name', name: 'getDynamicFields',
key: 'botName', arguments: [
type: 'string' as const, {
required: true, name: 'key',
value: 'Automatisch', value: 'listFieldsAfterSendAsBot',
description: },
'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', {
variables: true, name: 'parameters.sendAsBot',
}, value: '{parameters.sendAsBot}',
{ },
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,
}, },
], ],

View File

@@ -0,0 +1,3 @@
import listFieldsAfterSendAsBot from './send-as-bot';
export default [listFieldsAfterSendAsBot];

View File

@@ -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,
},
];
}
},
};

View File

@@ -3,6 +3,7 @@ import addAuthHeader from './common/add-auth-header';
import actions from './actions'; import actions from './actions';
import auth from './auth'; import auth from './auth';
import dynamicData from './dynamic-data'; import dynamicData from './dynamic-data';
import dynamicFields from './dynamic-fields';
export default defineApp({ export default defineApp({
name: 'Slack', name: 'Slack',
@@ -17,4 +18,5 @@ export default defineApp({
auth, auth,
actions, actions,
dynamicData, dynamicData,
dynamicFields,
}); });

View File

@@ -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;

View File

@@ -9,6 +9,7 @@ import getExecution from './queries/get-execution';
import getExecutions from './queries/get-executions'; import getExecutions from './queries/get-executions';
import getExecutionSteps from './queries/get-execution-steps'; import getExecutionSteps from './queries/get-execution-steps';
import getDynamicData from './queries/get-dynamic-data'; import getDynamicData from './queries/get-dynamic-data';
import getDynamicFields from './queries/get-dynamic-fields';
import getCurrentUser from './queries/get-current-user'; import getCurrentUser from './queries/get-current-user';
import getLicense from './queries/get-license.ee'; import getLicense from './queries/get-license.ee';
import healthcheck from './queries/healthcheck'; import healthcheck from './queries/healthcheck';
@@ -25,6 +26,7 @@ const queryResolvers = {
getExecutions, getExecutions,
getExecutionSteps, getExecutionSteps,
getDynamicData, getDynamicData,
getDynamicFields,
getCurrentUser, getCurrentUser,
getLicense, getLicense,
healthcheck, healthcheck,

View File

@@ -28,6 +28,11 @@ type Query {
key: String! key: String!
parameters: JSONObject parameters: JSONObject
): JSONObject ): JSONObject
getDynamicFields(
stepId: String!
key: String!
parameters: JSONObject
): [SubstepArgument]
getCurrentUser: User getCurrentUser: User
getLicense: GetLicense getLicense: GetLicense
healthcheck: AppHealth healthcheck: AppHealth
@@ -65,38 +70,64 @@ directive @specifiedBy(
url: String! url: String!
) on SCALAR ) on SCALAR
type Trigger {
name: String
key: String
description: String
pollInterval: Int
type: String
substeps: [Substep]
}
type Action { type Action {
name: String name: String
key: String key: String
description: String description: String
substeps: [ActionSubstep] substeps: [Substep]
} }
type ActionSubstep { type Substep {
key: String key: String
name: String name: String
arguments: [ActionSubstepArgument] arguments: [SubstepArgument]
} }
type ActionSubstepArgument { type SubstepArgument {
label: String label: String
key: String key: String
type: String type: String
description: String description: String
required: Boolean required: Boolean
variables: Boolean variables: Boolean
options: [ArgumentOption] options: [SubstepArgumentOption]
source: ActionSubstepArgumentSource source: SubstepArgumentSource
additionalFields: SubstepArgumentAdditionalFields
dependsOn: [String] dependsOn: [String]
} }
type ActionSubstepArgumentSource { type SubstepArgumentOption {
type: String label: String
name: String value: JSONObject
arguments: [ActionSubstepArgumentSourceArgument]
} }
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 name: String
value: String value: String
} }
@@ -198,7 +229,7 @@ type Field {
description: String description: String
docUrl: String docUrl: String
clickToCopy: Boolean clickToCopy: Boolean
options: [ArgumentOption] options: [SubstepArgumentOption]
} }
type FlowConnection { type FlowConnection {
@@ -394,49 +425,6 @@ input StepInput {
previousStep: PreviousStepInput 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 { type User {
id: String id: String
email: String email: String

View File

@@ -149,6 +149,22 @@ class Step extends Base {
return command; 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; export default Step;

View File

@@ -104,6 +104,7 @@ export interface IFieldDropdown {
dependsOn?: string[]; dependsOn?: string[];
options?: IFieldDropdownOption[]; options?: IFieldDropdownOption[];
source?: IFieldDropdownSource; source?: IFieldDropdownSource;
additionalFields?: IFieldDropdownAdditionalFields;
} }
export interface IFieldDropdownSource { export interface IFieldDropdownSource {
@@ -114,6 +115,14 @@ export interface IFieldDropdownSource {
value: string; value: string;
}[]; }[];
} }
export interface IFieldDropdownAdditionalFields {
type: string;
name: string;
arguments: {
name: string;
value: string;
}[];
}
export interface IFieldDropdownOption { export interface IFieldDropdownOption {
label: string; label: string;
@@ -167,6 +176,7 @@ export interface IApp {
flowCount?: number; flowCount?: number;
beforeRequest?: TBeforeRequest[]; beforeRequest?: TBeforeRequest[];
dynamicData?: IDynamicData; dynamicData?: IDynamicData;
dynamicFields?: IDynamicFields;
triggers?: ITrigger[]; triggers?: ITrigger[];
actions?: IAction[]; actions?: IAction[];
connections?: IConnection[]; connections?: IConnection[];
@@ -180,6 +190,10 @@ export interface IDynamicData {
[index: string]: any; [index: string]: any;
} }
export interface IDynamicFields {
[index: string]: any;
}
export interface IAuth { export interface IAuth {
generateAuthUrl?($: IGlobalVariable): Promise<void>; generateAuthUrl?($: IGlobalVariable): Promise<void>;
verifyCredentials?($: IGlobalVariable): Promise<void>; verifyCredentials?($: IGlobalVariable): Promise<void>;

View File

@@ -27,7 +27,7 @@ function ControlledAutocomplete(
required = false, required = false,
name, name,
defaultValue, defaultValue,
shouldUnregister, shouldUnregister = true,
onBlur, onBlur,
onChange, onChange,
description, description,

View File

@@ -1,7 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import MuiTextField from '@mui/material/TextField'; import MuiTextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
import type { IField, IFieldDropdownOption } from '@automatisch/types'; import type { IField, IFieldDropdownOption } from '@automatisch/types';
import useDynamicFields from 'hooks/useDynamicFields';
import useDynamicData from 'hooks/useDynamicData'; import useDynamicData from 'hooks/useDynamicData';
import PowerInput from 'components/PowerInput'; import PowerInput from 'components/PowerInput';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
@@ -52,56 +54,111 @@ export default function InputCreator(
} = schema; } = schema;
const { data, loading } = useDynamicData(stepId, schema); const { data, loading } = useDynamicData(stepId, schema);
const {
data: additionalFields,
loading: additionalFieldsLoading
} = useDynamicFields(stepId, schema);
const computedName = namePrefix ? `${namePrefix}.${name}` : name; const computedName = namePrefix ? `${namePrefix}.${name}` : name;
if (type === 'dropdown') { if (type === 'dropdown') {
const preparedOptions = schema.options || optionGenerator(data); const preparedOptions = schema.options || optionGenerator(data);
return ( return (
<ControlledAutocomplete <React.Fragment>
name={computedName} <ControlledAutocomplete
dependsOn={dependsOn} name={computedName}
fullWidth dependsOn={dependsOn}
disablePortal fullWidth
disableClearable={required} disablePortal
options={preparedOptions} disableClearable={required}
renderInput={(params) => <MuiTextField {...params} label={label} />} options={preparedOptions}
defaultValue={value as string} renderInput={(params) => <MuiTextField {...params} label={label} />}
description={description} defaultValue={value as string}
loading={loading} description={description}
disabled={disabled} loading={loading}
showOptionValue={showOptionValue} disabled={disabled}
/> showOptionValue={showOptionValue}
/>
{(additionalFieldsLoading && !additionalFields?.length) && <div>
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
</div>}
{additionalFields?.map((field) => (
<InputCreator
key={field.key}
schema={field}
namePrefix="parameters"
stepId={stepId}
disabled={disabled}
showOptionValue={true}
/>
))}
</React.Fragment>
); );
} }
if (type === 'string') { if (type === 'string') {
if (variables) { if (variables) {
return ( return (
<PowerInput <React.Fragment>
label={label} <PowerInput
description={description} label={label}
name={computedName} description={description}
required={required} name={computedName}
disabled={disabled} required={required}
/> disabled={disabled}
/>
{(additionalFieldsLoading && !additionalFields?.length) && <div>
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
</div>}
{additionalFields?.map((field) => (
<InputCreator
key={field.key}
schema={field}
namePrefix="parameters"
stepId={stepId}
disabled={disabled}
showOptionValue={true}
/>
))}
</React.Fragment>
); );
} }
return ( return (
<TextField <React.Fragment>
defaultValue={value} <TextField
required={required} defaultValue={value}
placeholder="" required={required}
readOnly={readOnly || disabled} placeholder=""
onChange={onChange} readOnly={readOnly || disabled}
onBlur={onBlur} onChange={onChange}
name={computedName} onBlur={onBlur}
label={label} name={computedName}
fullWidth label={label}
helperText={description} fullWidth
clickToCopy={clickToCopy} helperText={description}
/> clickToCopy={clickToCopy}
/>
{(additionalFieldsLoading && !additionalFields?.length) && <div>
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
</div>}
{additionalFields?.map((field) => (
<InputCreator
key={field.key}
schema={field}
namePrefix="parameters"
stepId={stepId}
disabled={disabled}
showOptionValue={true}
/>
))}
</React.Fragment>
); );
} }

View File

@@ -81,7 +81,7 @@ const PowerInput = (props: PowerInputProps) => {
name={name} name={name}
control={control} control={control}
defaultValue={defaultValue} defaultValue={defaultValue}
shouldUnregister={false} shouldUnregister={true}
render={({ render={({
field: { field: {
value, value,

View File

@@ -38,9 +38,10 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
required, required,
name, name,
defaultValue, defaultValue,
shouldUnregister, shouldUnregister = true,
clickToCopy, clickToCopy = false,
readOnly, readOnly = false,
disabled = false,
onBlur, onBlur,
onChange, onChange,
...textFieldProps ...textFieldProps
@@ -64,6 +65,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
<MuiTextField <MuiTextField
{...textFieldProps} {...textFieldProps}
{...field} {...field}
disabled={disabled}
onChange={(...args) => { onChange={(...args) => {
controllerOnChange(...args); controllerOnChange(...args);
onChange?.(...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,
};

View File

@@ -122,6 +122,14 @@ export const GET_APPS = gql`
value value
} }
} }
additionalFields {
type
name
arguments {
name
value
}
}
} }
} }
} }

View File

@@ -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
}
}
}
`;

View File

@@ -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;