feat: introduce CustomAutocomplete with variables

This commit is contained in:
Ali BARIN
2023-05-25 13:40:27 +00:00
parent 42842e7aec
commit f2dc2f5530
47 changed files with 1441 additions and 468 deletions

View File

@@ -20,7 +20,7 @@ export default defineAction({
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
description: 'Language to translate the text to.', description: 'Language to translate the text to.',
variables: false, variables: true,
value: '', value: '',
options: [ options: [
{ label: 'Bulgarian', value: 'BG' }, { label: 'Bulgarian', value: 'BG' },

View File

@@ -13,7 +13,7 @@ export default defineAction({
required: true, required: true,
value: null, value: null,
description: 'Delay for unit, e.g. minutes, hours, days, weeks.', description: 'Delay for unit, e.g. minutes, hours, days, weeks.',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Minutes', label: 'Minutes',

View File

@@ -11,7 +11,7 @@ export default defineAction({
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
description: 'Pick a channel to send the message to.', description: 'Pick a channel to send the message to.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',

View File

@@ -11,7 +11,7 @@ export default defineAction({
key: 'repo', key: 'repo',
type: 'dropdown' as const, type: 'dropdown' as const,
required: false, required: false,
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',

View File

@@ -21,7 +21,7 @@ export default defineAction({
required: false, required: false,
description: description:
'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -40,7 +40,7 @@ export default defineAction({
required: true, required: true,
dependsOn: ['parameters.driveId'], dependsOn: ['parameters.driveId'],
description: 'The spreadsheets in your Google Drive.', description: 'The spreadsheets in your Google Drive.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -63,7 +63,7 @@ export default defineAction({
required: true, required: true,
dependsOn: ['parameters.spreadsheetId'], dependsOn: ['parameters.spreadsheetId'],
description: 'The worksheets in your selected spreadsheet.', description: 'The worksheets in your selected spreadsheet.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',

View File

@@ -84,7 +84,7 @@ export default defineAction({
type: 'string' as const, type: 'string' as const,
required: true, required: true,
description: 'Header key', description: 'Header key',
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',
@@ -132,7 +132,7 @@ export default defineAction({
throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']); throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']);
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
} catch {} } catch { }
const requestData: AxiosRequestConfig = { const requestData: AxiosRequestConfig = {
url, url,

View File

@@ -19,7 +19,7 @@ export default defineAction({
key: 'model', key: 'model',
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -35,7 +35,7 @@ export default defineAction({
label: 'Messages', label: 'Messages',
key: 'messages', key: 'messages',
type: 'dynamic' as const, type: 'dynamic' as const,
required: false, required: true,
description: 'Add or remove messages as needed', description: 'Add or remove messages as needed',
value: [{ role: 'system', body: '' }], value: [{ role: 'system', body: '' }],
fields: [ fields: [

View File

@@ -14,7 +14,7 @@ export default defineAction({
key: 'model', key: 'model',
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',

View File

@@ -18,14 +18,14 @@ export default defineAction({
type: 'string' as const, type: 'string' as const,
value: 'public', value: 'public',
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Table name', label: 'Table name',
key: 'table', key: 'table',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Where clause entries', label: 'Where clause entries',
@@ -38,14 +38,14 @@ export default defineAction({
key: 'columnName', key: 'columnName',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Operator', label: 'Operator',
key: 'operator', key: 'operator',
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
variables: false, variables: true,
options: whereClauseOperators options: whereClauseOperators
}, },
{ {
@@ -69,7 +69,7 @@ export default defineAction({
key: 'parameter', key: 'parameter',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',

View File

@@ -16,14 +16,14 @@ export default defineAction({
type: 'string' as const, type: 'string' as const,
value: 'public', value: 'public',
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Table name', label: 'Table name',
key: 'table', key: 'table',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Column - value entries', label: 'Column - value entries',
@@ -37,7 +37,7 @@ export default defineAction({
key: 'columnName', key: 'columnName',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',
@@ -52,7 +52,7 @@ export default defineAction({
label: 'Run-time parameters', label: 'Run-time parameters',
key: 'params', key: 'params',
type: 'dynamic' as const, type: 'dynamic' as const,
required: false, required: true,
description: 'Change run-time configuration parameters with SET command', description: 'Change run-time configuration parameters with SET command',
fields: [ fields: [
{ {
@@ -60,7 +60,7 @@ export default defineAction({
key: 'parameter', key: 'parameter',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',

View File

@@ -27,7 +27,7 @@ export default defineAction({
key: 'parameter', key: 'parameter',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',

View File

@@ -19,14 +19,14 @@ export default defineAction({
type: 'string' as const, type: 'string' as const,
value: 'public', value: 'public',
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Table name', label: 'Table name',
key: 'table', key: 'table',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Where clause entries', label: 'Where clause entries',
@@ -39,14 +39,14 @@ export default defineAction({
key: 'columnName', key: 'columnName',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Operator', label: 'Operator',
key: 'operator', key: 'operator',
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
variables: false, variables: true,
options: whereClauseOperators options: whereClauseOperators
}, },
{ {
@@ -70,7 +70,7 @@ export default defineAction({
key: 'columnName', key: 'columnName',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',
@@ -93,7 +93,7 @@ export default defineAction({
key: 'parameter', key: 'parameter',
type: 'string' as const, type: 'string' as const,
required: true, required: true,
variables: false, variables: true,
}, },
{ {
label: 'Value', label: 'Value',

View File

@@ -10,6 +10,7 @@ export default defineAction({
key: 'object', key: 'object',
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
variables: true,
description: 'Pick which type of object you want to search for.', description: 'Pick which type of object you want to search for.',
source: { source: {
type: 'query', type: 'query',
@@ -28,7 +29,7 @@ export default defineAction({
type: 'dropdown' as const, type: 'dropdown' as const,
description: 'Pick which field to search by', description: 'Pick which field to search by',
required: true, required: true,
variables: false, variables: true,
dependsOn: ['parameters.object'], dependsOn: ['parameters.object'],
source: { source: {
type: 'query', type: 'query',

View File

@@ -23,7 +23,7 @@ export default defineAction({
'Sort messages by their match strength or by their date. Default is score.', 'Sort messages by their match strength or by their date. Default is score.',
required: true, required: true,
value: 'score', value: 'score',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Match strength', label: 'Match strength',
@@ -43,7 +43,7 @@ export default defineAction({
'Sort matching messages in ascending or descending order. Default is descending.', 'Sort matching messages in ascending or descending order. Default is descending.',
required: true, required: true,
value: 'desc', value: 'desc',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Descending (newest or best match first)', label: 'Descending (newest or best match first)',

View File

@@ -12,7 +12,7 @@ export default defineAction({
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
description: 'Pick a user to send the message to.', description: 'Pick a user to send the message to.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -40,7 +40,7 @@ export default defineAction({
value: false, value: false,
description: description:
'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Yes', label: 'Yes',

View File

@@ -12,7 +12,7 @@ export default defineAction({
type: 'dropdown' as const, type: 'dropdown' as const,
required: true, required: true,
description: 'Pick a channel to send the message to.', description: 'Pick a channel to send the message to.',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -40,7 +40,7 @@ export default defineAction({
value: false, value: false,
description: description:
'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Yes', label: 'Yes',

View File

@@ -29,7 +29,7 @@ export default defineAction({
required: false, required: false,
value: false, value: false,
description: 'Sends the message silently. Users will receive a notification with no sound.', description: 'Sends the message silently. Users will receive a notification with no sound.',
variables: false, variables: true,
options: [ options: [
{ {
label: 'Yes', label: 'Yes',

View File

@@ -10,7 +10,7 @@ export default defineAction({
key: 'projectId', key: 'projectId',
type: 'dropdown' as const, type: 'dropdown' as const,
required: false, required: false,
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',
@@ -27,7 +27,7 @@ export default defineAction({
key: 'sectionId', key: 'sectionId',
type: 'dropdown' as const, type: 'dropdown' as const,
required: false, required: false,
variables: false, variables: true,
dependsOn: ['parameters.projectId'], dependsOn: ['parameters.projectId'],
source: { source: {
type: 'query', type: 'query',

View File

@@ -13,7 +13,7 @@ export default defineAction({
required: true, required: true,
description: description:
'The number to send the SMS from. Include country code. Example: 15551234567', 'The number to send the SMS from. Include country code. Example: 15551234567',
variables: false, variables: true,
source: { source: {
type: 'query', type: 'query',
name: 'getDynamicData', name: 'getDynamicData',

View File

@@ -1,7 +1,9 @@
import { IDynamicData, IJSONObject } from '@automatisch/types'; import { IDynamicData, IJSONObject } from '@automatisch/types';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app'; import App from '../../models/app';
import ExecutionStep from '../../models/execution-step';
import globalVariable from '../../helpers/global-variable'; import globalVariable from '../../helpers/global-variable';
import computeParameters from '../../helpers/compute-parameters';
type Params = { type Params = {
stepId: string; stepId: string;
@@ -28,18 +30,29 @@ const getDynamicData = async (
if (!connection || !step.appKey) return null; if (!connection || !step.appKey) return null;
const flow = step.flow;
const app = await App.findOneByKey(step.appKey); const app = await App.findOneByKey(step.appKey);
const $ = await globalVariable({ connection, app, flow: step.flow, step }); const $ = await globalVariable({ connection, app, flow, step });
const command = app.dynamicData.find( const command = app.dynamicData.find(
(data: IDynamicData) => data.key === params.key (data: IDynamicData) => data.key === params.key
); );
// apply run-time parameters that're not persisted yet
for (const parameterKey in params.parameters) { for (const parameterKey in params.parameters) {
const parameterValue = params.parameters[parameterKey]; const parameterValue = params.parameters[parameterKey];
$.step.parameters[parameterKey] = parameterValue; $.step.parameters[parameterKey] = parameterValue;
} }
const priorExecutionSteps = await ExecutionStep.query().where({
execution_id: (await flow.$relatedQuery('lastExecution')).id,
});
// compute variables in parameters
const computedParameters = computeParameters($.step.parameters, priorExecutionSteps);
$.step.parameters = computedParameters;
const fetchedData = await command.run($); const fetchedData = await command.run($);
if (fetchedData.error) { if (fetchedData.error) {

View File

@@ -21,6 +21,7 @@ class Flow extends Base {
published_at: string; published_at: string;
remoteWebhookId: string; remoteWebhookId: string;
executions?: Execution[]; executions?: Execution[];
lastExecution?: Execution;
user?: User; user?: User;
static tableName = 'flows'; static tableName = 'flows';
@@ -58,6 +59,17 @@ class Flow extends Base {
to: 'executions.flow_id', to: 'executions.flow_id',
}, },
}, },
lastExecution: {
relation: Base.HasOneRelation,
modelClass: Execution,
join: {
from: 'flows.id',
to: 'executions.flow_id',
},
filter(builder: ExtendedQueryBuilder<Execution>) {
builder.orderBy('created_at', 'desc').limit(1).first();
},
},
user: { user: {
relation: Base.HasOneRelation, relation: Base.HasOneRelation,
modelClass: User, modelClass: User,
@@ -89,10 +101,7 @@ class Flow extends Base {
} }
async lastInternalId() { async lastInternalId() {
const lastExecution = await this.$relatedQuery('executions') const lastExecution = await this.$relatedQuery('lastExecution');
.orderBy('created_at', 'desc')
.limit(1)
.first();
return lastExecution ? (lastExecution as Execution).internalId : null; return lastExecution ? (lastExecution as Execution).internalId : null;
} }

View File

@@ -21,6 +21,7 @@
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"@types/react-window": "^1.8.5",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3", "compare-versions": "^4.1.3",
@@ -35,9 +36,10 @@
"react-json-tree": "^0.16.2", "react-json-tree": "^0.16.2",
"react-router-dom": "^6.0.2", "react-router-dom": "^6.0.2",
"react-scripts": "5.0.0", "react-scripts": "5.0.0",
"slate": "^0.72.8", "react-window": "^1.8.9",
"slate-history": "^0.66.0", "slate": "^0.94.1",
"slate-react": "^0.72.9", "slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"typescript": "^4.6.3", "typescript": "^4.6.3",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^1.0.1", "web-vitals": "^1.0.1",

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import { Controller as RHFController, useFormContext } from 'react-hook-form';
interface ControllerProps {
defaultValue?: string;
name: string;
required?: boolean;
shouldUnregister?: boolean;
children: React.ReactElement;
}
function Controller(
props: ControllerProps
): React.ReactElement {
const { control } = useFormContext();
const {
defaultValue = '',
name,
required,
shouldUnregister,
children,
} = props;
return (
<RHFController
rules={{ required }}
name={name}
control={control}
defaultValue={defaultValue}
shouldUnregister={shouldUnregister ?? false}
render={({
field,
}) => React.cloneElement(children, { field })}
/>
);
}
export default Controller;

View File

@@ -0,0 +1,96 @@
import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import Tab from '@mui/material/Tab';
import * as React from 'react';
import type { IFieldDropdownOption } from '@automatisch/types';
import Suggestions from 'components/PowerInput/Suggestions';
import TabPanel from 'components/TabPanel';
import Options from './Options';
import { Tabs } from './style';
interface CustomOptionsProps {
open: boolean;
anchorEl: any;
data: any;
options: readonly IFieldDropdownOption[];
onSuggestionClick: any;
onOptionClick: (event: React.MouseEvent, option: any) => void;
onTabChange: (tabIndex: 0 | 1) => void;
label?: string;
initialTabIndex?: 0 | 1;
};
const CustomOptions = (props: CustomOptionsProps) => {
const {
open,
anchorEl,
data,
options = [],
onSuggestionClick,
onOptionClick,
onTabChange,
label,
initialTabIndex,
} = props;
const [activeTabIndex, setActiveTabIndex] = React.useState<number | undefined>(undefined);
React.useEffect(function applyInitialActiveTabIndex() {
setActiveTabIndex((currentActiveTabIndex) => {
if (currentActiveTabIndex === undefined) {
return initialTabIndex;
}
return currentActiveTabIndex;
});
}, [initialTabIndex]);
return (
<Popper
open={open}
anchorEl={anchorEl}
style={{ width: anchorEl?.clientWidth, zIndex: 1 }}
modifiers={[
{
name: 'flip',
enabled: false,
options: {
altBoundary: false,
},
},
]}
>
<Paper elevation={5} sx={{ width: '100%' }}>
<Tabs
sx={{ mb: 2 }}
value={activeTabIndex ?? 0}
onChange={(event, tabIndex) => {
onTabChange(tabIndex);
setActiveTabIndex(tabIndex);
}}
>
<Tab label={label} />
<Tab label="Custom" />
</Tabs>
<TabPanel value={activeTabIndex ?? 0} index={0}>
<Options
data={options}
onOptionClick={onOptionClick}
/>
</TabPanel>
<TabPanel value={activeTabIndex ?? 0} index={1}>
<Suggestions
data={data}
onSuggestionClick={onSuggestionClick}
/>
</TabPanel>
</Paper>
</Popper>
);
};
export default CustomOptions;

View File

@@ -0,0 +1,126 @@
import type { IFieldDropdownOption } from '@automatisch/types';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import throttle from 'lodash/throttle';
import * as React from 'react';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { Typography } from '@mui/material';
import SearchInput from 'components/SearchInput';
import useFormatMessage from 'hooks/useFormatMessage';
import { SearchInputWrapper } from './style';
interface OptionsProps {
data: readonly IFieldDropdownOption[];
onOptionClick: (event: React.MouseEvent, option: any) => void;
};
const SHORT_LIST_LENGTH = 4;
const LIST_ITEM_HEIGHT = 64;
const computeListHeight = (currentLength: number) => {
const numberOfRenderedItems = Math.min(SHORT_LIST_LENGTH, currentLength);
return LIST_ITEM_HEIGHT * numberOfRenderedItems;
}
const renderItemFactory = ({ onOptionClick }: Pick<OptionsProps, 'onOptionClick'>) => (props: ListChildComponentProps) => {
const { index, style, data } = props;
const suboption = data[index];
return (
<ListItemButton
sx={{ pl: 4 }}
divider
onClick={(event) => onOptionClick(event, suboption)}
data-test="power-input-suggestion-item"
key={index}
style={style}
>
<ListItemText
primary={suboption.label}
primaryTypographyProps={{
variant: 'subtitle1',
title: 'Property name',
sx: { fontWeight: 700 },
}}
secondary={suboption.value}
secondaryTypographyProps={{
variant: 'subtitle2',
title: 'Sample value',
noWrap: true,
}}
/>
</ListItemButton>
);
};
const Options = (props: OptionsProps) => {
const formatMessage = useFormatMessage();
const {
data,
onOptionClick
} = props;
const [filteredData, setFilteredData] = React.useState<readonly IFieldDropdownOption[]>(
data
);
React.useEffect(function syncOptions() {
setFilteredData((filteredData) => {
if (filteredData.length === 0 && filteredData.length !== data.length) {
return data;
}
return filteredData;
})
}, [data]);
const renderItem = React.useMemo(() => renderItemFactory({
onOptionClick
}), [onOptionClick]);
const onSearchChange = React.useMemo(
() =>
throttle((event: React.ChangeEvent) => {
const search = (event.target as HTMLInputElement).value.toLowerCase();
if (!search) {
setFilteredData(data);
return;
}
const newFilteredData = data.filter(option => `${option.label}\n${option.value}`.toLowerCase().includes(search.toLowerCase()));
setFilteredData(newFilteredData);
}, 400),
[data]
);
return (
<>
<SearchInputWrapper>
<SearchInput onChange={onSearchChange} />
</SearchInputWrapper>
<FixedSizeList
height={computeListHeight(filteredData.length)}
width="100%"
itemSize={LIST_ITEM_HEIGHT}
itemCount={filteredData.length}
overscanCount={2}
itemData={filteredData}
>
{renderItem}
</FixedSizeList>
{filteredData.length === 0 && (
<Typography sx={{ p: (theme) => theme.spacing(0, 0, 2, 2) }}>
{formatMessage('customAutocomplete.noOptions')}
</Typography>
)}
</>
);
};
export default Options;

View File

@@ -0,0 +1,280 @@
import * as React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import FormHelperText from '@mui/material/FormHelperText';
import { AutocompleteProps } from '@mui/material/Autocomplete';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import type { IFieldDropdownOption } from '@automatisch/types';
import { FakeDropdownButton } from './style';
import ClickAwayListener from '@mui/base/ClickAwayListener';
import InputLabel from '@mui/material/InputLabel';
import { createEditor } from 'slate';
import { Editable, ReactEditor,} from 'slate-react';
import Slate from 'components/Slate';
import Element from 'components/Slate/Element';
import {
serialize,
deserialize,
insertVariable,
customizeEditor,
resetEditor,
overrideEditorValue,
focusEditor,
} from 'components/Slate/utils';
import { FakeInput, InputLabelWrapper, ChildrenWrapper, } from 'components/PowerInput/style';
import { VariableElement } from 'components/Slate/types';
import CustomOptions from './CustomOptions';
import { processStepWithExecutions } from 'components/PowerInput/data';
import { StepExecutionsContext } from 'contexts/StepExecutions';
interface ControlledCustomAutocompleteProps
extends AutocompleteProps<IFieldDropdownOption, boolean, boolean, boolean> {
showOptionValue?: boolean;
dependsOn?: string[];
defaultValue?: string;
name: string;
label?: string;
type?: string;
required?: boolean;
readOnly?: boolean;
description?: string;
docUrl?: string;
clickToCopy?: boolean;
disabled?: boolean;
shouldUnregister?: boolean;
}
function ControlledCustomAutocomplete(
props: ControlledCustomAutocompleteProps
): React.ReactElement {
const {
defaultValue = '',
name,
label,
required,
options = [],
dependsOn = [],
description,
loading,
disabled,
shouldUnregister,
} = props;
const { control, watch } = useFormContext();
const { field, fieldState } = useController({
control,
name,
defaultValue,
rules: { required },
shouldUnregister,
});
const {
value,
onChange: controllerOnChange,
onBlur: controllerOnBlur,
} = field;
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const [isInitialValueSet, setInitialValue] = React.useState(false);
const [isSingleChoice, setSingleChoice] = React.useState<boolean | undefined>(undefined);
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef<HTMLDivElement | null>(null);
const renderElement = React.useCallback(
(props) => <Element {...props} disabled={disabled} />,
[disabled]
);
const [editor] = React.useState(() => customizeEditor(createEditor()));
const [showVariableSuggestions, setShowVariableSuggestions] =
React.useState(false);
let dependsOnValues: unknown[] = [];
if (dependsOn?.length) {
dependsOnValues = watch(dependsOn);
}
React.useEffect(() => {
const ref = ReactEditor.toDOMNode(editor, editor);
resizeObserver.observe(ref);
return () => resizeObserver.unobserve(ref);
}, []);
const promoteValue = () => {
const serializedValue = serialize(editor.children);
controllerOnChange(serializedValue);
}
const resizeObserver = React.useMemo(function syncCustomOptionsPosition() {
return new ResizeObserver(() => {
forceUpdate();
})
}, []);
React.useEffect(() => {
const hasDependencies = dependsOnValues.length;
if (hasDependencies) {
// Reset the field when a dependent has been updated
resetEditor(editor);
}
}, dependsOnValues);
React.useEffect(function updateInitialValue() {
const hasOptions = options.length;
const isOptionsLoaded = loading === false;
if (!isInitialValueSet && hasOptions && isOptionsLoaded) {
setInitialValue(true);
const option: IFieldDropdownOption | undefined = options.find((option) => option.value === value);
if (option) {
overrideEditorValue(editor, { option, focus: false });
setSingleChoice(true);
} else if (value) {
setSingleChoice(false);
}
}
}, [isInitialValueSet, options, loading]);
const hideSuggestionsOnShift = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.code === 'Tab') {
setShowVariableSuggestions(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
hideSuggestionsOnShift(event);
if (event.code === 'Tab') {
promoteValue();
}
if (isSingleChoice && event.code !== 'Tab') {
event.preventDefault();
}
};
const stepsWithVariables = React.useMemo(() => {
return processStepWithExecutions(priorStepsWithExecutions);
}, [priorStepsWithExecutions]);
const handleVariableSuggestionClick = React.useCallback(
(variable: Pick<VariableElement, 'name' | 'value'>) => {
insertVariable(editor, variable, stepsWithVariables);
},
[stepsWithVariables]
);
const handleOptionClick = React.useCallback(
(event: React.MouseEvent, option: IFieldDropdownOption) => {
event.stopPropagation();
overrideEditorValue(editor, { option, focus: false });
setShowVariableSuggestions(false);
promoteValue();
},
[stepsWithVariables]
);
const reset = (tabIndex: 0 | 1) => {
const isOptions = tabIndex === 0;
setSingleChoice(isOptions);
resetEditor(editor, { focus: true });
}
return (
<Slate
editor={editor}
value={deserialize(value, options, stepsWithVariables)}
>
<ClickAwayListener
mouseEvent="onMouseDown"
onClickAway={() => {
promoteValue();
setShowVariableSuggestions(false);
}}
>
{/* ref-able single child for ClickAwayListener */}
<ChildrenWrapper style={{ width: '100%' }} data-test="power-input">
<FakeInput
disabled={disabled}
tabIndex={-1}
onClick={() => {
focusEditor(editor);
}}
>
<InputLabelWrapper>
<InputLabel
shrink={true}
disabled={disabled}
variant="outlined"
sx={{ bgcolor: 'white', display: 'inline-block', px: 0.75 }}
>
{label}
</InputLabel>
</InputLabelWrapper>
<Editable
readOnly={disabled}
style={{ width: '100%' }}
renderElement={renderElement}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowVariableSuggestions(true);
}}
onBlur={() => {
controllerOnBlur();
}}
/>
<FakeDropdownButton
disabled={disabled}
edge="end"
size="small"
tabIndex={-1}
>
<ArrowDropDownIcon />
</FakeDropdownButton>
</FakeInput>
{/* ghost placer for the variables popover */}
<div
ref={editorRef}
style={{
position: 'absolute',
right: 16,
left: 16
}}
/>
<CustomOptions
label={label}
open={showVariableSuggestions}
initialTabIndex={isSingleChoice === undefined ? undefined : (isSingleChoice ? 0 : 1)}
anchorEl={editorRef.current}
data={stepsWithVariables}
options={options}
onSuggestionClick={handleVariableSuggestionClick}
onOptionClick={handleOptionClick}
onTabChange={reset}
/>
<FormHelperText
variant="outlined"
error={Boolean(fieldState.isTouched && fieldState.error)}
>
{fieldState.isTouched
? fieldState.error?.message || description
: description}
</FormHelperText>
</ChildrenWrapper>
</ClickAwayListener>
</Slate>
);
}
export default ControlledCustomAutocomplete;

View File

@@ -0,0 +1,18 @@
import { styled } from '@mui/material/styles';
import MuiIconButton from '@mui/material/IconButton';
import MuiTabs from '@mui/material/Tabs';
export const FakeDropdownButton = styled(MuiIconButton)`
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
top: 50%;
transform: translateY(-50%);
`;
export const Tabs = styled(MuiTabs)`
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
`;
export const SearchInputWrapper = styled('div')`
padding: ${({ theme }) => theme.spacing(0, 2, 2, 2)};
`;

View File

@@ -41,6 +41,7 @@ import {
Header, Header,
Wrapper, Wrapper,
} from './style'; } from './style';
import isEmpty from 'helpers/isEmpty';
type FlowStepProps = { type FlowStepProps = {
collapsed?: boolean; collapsed?: boolean;
@@ -75,7 +76,13 @@ function generateValidationSchema(substeps: ISubstep[]) {
if (required) { if (required) {
substepArgumentValidations[key] = substepArgumentValidations[ substepArgumentValidations[key] = substepArgumentValidations[
key key
].required(`${key} is required.`); ]
.required(`${key} is required.`)
.test(
'empty-check',
`${key} must be not empty`,
(value: any) => !isEmpty(value),
);
} }
// if the field depends on another field, add the dependsOn required validation // if the field depends on another field, add the dependsOn required validation

View File

@@ -4,7 +4,7 @@ import Collapse from '@mui/material/Collapse';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import type { IField, IStep, ISubstep } from '@automatisch/types'; import type { IStep, ISubstep } from '@automatisch/types';
import { EditorContext } from 'contexts/Editor'; import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
@@ -21,25 +21,6 @@ type FlowSubstepProps = {
step: IStep; step: IStep;
}; };
const validateSubstep = (substep: ISubstep, step: IStep) => {
if (!substep) return true;
const args: IField[] = substep.arguments || [];
return args.every((arg) => {
if (arg.required === false) {
return true;
}
const argValue = step.parameters?.[arg.key];
// `false` is an exceptional valid value
if (argValue === false) return true;
return argValue !== undefined && argValue !== null;
});
};
function FlowSubstep(props: FlowSubstepProps): React.ReactElement { function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
const { const {
substep, substep,
@@ -54,19 +35,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
const formContext = useFormContext(); const formContext = useFormContext();
const [validationStatus, setValidationStatus] = React.useState< const validationStatus = formContext.formState.isValid;
boolean | null
>(validateSubstep(substep, formContext.getValues() as IStep));
React.useEffect(() => {
function validate(step: unknown) {
const validationResult = validateSubstep(substep, step as IStep);
setValidationStatus(validationResult);
}
const subscription = formContext.watch(validate);
return () => subscription.unsubscribe();
}, [substep, formContext.watch]);
const onToggle = expanded ? onCollapse : onExpand; const onToggle = expanded ? onCollapse : onExpand;

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 ControlledCustomAutocomplete from 'components/ControlledCustomAutocomplete';
import DynamicField from 'components/DynamicField'; import DynamicField from 'components/DynamicField';
type InputCreatorProps = { type InputCreatorProps = {
@@ -81,6 +82,7 @@ export default function InputCreator(
return ( return (
<React.Fragment> <React.Fragment>
{!schema.variables && (
<ControlledAutocomplete <ControlledAutocomplete
key={computedName} key={computedName}
name={computedName} name={computedName}
@@ -97,6 +99,27 @@ export default function InputCreator(
showOptionValue={showOptionValue} showOptionValue={showOptionValue}
shouldUnregister={shouldUnregister} shouldUnregister={shouldUnregister}
/> />
)}
{schema.variables && (
<ControlledCustomAutocomplete
key={computedName}
name={computedName}
dependsOn={schema.dependsOn}
label={label}
fullWidth
disablePortal
disableClearable={required}
options={preparedOptions}
renderInput={(params) => <MuiTextField {...params} label={label} />}
defaultValue={value as string}
description={description}
loading={loading}
disabled={disabled}
showOptionValue={showOptionValue}
shouldUnregister={shouldUnregister}
/>
)}
{(additionalFieldsLoading && !additionalFields?.length) && <div> {(additionalFieldsLoading && !additionalFields?.length) && <div>
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />

View File

@@ -0,0 +1,61 @@
import Paper from '@mui/material/Paper';
import MuiPopper from '@mui/material/Popper';
import Tab from '@mui/material/Tab';
import * as React from 'react';
import Suggestions from 'components/PowerInput/Suggestions';
import TabPanel from 'components/TabPanel';
import { Tabs } from './style';
interface PopperProps {
open: boolean;
anchorEl: any;
data: any;
onSuggestionClick: any;
};
const Popper = (props: PopperProps) => {
const {
open,
anchorEl,
data,
onSuggestionClick,
} = props;
return (
<MuiPopper
open={open}
anchorEl={anchorEl}
style={{ width: anchorEl?.clientWidth, zIndex: 1 }}
modifiers={[
{
name: 'flip',
enabled: false,
options: {
altBoundary: false,
},
},
]}
>
<Paper elevation={5} sx={{ width: '100%' }}>
<Tabs
sx={{ mb: 2 }}
value={0}
>
<Tab label="Insert data..." />
</Tabs>
<TabPanel value={0} index={0}>
<Suggestions
data={data}
onSuggestionClick={onSuggestionClick}
/>
</TabPanel>
</Paper>
</MuiPopper>
);
};
export default Popper;

View File

@@ -1,35 +1,99 @@
import * as React from 'react'; import type { IStep } from '@automatisch/types';
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import MuiListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import Collapse from '@mui/material/Collapse';
import Typography from '@mui/material/Typography';
import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
import type { IStep } from '@automatisch/types'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import throttle from 'lodash/throttle';
import * as React from 'react';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
const ListItemText = styled(MuiListItemText)``; import SearchInput from 'components/SearchInput';
import useFormatMessage from 'hooks/useFormatMessage';
type SuggestionsProps = { type SuggestionsProps = {
data: any[]; data: {
id: string;
name: string;
output: Record<string, unknown>[]
}[];
onSuggestionClick: (variable: any) => void; onSuggestionClick: (variable: any) => void;
}; };
const SHORT_LIST_LENGTH = 4; const SHORT_LIST_LENGTH = 4;
const LIST_HEIGHT = 256; const LIST_ITEM_HEIGHT = 64;
const computeListHeight = (currentLength: number) => {
const numberOfRenderedItems = Math.min(SHORT_LIST_LENGTH, currentLength);
return LIST_ITEM_HEIGHT * numberOfRenderedItems;
}
const getPartialArray = (array: any[], length = array.length) => { const getPartialArray = (array: any[], length = array.length) => {
return array.slice(0, length); return array.slice(0, length);
}; };
const renderItemFactory = ({ onSuggestionClick }: Pick<SuggestionsProps, 'onSuggestionClick'>) => (props: ListChildComponentProps) => {
const { index, style, data } = props;
const suboption = data[index];
return (
<ListItemButton
sx={{ pl: 4 }}
divider
onClick={() => onSuggestionClick(suboption)}
data-test="power-input-suggestion-item"
key={index}
style={style}
>
<ListItemText
primary={suboption.label}
primaryTypographyProps={{
variant: 'subtitle1',
title: 'Property name',
sx: { fontWeight: 700 },
}}
secondary={suboption.sampleValue || ''}
secondaryTypographyProps={{
variant: 'subtitle2',
title: 'Sample value',
noWrap: true,
}}
/>
</ListItemButton>
);
}
const Suggestions = (props: SuggestionsProps) => { const Suggestions = (props: SuggestionsProps) => {
const { data, onSuggestionClick = () => null } = props; const formatMessage = useFormatMessage();
const {
data,
onSuggestionClick = () => null
} = props;
const [current, setCurrent] = React.useState<number | null>(0); const [current, setCurrent] = React.useState<number | null>(0);
const [listLength, setListLength] = React.useState<number>(SHORT_LIST_LENGTH); const [listLength, setListLength] = React.useState<number>(SHORT_LIST_LENGTH);
const [filteredData, setFilteredData] = React.useState<any[]>(
data
);
React.useEffect(function syncOptions() {
setFilteredData((filteredData) => {
if (filteredData.length === 0 && filteredData.length !== data.length) {
return data;
}
return filteredData;
})
}, [data]);
const renderItem = React.useMemo(() => renderItemFactory({
onSuggestionClick
}), [onSuggestionClick]);
const expandList = () => { const expandList = () => {
setListLength(Infinity); setListLength(Infinity);
@@ -43,14 +107,45 @@ const Suggestions = (props: SuggestionsProps) => {
setListLength(SHORT_LIST_LENGTH); setListLength(SHORT_LIST_LENGTH);
}, [current]); }, [current]);
const onSearchChange = React.useMemo(
() =>
throttle((event: React.ChangeEvent) => {
const search = (event.target as HTMLInputElement).value.toLowerCase();
if (!search) {
setFilteredData(data);
return;
}
const newFilteredData = data
.map((stepWithOutput) => {
return {
id: stepWithOutput.id,
name: stepWithOutput.name,
output: stepWithOutput.output
.filter(option => `${option.label}\n${option.sampleValue}`
.toLowerCase()
.includes(search.toLowerCase())
)
}
})
.filter((stepWithOutput) => stepWithOutput.output.length);
setFilteredData(newFilteredData);
}, 400),
[data]
);
return ( return (
<Paper elevation={5} sx={{ width: '100%' }}> <Paper elevation={0} sx={{ width: '100%' }}>
<Typography variant="subtitle2" sx={{ p: 2 }}> <Box px={2} pb={2}>
Variables <SearchInput onChange={onSearchChange} />
</Typography> </Box>
{filteredData.length > 0 && (
<List disablePadding> <List disablePadding>
{data.map((option: IStep, index: number) => ( {filteredData.map((option: IStep, index: number) => (
<> <React.Fragment key={`${index}-${option.name}`}>
<ListItemButton <ListItemButton
divider divider
onClick={() => onClick={() =>
@@ -66,40 +161,18 @@ const Suggestions = (props: SuggestionsProps) => {
(current === index ? <ExpandLess /> : <ExpandMore />)} (current === index ? <ExpandLess /> : <ExpandMore />)}
</ListItemButton> </ListItemButton>
<Collapse in={current === index} timeout="auto" unmountOnExit> <Collapse in={current === index || filteredData.length === 1} timeout="auto" unmountOnExit>
<List <FixedSizeList
component="div" height={computeListHeight(getPartialArray((option.output as any) || [], listLength).length)}
disablePadding width="100%"
sx={{ maxHeight: LIST_HEIGHT, overflowY: 'auto' }} itemSize={LIST_ITEM_HEIGHT}
itemCount={getPartialArray((option.output as any) || [], listLength).length}
overscanCount={2}
itemData={getPartialArray((option.output as any) || [], listLength)}
data-test="power-input-suggestion-group" data-test="power-input-suggestion-group"
> >
{getPartialArray((option.output as any) || [], listLength).map( {renderItem}
(suboption: any, index: number) => ( </FixedSizeList>
<ListItemButton
sx={{ pl: 4 }}
divider
onClick={() => onSuggestionClick(suboption)}
data-test="power-input-suggestion-item"
key={`suggestion-${suboption.name}`}
>
<ListItemText
primary={suboption.name}
primaryTypographyProps={{
variant: 'subtitle1',
title: 'Property name',
sx: { fontWeight: 700 },
}}
secondary={suboption.value || ''}
secondaryTypographyProps={{
variant: 'subtitle2',
title: 'Sample value',
noWrap: true,
}}
/>
</ListItemButton>
)
)}
</List>
{(option.output?.length || 0) > listLength && ( {(option.output?.length || 0) > listLength && (
<Button fullWidth onClick={expandList}> <Button fullWidth onClick={expandList}>
@@ -113,9 +186,16 @@ const Suggestions = (props: SuggestionsProps) => {
</Button> </Button>
)} )}
</Collapse> </Collapse>
</> </React.Fragment>
))} ))}
</List> </List>
)}
{filteredData.length === 0 && (
<Typography sx={{ p: (theme) => theme.spacing(0, 0, 2, 2) }}>
{formatMessage('powerInputSuggestions.noOptions')}
</Typography>
)}
</Paper> </Paper>
); );
}; };

View File

@@ -3,38 +3,63 @@ import type { IStep } from '@automatisch/types';
const joinBy = (delimiter = '.', ...args: string[]) => const joinBy = (delimiter = '.', ...args: string[]) =>
args.filter(Boolean).join(delimiter); args.filter(Boolean).join(delimiter);
const process = (data: any, parentKey?: any, index?: number): any[] => { type TProcessPayload = {
data: any;
parentKey: string;
index?: number;
parentLabel?: string;
};
const process = ({ data, parentKey, index, parentLabel = '' }: TProcessPayload): any[] => {
if (typeof data !== 'object') { if (typeof data !== 'object') {
return [ return [
{ {
name: `${parentKey}.${index}`, label: `${parentLabel}.${index}`,
value: data, value: `${parentKey}.${index}`,
sampleValue: data,
}, },
]; ];
} }
const entries = Object.entries(data); const entries = Object.entries(data);
return entries.flatMap(([name, value]) => { return entries.flatMap(([name, sampleValue]) => {
const fullName = joinBy( const label = joinBy(
'.',
parentLabel,
(index as number)?.toString(),
name
);
const value = joinBy(
'.', '.',
parentKey, parentKey,
(index as number)?.toString(), (index as number)?.toString(),
name name
); );
if (Array.isArray(value)) { if (Array.isArray(sampleValue)) {
return value.flatMap((item, index) => process(item, fullName, index)); return sampleValue.flatMap((item, index) => process({
data: item,
parentKey: value,
index,
parentLabel: label
}));
} }
if (typeof value === 'object' && value !== null) { if (typeof sampleValue === 'object' && sampleValue !== null) {
return process(value, fullName); return process({
data: sampleValue,
parentKey: value,
parentLabel: label,
});
} }
return [ return [
{ {
name: fullName, label,
value, value,
sampleValue,
}, },
]; ];
}); });
@@ -52,12 +77,11 @@ export const processStepWithExecutions = (steps: IStep[]): any[] => {
.map((step: IStep, index: number) => ({ .map((step: IStep, index: number) => ({
id: step.id, id: step.id,
// TODO: replace with step.name once introduced // TODO: replace with step.name once introduced
name: `${index + 1}. ${ name: `${index + 1}. ${(step.appKey || '').charAt(0)?.toUpperCase() + step.appKey?.slice(1)
(step.appKey || '').charAt(0)?.toUpperCase() + step.appKey?.slice(1)
}`, }`,
output: process( output: process({
step.executionSteps?.[0]?.dataOut || {}, data: step.executionSteps?.[0]?.dataOut || {},
`step.${step.id}` parentKey: `step.${step.id}`,
), }),
})); }));
}; };

View File

@@ -1,25 +1,26 @@
import * as React from 'react';
import ClickAwayListener from '@mui/base/ClickAwayListener'; import ClickAwayListener from '@mui/base/ClickAwayListener';
import Chip from '@mui/material/Chip';
import Popper from '@mui/material/Popper';
import InputLabel from '@mui/material/InputLabel';
import FormHelperText from '@mui/material/FormHelperText'; import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import * as React from 'react';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { createEditor } from 'slate'; import { createEditor } from 'slate';
import { Slate, Editable, useSelected, useFocused } from 'slate-react'; import { Editable } from 'slate-react';
import Slate from 'components/Slate';
import Element from 'components/Slate/Element';
import { import {
serialize, customizeEditor,
deserialize, deserialize,
insertVariable, insertVariable,
customizeEditor, serialize,
} from './utils'; } from 'components/Slate/utils';
import Suggestions from './Suggestions';
import { StepExecutionsContext } from 'contexts/StepExecutions'; import { StepExecutionsContext } from 'contexts/StepExecutions';
import { FakeInput, InputLabelWrapper, ChildrenWrapper } from './style'; import { VariableElement } from 'components/Slate/types';
import { VariableElement } from './types'; import Popper from './Popper';
import { processStepWithExecutions } from './data'; import { processStepWithExecutions } from './data';
import { ChildrenWrapper, FakeInput, InputLabelWrapper } from './style';
type PowerInputProps = { type PowerInputProps = {
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -59,6 +60,12 @@ const PowerInput = (props: PowerInputProps) => {
const [showVariableSuggestions, setShowVariableSuggestions] = const [showVariableSuggestions, setShowVariableSuggestions] =
React.useState(false); React.useState(false);
const disappearSuggestionsOnShift = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.code === 'Tab') {
setShowVariableSuggestions(false);
}
}
const stepsWithVariables = React.useMemo(() => { const stepsWithVariables = React.useMemo(() => {
return processStepWithExecutions(priorStepsWithExecutions); return processStepWithExecutions(priorStepsWithExecutions);
}, [priorStepsWithExecutions]); }, [priorStepsWithExecutions]);
@@ -93,7 +100,7 @@ const PowerInput = (props: PowerInputProps) => {
}) => ( }) => (
<Slate <Slate
editor={editor} editor={editor}
value={deserialize(value, stepsWithVariables)} value={deserialize(value, [], stepsWithVariables)}
onChange={(value) => { onChange={(value) => {
controllerOnChange(serialize(value)); controllerOnChange(serialize(value));
}} }}
@@ -122,6 +129,7 @@ const PowerInput = (props: PowerInputProps) => {
readOnly={disabled} readOnly={disabled}
style={{ width: '100%' }} style={{ width: '100%' }}
renderElement={renderElement} renderElement={renderElement}
onKeyDown={disappearSuggestionsOnShift}
onFocus={() => { onFocus={() => {
setShowVariableSuggestions(true); setShowVariableSuggestions(true);
}} }}
@@ -134,14 +142,14 @@ const PowerInput = (props: PowerInputProps) => {
{/* ghost placer for the variables popover */} {/* ghost placer for the variables popover */}
<div ref={editorRef} style={{ position: 'absolute', right: 16, left: 16 }} /> <div ref={editorRef} style={{ position: 'absolute', right: 16, left: 16 }} />
<FormHelperText variant="outlined">{description}</FormHelperText> <Popper
<SuggestionsPopper
open={showVariableSuggestions} open={showVariableSuggestions}
anchorEl={editorRef.current} anchorEl={editorRef.current}
data={stepsWithVariables} data={stepsWithVariables}
onSuggestionClick={handleVariableSuggestionClick} onSuggestionClick={handleVariableSuggestionClick}
/> />
<FormHelperText variant="outlined">{description}</FormHelperText>
</ChildrenWrapper> </ChildrenWrapper>
</ClickAwayListener> </ClickAwayListener>
</Slate> </Slate>
@@ -150,60 +158,4 @@ const PowerInput = (props: PowerInputProps) => {
); );
}; };
const SuggestionsPopper = (props: any) => {
const { open, anchorEl, data, onSuggestionClick } = props;
return (
<Popper
open={open}
anchorEl={anchorEl}
style={{ width: anchorEl?.clientWidth, zIndex: 1 }}
modifiers={[
{
name: 'flip',
enabled: false,
options: {
altBoundary: false,
},
},
]}
>
<Suggestions data={data} onSuggestionClick={onSuggestionClick} />
</Popper>
);
};
const Element = (props: any) => {
const { attributes, children, element } = props;
switch (element.type) {
case 'variable':
return <Variable {...props} />;
default:
return <p {...attributes}>{children}</p>;
}
};
const Variable = ({ attributes, children, element }: any) => {
const selected = useSelected();
const focused = useFocused();
const label = (
<>
{element.name}
{children}
</>
);
return (
<Chip
{...attributes}
component="span"
contentEditable={false}
style={{
boxShadow: selected && focused ? '0 0 0 2px #B4D5FF' : 'none',
}}
size="small"
label={label}
/>
);
};
export default PowerInput; export default PowerInput;

View File

@@ -1,3 +1,4 @@
import MuiTabs from '@mui/material/Tabs';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
export const ChildrenWrapper = styled('div')` export const ChildrenWrapper = styled('div')`
@@ -18,27 +19,41 @@ 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: 56px;
width: 100%; width: 100%;
display: block; display: block;
padding: ${({ theme }) => theme.spacing(0, 1.75)}; padding: ${({ theme }) => theme.spacing(0, 10, 0, 1.75)};
border-radius: ${({ theme }) => theme.spacing(0.5)}; border-radius: ${({ theme }) => theme.spacing(0.5)};
border-color: rgba(0, 0, 0, 0.23); border-color: rgba(0, 0, 0, 0.23);
position: relative; position: relative;
${({ disabled, theme }) => ${({ disabled, theme }) =>
!!disabled && !!disabled && `
` color: ${theme.palette.action.disabled};
color: ${theme.palette.action.disabled}, border-color: ${theme.palette.action.disabled};
border-color: ${theme.palette.action.disabled},
`} `}
&:hover { &:hover {
border-color: ${({ theme }) => theme.palette.text.primary}; border-color: ${({ theme }) => theme.palette.text.primary};
} }
&:focus-within { &:focus-within, &:focus {
&:before {
border-color: ${({ theme }) => theme.palette.primary.main}; border-color: ${({ theme }) => theme.palette.primary.main};
border-radius: ${({ theme }) => theme.spacing(0.5)};
border-style: solid;
border-width: 2px; border-width: 2px;
bottom: -2px;
content: '';
display: block;
left: -2px;
position: absolute;
right: -2px;
top: -2px;
}
} }
`; `;
export const Tabs = styled(MuiTabs)`
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
`;

View File

@@ -1,125 +0,0 @@
import { Text, Descendant, Transforms } from 'slate';
import { withHistory } from 'slate-history';
import { withReact } from 'slate-react';
import type { CustomEditor, CustomElement, VariableElement } from './types';
function getStepPosition(
id: string,
stepsWithVariables: Record<string, unknown>[]
) {
const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => {
return stepWithVariables.id === id;
});
return stepIndex + 1;
}
function humanizeVariableName(
variableName: string,
stepsWithVariables: Record<string, unknown>[]
) {
const nameWithoutCurlies = variableName.replace(/{{|}}/g, '');
const stepId = nameWithoutCurlies.match(stepIdRegExp)?.[1] || '';
const stepPosition = getStepPosition(stepId, stepsWithVariables);
const humanizedVariableName = nameWithoutCurlies.replace(
`step.${stepId}.`,
`step${stepPosition}.`
);
return humanizedVariableName;
}
const variableRegExp = /({{.*?}})/;
const stepIdRegExp = /^step.([\da-zA-Z-]*)/;
export const deserialize = (
value: string,
stepsWithVariables: any[]
): Descendant[] => {
if (!value)
return [
{
type: 'paragraph',
children: [{ text: '' }],
},
];
return value.split('\n').map((line) => {
const nodes = line.split(variableRegExp);
if (nodes.length > 1) {
return {
type: 'paragraph',
children: nodes.map((node) => {
if (node.match(variableRegExp)) {
return {
type: 'variable',
name: humanizeVariableName(node, stepsWithVariables),
value: node,
children: [{ text: '' }],
};
}
return {
text: node,
};
}),
};
}
return {
type: 'paragraph',
children: [{ text: line }],
};
});
};
export const serialize = (value: Descendant[]): string => {
return value.map((node) => serializeNode(node)).join('\n');
};
const serializeNode = (node: CustomElement | Descendant): string => {
if (Text.isText(node)) {
return node.text;
}
if (node.type === 'variable') {
return node.value as string;
}
return node.children.map((n) => serializeNode(n)).join('');
};
export const withVariables = (editor: CustomEditor) => {
const { isInline, isVoid } = editor;
editor.isInline = (element: CustomElement) => {
return element.type === 'variable' ? true : isInline(element);
};
editor.isVoid = (element: CustomElement) => {
return element.type === 'variable' ? true : isVoid(element);
};
return editor;
};
export const insertVariable = (
editor: CustomEditor,
variableData: Pick<VariableElement, 'name' | 'value'>,
stepsWithVariables: Record<string, unknown>[]
) => {
const variable: VariableElement = {
type: 'variable',
name: humanizeVariableName(variableData.name as string, stepsWithVariables),
value: `{{${variableData.name}}}`,
children: [{ text: '' }],
};
Transforms.insertNodes(editor, variable);
Transforms.move(editor);
};
export const customizeEditor = (editor: CustomEditor): CustomEditor => {
return withVariables(withReact(withHistory(editor)));
};

View File

@@ -1,70 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import get from 'lodash/get';
import set from 'lodash/set';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import forIn from 'lodash/forIn';
import isPlainObject from 'lodash/isPlainObject';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { IJSONObject } from '@automatisch/types'; import { IJSONObject } from '@automatisch/types';
import JSONViewer from 'components/JSONViewer'; import JSONViewer from 'components/JSONViewer';
import SearchInput from 'components/SearchInput'; import SearchInput from 'components/SearchInput';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import filterObject from 'helpers/filterObject';
type JSONViewerProps = { type JSONViewerProps = {
data: IJSONObject; data: IJSONObject;
}; };
function aggregate(
data: any,
searchTerm: string,
result = {},
prefix: string[] = [],
withinArray = false
) {
if (withinArray) {
const containerValue = get(result, prefix, []);
result = aggregate(
data,
searchTerm,
result,
prefix.concat(containerValue.length.toString())
);
return result;
}
if (isPlainObject(data)) {
forIn(data, (value, key) => {
const fullKey = [...prefix, key];
if (key.toLowerCase().includes(searchTerm)) {
set(result, fullKey, value);
return;
}
result = aggregate(value, searchTerm, result, fullKey);
});
}
if (Array.isArray(data)) {
forIn(data, (value) => {
result = aggregate(value, searchTerm, result, prefix, true);
});
}
if (
['string', 'number'].includes(typeof data) &&
String(data).toLowerCase().includes(searchTerm)
) {
set(result, prefix, data);
}
return result;
}
const SearchableJSONViewer = ({ data }: JSONViewerProps) => { const SearchableJSONViewer = ({ data }: JSONViewerProps) => {
const [filteredData, setFilteredData] = React.useState<IJSONObject | null>( const [filteredData, setFilteredData] = React.useState<IJSONObject | null>(
data data
@@ -81,7 +29,7 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => {
return; return;
} }
const newFilteredData = aggregate(data, search); const newFilteredData = filterObject(data, search);
if (isEmpty(newFilteredData)) { if (isEmpty(newFilteredData)) {
setFilteredData(null); setFilteredData(null);

View File

@@ -0,0 +1,12 @@
import Variable from './Variable';
export default function Element(props: any) {
const { attributes, children, element, disabled } = props;
switch (element.type) {
case 'variable':
return <Variable {...props} disabled={disabled} />;
default:
return <p {...attributes}>{children}</p>;
}
};

View File

@@ -0,0 +1,28 @@
import Chip from '@mui/material/Chip';
import { useSelected, useFocused } from 'slate-react';
export default function Variable({ attributes, children, element, disabled }: any) {
const selected = useSelected();
const focused = useFocused();
const label = (
<>
<span style={{ fontWeight: 500 }}>{element.name}</span>: <span style={{ fontWeight: 300 }}>{element.sampleValue}</span>
{children}
</>
);
return (
<Chip
{...attributes}
disabled={disabled}
component="span"
contentEditable={false}
style={{
boxShadow: selected && focused ? '0 0 0 2px #B4D5FF' : 'none',
}}
size="small"
label={label}
/>
);
};

View File

@@ -0,0 +1,3 @@
import { Slate } from 'slate-react';
export default Slate;

View File

@@ -5,14 +5,21 @@ export type VariableElement = {
type: 'variable'; type: 'variable';
value?: unknown; value?: unknown;
name?: string; name?: string;
sampleValue?: unknown;
children: Text[]; children: Text[];
}; };
export type ParagraphElement = { export type ParagraphElement = {
type: 'paragraph'; type: 'paragraph';
value?: string;
children: Descendant[]; children: Descendant[];
}; };
export type CustomText = {
text: string;
value: string;
};
export type CustomEditor = BaseEditor & ReactEditor; export type CustomEditor = BaseEditor & ReactEditor;
export type CustomElement = VariableElement | ParagraphElement; export type CustomElement = VariableElement | ParagraphElement;

View File

@@ -0,0 +1,280 @@
import { Text, Descendant } from 'slate';
import { withHistory } from 'slate-history';
import { ReactEditor, withReact } from 'slate-react';
import { IFieldDropdownOption } from '@automatisch/types';
import type { CustomEditor, CustomElement, CustomText, ParagraphElement, VariableElement } from './types';
type StepWithVariables = {
id: string;
name: string;
output: {
label: string;
sampleValue: string;
value: string;
}[];
}
type StepsWithVariables = StepWithVariables[];
function isCustomText(value: any): value is CustomText {
const isText = Text.isText(value);
const hasValueProperty = 'value' in value;
if (isText && hasValueProperty) return true;
return false;
}
function getStepPosition(
id: string,
stepsWithVariables: StepsWithVariables
) {
const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => {
return stepWithVariables.id === id;
});
return stepIndex + 1;
}
function getVariableName(variable: string) {
return variable.replace(/{{|}}/g, '');
}
function getVariableStepId(variable: string) {
const nameWithoutCurlies = getVariableName(variable);
const stepId = nameWithoutCurlies.match(stepIdRegExp)?.[1] || '';
return stepId;
}
function getVariableSampleValue(variable: string, stepsWithVariables: StepsWithVariables) {
const variableStepId = getVariableStepId(variable);
const stepWithVariables = stepsWithVariables.find(({ id }: { id: string }) => id === variableStepId);
if (!stepWithVariables) return null;
const variableName = getVariableName(variable);
const variableData = stepWithVariables.output.find(({ value }) => variableName === value);
if (!variableData) return null;
return variableData.sampleValue;
}
function getVariableDetails(variable: string, stepsWithVariables: StepsWithVariables) {
const variableName = getVariableName(variable);
const stepId = getVariableStepId(variableName);
const stepPosition = getStepPosition(stepId, stepsWithVariables);
const sampleValue = getVariableSampleValue(variable, stepsWithVariables);
const label = variableName.replace(
`step.${stepId}.`,
`step${stepPosition}.`
);
return {
sampleValue,
label,
};
}
const variableRegExp = /({{.*?}})/;
const stepIdRegExp = /^step.([\da-zA-Z-]*)/;
export const deserialize = (
value: string,
options: readonly IFieldDropdownOption[],
stepsWithVariables: StepsWithVariables
): Descendant[] => {
if (!value)
return [
{
type: 'paragraph',
children: [{ text: '' }],
},
];
const selectedNativeOption = options.find((option) => value === option.value);
if (selectedNativeOption) {
return [
{
type: 'paragraph',
value: selectedNativeOption.value as string,
children: [{ text: selectedNativeOption.label }],
},
];
}
return value.split('\n').map((line) => {
const nodes = line.split(variableRegExp);
if (nodes.length > 1) {
return {
type: 'paragraph',
children: nodes.map((node) => {
if (node.match(variableRegExp)) {
const variableDetails = getVariableDetails(node, stepsWithVariables);
return {
type: 'variable',
name: variableDetails.label,
sampleValue: variableDetails.sampleValue,
value: node,
children: [{ text: '' }],
};
}
return {
text: node,
};
}),
};
}
return {
type: 'paragraph',
children: [{ text: line }],
};
});
};
export const serialize = (value: Descendant[]): string => {
const serializedNodes = value.map((node) => serializeNode(node));
const hasSingleNode = value.length === 1;
/**
* return single serialize node alone so that we don't stringify.
* booleans stay booleans, numbers stay number
*/
if (hasSingleNode) {
return serializedNodes[0];
}
const serializedValue = serializedNodes.join('\n');
return serializedValue;
};
const serializeNode = (node: CustomElement | Descendant): string => {
if (isCustomText(node)) return node.value;
if (Text.isText(node)) {
return node.text;
}
if (node.type === 'variable') {
return node.value as string;
}
const hasSingleChild = node.children.length === 1;
/**
* serialize alone so that we don't stringify.
* booleans stay booleans, numbers stay number
*/
if (hasSingleChild) {
return serializeNode(node.children[0]);
}
return node.children.map((n) => serializeNode(n)).join('');
};
export const withVariables = (editor: CustomEditor) => {
const { isInline, isVoid } = editor;
editor.isInline = (element: CustomElement) => {
return element.type === 'variable' ? true : isInline(element);
};
editor.isVoid = (element: CustomElement) => {
return element.type === 'variable' ? true : isVoid(element);
};
return editor;
};
export const insertVariable = (
editor: CustomEditor,
variableData: Record<string, unknown>,
stepsWithVariables: StepsWithVariables
) => {
const variableDetails = getVariableDetails(`{{${variableData.value}}}`, stepsWithVariables);
const variable: VariableElement = {
type: 'variable',
name: variableDetails.label,
sampleValue: variableDetails.sampleValue,
value: `{{${variableData.value}}}`,
children: [{ text: '' }],
};
editor.insertNodes(variable, { select: false });
focusEditor(editor);
};
export const focusEditor = (editor: CustomEditor) => {
ReactEditor.focus(editor);
editor.move();
}
export const resetEditor = (editor: CustomEditor, options?: { focus: boolean }) => {
const focus = options?.focus || false;
editor.removeNodes({
at: {
anchor: editor.start([]),
focus: editor.end([])
},
});
// `editor.normalize({ force: true })` doesn't add an empty node in the editor
editor.insertNode(createTextNode(''));
if (focus) {
focusEditor(editor);
}
}
export const overrideEditorValue = (editor: CustomEditor, options: { option: IFieldDropdownOption, focus: boolean }) => {
const { option, focus } = options;
const variable: ParagraphElement = {
type: 'paragraph',
children: [
{
value: option.value as string,
text: option.label as string
}
],
};
editor.withoutNormalizing(() => {
editor.removeNodes({
at: {
anchor: editor.start([]),
focus: editor.end([])
},
});
editor.insertNode(variable);
if (focus) {
focusEditor(editor);
}
});
};
export const createTextNode = (text: string): ParagraphElement => ({
type: 'paragraph',
children: [
{
text
}
]
});
export const customizeEditor = (editor: CustomEditor): CustomEditor => {
return withVariables(withReact(withHistory(editor)));
};

View File

@@ -8,7 +8,9 @@ export const CREATE_STEP = gql`
key key
appKey appKey
parameters parameters
iconUrl
position position
webhookUrl
status status
connection { connection {
id id

View File

@@ -0,0 +1,53 @@
import get from 'lodash/get';
import set from 'lodash/set';
import forIn from 'lodash/forIn';
import isPlainObject from 'lodash/isPlainObject';
export default function filterObject(
data: any,
searchTerm: string,
result = {},
prefix: string[] = [],
withinArray = false
) {
if (withinArray) {
const containerValue = get(result, prefix, []);
result = filterObject(
data,
searchTerm,
result,
prefix.concat(containerValue.length.toString())
);
return result;
}
if (isPlainObject(data)) {
forIn(data, (value, key) => {
const fullKey = [...prefix, key];
if (key.toLowerCase().includes(searchTerm)) {
set(result, fullKey, value);
return;
}
result = filterObject(value, searchTerm, result, fullKey);
});
}
if (Array.isArray(data)) {
forIn(data, (value) => {
result = filterObject(value, searchTerm, result, prefix, true);
});
}
if (
['string', 'number'].includes(typeof data) &&
String(data).toLowerCase().includes(searchTerm)
) {
set(result, prefix, data);
}
return result;
};

View File

@@ -0,0 +1,16 @@
import lodashIsEmpty from 'lodash/isEmpty';
export default function isEmpty(value: any) {
if (value === undefined && value === null) return true;
if (Array.isArray(value) || typeof value === 'string') {
return value.length === 0;
}
if (!Number.isNaN(value)) {
return false;
}
// covers objects and anything else possibly
return lodashIsEmpty(value);
};

View File

@@ -162,5 +162,7 @@
"trialBadge.over": "Trial is over", "trialBadge.over": "Trial is over",
"trialOverAlert.text": "Your free trial is over. Please <link>upgrade</link> your plan to continue using Automatisch.", "trialOverAlert.text": "Your free trial is over. Please <link>upgrade</link> your plan to continue using Automatisch.",
"checkoutCompletedAlert.text": "Thank you for upgrading your subscription and supporting our self-funded business!", "checkoutCompletedAlert.text": "Thank you for upgrading your subscription and supporting our self-funded business!",
"subscriptionCancelledAlert.text": "Your subscription is cancelled, but you can continue using Automatisch until {date}." "subscriptionCancelledAlert.text": "Your subscription is cancelled, but you can continue using Automatisch until {date}.",
"customAutocomplete.noOptions": "No options available.",
"powerInputSuggestions.noOptions": "No options available."
} }

View File

@@ -1315,6 +1315,13 @@
core-js-pure "^3.20.2" core-js-pure "^3.20.2"
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.0.0":
version "7.21.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.16.7" version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
@@ -2121,6 +2128,11 @@
"@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/sourcemap-codec" "^1.4.10"
"@juggle/resize-observer@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@lerna/add@4.0.0": "@lerna/add@4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f"
@@ -4178,6 +4190,13 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-window@^1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@16 || 17", "@types/react@^17.0.0": "@types/react@*", "@types/react@16 || 17", "@types/react@^17.0.0":
version "17.0.38" version "17.0.38"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd"
@@ -11978,6 +11997,11 @@ memfs@^3.1.2, memfs@^3.2.2:
dependencies: dependencies:
fs-monkey "1.0.3" fs-monkey "1.0.3"
"memoize-one@>=3.1.1 <6":
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
memory-cache@^0.2.0: memory-cache@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.2.0.tgz#7890b01d52c00c8ebc9d533e1f8eb17e3034871a" resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.2.0.tgz#7890b01d52c00c8ebc9d533e1f8eb17e3034871a"
@@ -14770,6 +14794,14 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-window@^1.8.9:
version "1.8.9"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8"
integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^17.0.2: react@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
@@ -15722,18 +15754,19 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
slate-history@^0.66.0: slate-history@^0.93.0:
version "0.66.0" version "0.93.0"
resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.66.0.tgz#ac63fddb903098ceb4c944433e3f75fe63acf940" resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.93.0.tgz#d2fad47e4e8b262ab7c86b653f5dd6d9b6d85277"
integrity sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng== integrity sha512-Gr1GMGPipRuxIz41jD2/rbvzPj8eyar56TVMyJBvBeIpQSSjNISssvGNDYfJlSWM8eaRqf6DAcxMKzsLCYeX6g==
dependencies: dependencies:
is-plain-object "^5.0.0" is-plain-object "^5.0.0"
slate-react@^0.72.9: slate-react@^0.94.2:
version "0.72.9" version "0.94.2"
resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.72.9.tgz#b05dd533bd29dd2d4796b614a8d8e01f214bb714" resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.94.2.tgz#3fc70f0212f42a1c417012d7a911f0ec9f6b11fe"
integrity sha512-FEsqB+D1R/h+w1eCtHH367Krw2X7vju2GjMRL/d0bUiCRXlV50J9I9TJizvi7aaZyqBY8BypCuIiq9nNmsulCA== integrity sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q==
dependencies: dependencies:
"@juggle/resize-observer" "^3.4.0"
"@types/is-hotkey" "^0.1.1" "@types/is-hotkey" "^0.1.1"
"@types/lodash" "^4.14.149" "@types/lodash" "^4.14.149"
direction "^1.0.3" direction "^1.0.3"
@@ -15743,10 +15776,10 @@ slate-react@^0.72.9:
scroll-into-view-if-needed "^2.2.20" scroll-into-view-if-needed "^2.2.20"
tiny-invariant "1.0.6" tiny-invariant "1.0.6"
slate@^0.72.8: slate@^0.94.1:
version "0.72.8" version "0.94.1"
resolved "https://registry.yarnpkg.com/slate/-/slate-0.72.8.tgz#5a018edf24e45448655293a68bfbcf563aa5ba81" resolved "https://registry.yarnpkg.com/slate/-/slate-0.94.1.tgz#13b0ba7d0a7eeb0ec89a87598e9111cbbd685696"
integrity sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw== integrity sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==
dependencies: dependencies:
immer "^9.0.6" immer "^9.0.6"
is-plain-object "^5.0.0" is-plain-object "^5.0.0"