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
additionalFields: SubstepArgumentAdditionalFields
dependsOn: [String]
fields: [SubstepArgument]
value: JSONObject
}
type SubstepArgumentOption {

View File

@@ -145,7 +145,18 @@ export interface IFieldText {
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 {
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> = {};
for (const arg of args) {
const { key, required, dependsOn } = arg;
const { key, required } = arg;
// base validation for the field if not exists
if (!substepArgumentValidations[key]) {
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 (required) {
substepArgumentValidations[key] = substepArgumentValidations[
@@ -79,8 +79,8 @@ function generateValidationSchema(substeps: ISubstep[]) {
}
// if the field depends on another field, add the dependsOn required validation
if (Array.isArray(dependsOn) && dependsOn.length > 0) {
for (const dependsOnKey of dependsOn) {
if (Array.isArray(arg.dependsOn) && arg.dependsOn.length > 0) {
for (const dependsOnKey of arg.dependsOn) {
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.

View File

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

View File

@@ -5,7 +5,7 @@ import Popper from '@mui/material/Popper';
import InputLabel from '@mui/material/InputLabel';
import FormHelperText from '@mui/material/FormHelperText';
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 {
@@ -17,7 +17,7 @@ import {
import Suggestions from './Suggestions';
import { StepExecutionsContext } from 'contexts/StepExecutions';
import { FakeInput, InputLabelWrapper } from './style';
import { FakeInput, InputLabelWrapper, ChildrenWrapper } from './style';
import { VariableElement } from './types';
import { processStepWithExecutions } from './data';
@@ -34,6 +34,7 @@ type PowerInputProps = {
docUrl?: string;
clickToCopy?: boolean;
disabled?: boolean;
shouldUnregister?: boolean;
};
const PowerInput = (props: PowerInputProps) => {
@@ -46,6 +47,7 @@ const PowerInput = (props: PowerInputProps) => {
required,
description,
disabled,
shouldUnregister,
} = props;
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef<HTMLDivElement | null>(null);
@@ -81,7 +83,7 @@ const PowerInput = (props: PowerInputProps) => {
name={name}
control={control}
defaultValue={defaultValue}
shouldUnregister={true}
shouldUnregister={shouldUnregister ?? true}
render={({
field: {
value,
@@ -103,7 +105,7 @@ const PowerInput = (props: PowerInputProps) => {
}}
>
{/* 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}>
<InputLabelWrapper>
<InputLabel
@@ -140,7 +142,7 @@ const PowerInput = (props: PowerInputProps) => {
data={stepsWithVariables}
onSuggestionClick={handleVariableSuggestionClick}
/>
</div>
</ChildrenWrapper>
</ClickAwayListener>
</Slate>
)}

View File

@@ -1,5 +1,12 @@
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')`
position: absolute;
left: ${({ theme }) => theme.spacing(1.75)};
@@ -9,7 +16,7 @@ export const InputLabelWrapper = styled('div')`
export const FakeInput = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled?: boolean }>`
}) <{ disabled?: boolean }>`
border: 1px solid #eee;
min-height: 52px;
width: 100%;

View File

@@ -110,6 +110,36 @@ export const GET_APPS = gql`
description
variables
dependsOn
value
options {
label
value
}
source {
type
name
arguments {
name
value
}
}
additionalFields {
type
name
arguments {
name
value
}
}
fields {
label
key
type
required
description
variables
value
dependsOn
options {
label
value
@@ -135,4 +165,5 @@ export const GET_APPS = gql`
}
}
}
}
`;