Merge pull request #1002 from automatisch/dynamic-field-schema

feat(http-request): add headers support
This commit is contained in:
Ömer Faruk Aydın
2023-03-23 18:32:17 +03:00
committed by GitHub
12 changed files with 265 additions and 23 deletions

View File

@@ -2,6 +2,13 @@ import defineAction from '../../../../helpers/define-action';
type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type THeaderEntry = {
key: string;
value: string;
}
type THeaderEntries = THeaderEntry[];
export default defineAction({
name: 'Custom Request',
key: 'customRequest',
@@ -38,15 +45,47 @@ export default defineAction({
description: 'Place raw JSON data here.',
variables: true,
},
{
label: 'Headers',
key: 'headers',
type: 'dynamic' as const,
required: false,
description: 'Add or remove headers as needed',
value: [{
key: 'Content-Type',
value: 'application/json'
}],
fields: [
{
label: 'Key',
key: 'key',
type: 'string' as const,
required: true,
description: 'Header key',
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
description: 'Header value',
variables: true,
}
],
}
],
async run($) {
const method = $.step.parameters.method as TMethod;
const data = $.step.parameters.data as string;
const url = $.step.parameters.url as string;
const headers = $.step.parameters.headers as THeaderEntries;
const maxFileSize = 25 * 1024 * 1024; // 25MB
const metadataResponse = await $.http.head(url);
const headersObject = headers.reduce((result, entry) => ({ ...result, [entry.key]: entry.value }), {})
const metadataResponse = await $.http.head(url, { headers: headersObject });
if (Number(metadataResponse.headers['content-length']) > maxFileSize) {
throw new Error(
@@ -58,9 +97,7 @@ export default defineAction({
url,
method,
data,
headers: {
'Content-Type': 'application/json',
},
headers: headersObject,
});
let responseData = response.data;

View File

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

View File

@@ -9,7 +9,7 @@ const appInfoConverter = (rawAppData: IApp) => {
if (rawAppData.auth?.fields) {
rawAppData.auth.fields = rawAppData.auth.fields.map((field) => {
if (typeof field.value === 'string') {
if (field.type === 'string' && typeof field.value === 'string') {
return {
...field,
value: field.value.replace('{WEB_APP_URL}', appConfig.webAppUrl),

View File

@@ -47,6 +47,7 @@ export default function createHttpClient({
if (
// TODO: provide a `shouldRefreshToken` function in the app
(status === 401 || status === 403) &&
$.app.auth &&
$.app.auth.refreshToken &&
!$.app.auth.isRefreshTokenRequested
) {

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

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

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