feat: support interactive fields

This commit is contained in:
Ali BARIN
2023-03-16 22:55:09 +00:00
parent 71a7943d01
commit 75bbd16b0c
9 changed files with 222 additions and 18 deletions

View File

@@ -107,6 +107,8 @@ type SubstepArgument {
source: SubstepArgumentSource source: SubstepArgumentSource
additionalFields: SubstepArgumentAdditionalFields additionalFields: SubstepArgumentAdditionalFields
dependsOn: [String] dependsOn: [String]
fields: [SubstepArgument]
value: JSONObject
} }
type SubstepArgumentOption { type SubstepArgumentOption {

View File

@@ -145,7 +145,18 @@ export interface IFieldText {
dependsOn?: string[]; dependsOn?: string[];
} }
export type IField = IFieldDropdown | IFieldText; export interface IFieldDynamic {
key: string;
label: string;
type: 'dynamic';
required?: boolean;
readOnly?: boolean;
description?: string;
value?: Record<string, unknown>[];
fields: (IFieldDropdown | IFieldText)[];
}
export type IField = IFieldDropdown | IFieldText | IFieldDynamic;
export interface IAuthenticationStepField { export interface IAuthenticationStepField {
name: string; name: string;

View File

@@ -0,0 +1,126 @@
import * as React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useFormContext, useWatch } from 'react-hook-form';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import RemoveIcon from '@mui/icons-material/Remove';
import AddIcon from '@mui/icons-material/Add';
import { IFieldDynamic } from '@automatisch/types';
import InputCreator from 'components/InputCreator';
import { EditorContext } from 'contexts/Editor';
interface DynamicFieldProps {
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
defaultValue?: Record<string, unknown>[];
name: string;
label: string;
type?: string;
required?: boolean;
readOnly?: boolean;
description?: string;
docUrl?: string;
clickToCopy?: boolean;
disabled?: boolean;
fields: IFieldDynamic["fields"];
shouldUnregister?: boolean;
}
function DynamicField(
props: DynamicFieldProps
): React.ReactElement {
const { label, description, fields, name, defaultValue } = props;
const { control, setValue, getValues } = useFormContext();
const fieldsValue = useWatch({ control, name }) as Record<string, unknown>[];
const editorContext = React.useContext(EditorContext);
const createEmptyItem = React.useCallback(() => {
return fields.reduce((previousValue, field) => {
return {
...previousValue,
[field.key]: '',
__id: uuidv4(),
}
}, {});
}, [fields]);
const addItem = React.useCallback(() => {
const values = getValues(name);
if (!values) {
setValue(name, [createEmptyItem()]);
} else {
setValue(name, values.concat(createEmptyItem()));
}
}, [getValues, createEmptyItem]);
const removeItem = React.useCallback((index) => {
if (fieldsValue.length === 1) return;
const newFieldsValue = fieldsValue.filter((fieldValue, fieldIndex) => fieldIndex !== index);
setValue(name, newFieldsValue);
}, [fieldsValue]);
React.useEffect(function addInitialGroupWhenEmpty() {
const fieldValues = getValues(name);
if (!fieldValues && defaultValue) {
setValue(name, defaultValue);
} else if (!fieldValues) {
setValue(name, [createEmptyItem()]);
}
}, [createEmptyItem, defaultValue]);
return (
<React.Fragment>
<Typography variant="subtitle2">{label}</Typography>
{fieldsValue?.map((field, index) => (
<Stack direction="row" spacing={2} key={`fieldGroup-${field.__id}`}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 2 }} sx={{ display: 'flex', flex: 1 }}>
{fields.map((fieldSchema, fieldSchemaIndex) => (
<Box sx={{ display: 'flex', flex: '1 0 0px' }} key={`field-${field.__id}-${fieldSchemaIndex}`}>
<InputCreator
schema={fieldSchema}
namePrefix={`${name}.${index}`}
disabled={editorContext.readOnly}
shouldUnregister={false}
/>
</Box>
))}
</Stack>
<IconButton
size="small"
edge="start"
onClick={() => removeItem(index)}
sx={{ width: 61, height: 61 }}
>
<RemoveIcon />
</IconButton>
</Stack>
))}
<Stack direction="row" spacing={2}>
<Stack spacing={{ xs: 2 }} sx={{ display: 'flex', flex: 1 }} />
<IconButton
size="small"
edge="start"
onClick={addItem}
sx={{ width: 61, height: 61 }}
>
<AddIcon />
</IconButton>
</Stack>
<Typography variant="caption">{description}</Typography>
</React.Fragment>
);
}
export default DynamicField;

View File

@@ -63,14 +63,14 @@ function generateValidationSchema(substeps: ISubstep[]) {
const substepArgumentValidations: Record<string, BaseSchema> = {}; const substepArgumentValidations: Record<string, BaseSchema> = {};
for (const arg of args) { for (const arg of args) {
const { key, required, dependsOn } = arg; const { key, required } = arg;
// base validation for the field if not exists // base validation for the field if not exists
if (!substepArgumentValidations[key]) { if (!substepArgumentValidations[key]) {
substepArgumentValidations[key] = yup.mixed(); substepArgumentValidations[key] = yup.mixed();
} }
if (typeof substepArgumentValidations[key] === 'object') { if (typeof substepArgumentValidations[key] === 'object' && (arg.type === 'string' || arg.type === 'dropdown')) {
// if the field is required, add the required validation // if the field is required, add the required validation
if (required) { if (required) {
substepArgumentValidations[key] = substepArgumentValidations[ substepArgumentValidations[key] = substepArgumentValidations[
@@ -79,8 +79,8 @@ function generateValidationSchema(substeps: ISubstep[]) {
} }
// if the field depends on another field, add the dependsOn required validation // if the field depends on another field, add the dependsOn required validation
if (Array.isArray(dependsOn) && dependsOn.length > 0) { if (Array.isArray(arg.dependsOn) && arg.dependsOn.length > 0) {
for (const dependsOnKey of dependsOn) { for (const dependsOnKey of arg.dependsOn) {
const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`; const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`;
// TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported. // TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported.

View File

@@ -144,7 +144,7 @@ function FilterConditions(props: FilterConditionsProps): React.ReactElement {
{group?.and?.map((groupItem: TGroupItem, groupItemIndex: number) => ( {group?.and?.map((groupItem: TGroupItem, groupItemIndex: number) => (
<Stack direction="row" spacing={2} key={`item-${groupItem.id}`}> <Stack direction="row" spacing={2} key={`item-${groupItem.id}`}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 2}} sx={{ display: 'flex', flex: 1 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 2 }} sx={{ display: 'flex', flex: 1 }}>
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}> <Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
<InputCreator <InputCreator
schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.key`, label: 'Choose field' })} schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.key`, label: 'Choose field' })}

View File

@@ -8,6 +8,7 @@ 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';
import ControlledAutocomplete from 'components/ControlledAutocomplete'; import ControlledAutocomplete from 'components/ControlledAutocomplete';
import DynamicField from 'components/DynamicField';
type InputCreatorProps = { type InputCreatorProps = {
onChange?: React.ChangeEventHandler; onChange?: React.ChangeEventHandler;
@@ -17,6 +18,7 @@ type InputCreatorProps = {
stepId?: string; stepId?: string;
disabled?: boolean; disabled?: boolean;
showOptionValue?: boolean; showOptionValue?: boolean;
shouldUnregister?: boolean;
}; };
type RawOption = { type RawOption = {
@@ -38,6 +40,7 @@ export default function InputCreator(
stepId, stepId,
disabled, disabled,
showOptionValue, showOptionValue,
shouldUnregister,
} = props; } = props;
const { const {
@@ -47,10 +50,7 @@ export default function InputCreator(
readOnly = false, readOnly = false,
value, value,
description, description,
clickToCopy,
variables,
type, type,
dependsOn,
} = schema; } = schema;
const { data, loading } = useDynamicData(stepId, schema); const { data, loading } = useDynamicData(stepId, schema);
@@ -60,14 +60,31 @@ export default function InputCreator(
} = useDynamicFields(stepId, schema); } = useDynamicFields(stepId, schema);
const computedName = namePrefix ? `${namePrefix}.${name}` : name; const computedName = namePrefix ? `${namePrefix}.${name}` : name;
if (type === 'dynamic') {
return (
<DynamicField
label={label}
description={description}
defaultValue={value}
name={computedName}
key={computedName}
required={required}
disabled={disabled}
fields={schema.fields}
shouldUnregister={shouldUnregister}
/>
);
}
if (type === 'dropdown') { if (type === 'dropdown') {
const preparedOptions = schema.options || optionGenerator(data); const preparedOptions = schema.options || optionGenerator(data);
return ( return (
<React.Fragment> <React.Fragment>
<ControlledAutocomplete <ControlledAutocomplete
key={computedName}
name={computedName} name={computedName}
dependsOn={dependsOn} dependsOn={schema.dependsOn}
fullWidth fullWidth
disablePortal disablePortal
disableClearable={required} disableClearable={required}
@@ -78,6 +95,7 @@ export default function InputCreator(
loading={loading} loading={loading}
disabled={disabled} disabled={disabled}
showOptionValue={showOptionValue} showOptionValue={showOptionValue}
shouldUnregister={shouldUnregister}
/> />
{(additionalFieldsLoading && !additionalFields?.length) && <div> {(additionalFieldsLoading && !additionalFields?.length) && <div>
@@ -92,6 +110,7 @@ export default function InputCreator(
stepId={stepId} stepId={stepId}
disabled={disabled} disabled={disabled}
showOptionValue={true} showOptionValue={true}
shouldUnregister={shouldUnregister}
/> />
))} ))}
</React.Fragment> </React.Fragment>
@@ -99,15 +118,17 @@ export default function InputCreator(
} }
if (type === 'string') { if (type === 'string') {
if (variables) { if (schema.variables) {
return ( return (
<React.Fragment> <React.Fragment>
<PowerInput <PowerInput
key={computedName}
label={label} label={label}
description={description} description={description}
name={computedName} name={computedName}
required={required} required={required}
disabled={disabled} disabled={disabled}
shouldUnregister={shouldUnregister}
/> />
{(additionalFieldsLoading && !additionalFields?.length) && <div> {(additionalFieldsLoading && !additionalFields?.length) && <div>
@@ -122,6 +143,7 @@ export default function InputCreator(
stepId={stepId} stepId={stepId}
disabled={disabled} disabled={disabled}
showOptionValue={true} showOptionValue={true}
shouldUnregister={shouldUnregister}
/> />
))} ))}
</React.Fragment> </React.Fragment>
@@ -131,6 +153,7 @@ export default function InputCreator(
return ( return (
<React.Fragment> <React.Fragment>
<TextField <TextField
key={computedName}
defaultValue={value} defaultValue={value}
required={required} required={required}
placeholder="" placeholder=""
@@ -141,7 +164,8 @@ export default function InputCreator(
label={label} label={label}
fullWidth fullWidth
helperText={description} helperText={description}
clickToCopy={clickToCopy} clickToCopy={schema.clickToCopy}
shouldUnregister={shouldUnregister}
/> />
{(additionalFieldsLoading && !additionalFields?.length) && <div> {(additionalFieldsLoading && !additionalFields?.length) && <div>
@@ -156,6 +180,7 @@ export default function InputCreator(
stepId={stepId} stepId={stepId}
disabled={disabled} disabled={disabled}
showOptionValue={true} showOptionValue={true}
shouldUnregister={shouldUnregister}
/> />
))} ))}
</React.Fragment> </React.Fragment>

View File

@@ -5,7 +5,7 @@ import Popper from '@mui/material/Popper';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import FormHelperText from '@mui/material/FormHelperText'; import FormHelperText from '@mui/material/FormHelperText';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { Editor, Transforms, Range, createEditor } from 'slate'; import { createEditor } from 'slate';
import { Slate, Editable, useSelected, useFocused } from 'slate-react'; import { Slate, Editable, useSelected, useFocused } from 'slate-react';
import { import {
@@ -17,7 +17,7 @@ import {
import Suggestions from './Suggestions'; import Suggestions from './Suggestions';
import { StepExecutionsContext } from 'contexts/StepExecutions'; import { StepExecutionsContext } from 'contexts/StepExecutions';
import { FakeInput, InputLabelWrapper } from './style'; import { FakeInput, InputLabelWrapper, ChildrenWrapper } from './style';
import { VariableElement } from './types'; import { VariableElement } from './types';
import { processStepWithExecutions } from './data'; import { processStepWithExecutions } from './data';
@@ -34,6 +34,7 @@ type PowerInputProps = {
docUrl?: string; docUrl?: string;
clickToCopy?: boolean; clickToCopy?: boolean;
disabled?: boolean; disabled?: boolean;
shouldUnregister?: boolean;
}; };
const PowerInput = (props: PowerInputProps) => { const PowerInput = (props: PowerInputProps) => {
@@ -46,6 +47,7 @@ const PowerInput = (props: PowerInputProps) => {
required, required,
description, description,
disabled, disabled,
shouldUnregister,
} = props; } = props;
const priorStepsWithExecutions = React.useContext(StepExecutionsContext); const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef<HTMLDivElement | null>(null); const editorRef = React.useRef<HTMLDivElement | null>(null);
@@ -81,7 +83,7 @@ const PowerInput = (props: PowerInputProps) => {
name={name} name={name}
control={control} control={control}
defaultValue={defaultValue} defaultValue={defaultValue}
shouldUnregister={true} shouldUnregister={shouldUnregister ?? true}
render={({ render={({
field: { field: {
value, value,
@@ -103,7 +105,7 @@ const PowerInput = (props: PowerInputProps) => {
}} }}
> >
{/* ref-able single child for ClickAwayListener */} {/* ref-able single child for ClickAwayListener */}
<div style={{ width: '100%' }} data-test="power-input"> <ChildrenWrapper style={{ width: '100%' }} data-test="power-input">
<FakeInput disabled={disabled}> <FakeInput disabled={disabled}>
<InputLabelWrapper> <InputLabelWrapper>
<InputLabel <InputLabel
@@ -140,7 +142,7 @@ const PowerInput = (props: PowerInputProps) => {
data={stepsWithVariables} data={stepsWithVariables}
onSuggestionClick={handleVariableSuggestionClick} onSuggestionClick={handleVariableSuggestionClick}
/> />
</div> </ChildrenWrapper>
</ClickAwayListener> </ClickAwayListener>
</Slate> </Slate>
)} )}

View File

@@ -1,5 +1,12 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
export const ChildrenWrapper = styled('div')`
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
`;
export const InputLabelWrapper = styled('div')` export const InputLabelWrapper = styled('div')`
position: absolute; position: absolute;
left: ${({ theme }) => theme.spacing(1.75)}; left: ${({ theme }) => theme.spacing(1.75)};
@@ -9,7 +16,7 @@ export const InputLabelWrapper = styled('div')`
export const FakeInput = styled('div', { export const FakeInput = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled', shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean }>` }) <{ disabled?: boolean }>`
border: 1px solid #eee; border: 1px solid #eee;
min-height: 52px; min-height: 52px;
width: 100%; width: 100%;

View File

@@ -110,6 +110,7 @@ export const GET_APPS = gql`
description description
variables variables
dependsOn dependsOn
value
options { options {
label label
value value
@@ -130,6 +131,36 @@ export const GET_APPS = gql`
value value
} }
} }
fields {
label
key
type
required
description
variables
value
dependsOn
options {
label
value
}
source {
type
name
arguments {
name
value
}
}
additionalFields {
type
name
arguments {
name
value
}
}
}
} }
} }
} }