feat(Editor): implement dynamic fields
This commit is contained in:
@@ -51,7 +51,7 @@ export default defineAction({
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
arguments: [
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
9
packages/types/index.d.ts
vendored
9
packages/types/index.d.ts
vendored
@@ -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;
|
||||
|
@@ -27,7 +27,7 @@ function ControlledAutocomplete(
|
||||
required = false,
|
||||
name,
|
||||
defaultValue,
|
||||
shouldUnregister,
|
||||
shouldUnregister = true,
|
||||
onBlur,
|
||||
onChange,
|
||||
description,
|
||||
|
@@ -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,12 +54,17 @@ 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 (
|
||||
<React.Fragment>
|
||||
<ControlledAutocomplete
|
||||
name={computedName}
|
||||
dependsOn={dependsOn}
|
||||
@@ -72,12 +79,29 @@ export default function InputCreator(
|
||||
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 (variables) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PowerInput
|
||||
label={label}
|
||||
description={description}
|
||||
@@ -85,10 +109,27 @@ export default function InputCreator(
|
||||
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 (
|
||||
<React.Fragment>
|
||||
<TextField
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
@@ -102,6 +143,22 @@ export default function InputCreator(
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -81,7 +81,7 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
shouldUnregister={false}
|
||||
shouldUnregister={true}
|
||||
render={({
|
||||
field: {
|
||||
value,
|
||||
|
@@ -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 {
|
||||
<MuiTextField
|
||||
{...textFieldProps}
|
||||
{...field}
|
||||
disabled={disabled}
|
||||
onChange={(...args) => {
|
||||
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,
|
||||
};
|
||||
|
@@ -122,6 +122,14 @@ export const GET_APPS = gql`
|
||||
value
|
||||
}
|
||||
}
|
||||
additionalFields {
|
||||
type
|
||||
name
|
||||
arguments {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
21
packages/web/src/graphql/queries/get-dynamic-fields.ts
Normal file
21
packages/web/src/graphql/queries/get-dynamic-fields.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
112
packages/web/src/hooks/useDynamicFields.ts
Normal file
112
packages/web/src/hooks/useDynamicFields.ts
Normal 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;
|
Reference in New Issue
Block a user