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

@@ -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 { 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 type { IStep } from '@automatisch/types';
import ExpandLess from '@mui/icons-material/ExpandLess';
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 = {
data: any[];
data: {
id: string;
name: string;
output: Record<string, unknown>[]
}[];
onSuggestionClick: (variable: any) => void;
};
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) => {
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 { data, onSuggestionClick = () => null } = props;
const formatMessage = useFormatMessage();
const {
data,
onSuggestionClick = () => null
} = props;
const [current, setCurrent] = React.useState<number | null>(0);
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 = () => {
setListLength(Infinity);
@@ -43,79 +107,95 @@ const Suggestions = (props: SuggestionsProps) => {
setListLength(SHORT_LIST_LENGTH);
}, [current]);
return (
<Paper elevation={5} sx={{ width: '100%' }}>
<Typography variant="subtitle2" sx={{ p: 2 }}>
Variables
</Typography>
<List disablePadding>
{data.map((option: IStep, index: number) => (
<>
<ListItemButton
divider
onClick={() =>
setCurrent((currentIndex) =>
currentIndex === index ? null : index
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())
)
}
sx={{ py: 0.5 }}
>
<ListItemText primary={option.name} />
}
})
.filter((stepWithOutput) => stepWithOutput.output.length);
{!!option.output?.length &&
(current === index ? <ExpandLess /> : <ExpandMore />)}
</ListItemButton>
setFilteredData(newFilteredData);
}, 400),
[data]
);
<Collapse in={current === index} timeout="auto" unmountOnExit>
<List
component="div"
disablePadding
sx={{ maxHeight: LIST_HEIGHT, overflowY: 'auto' }}
data-test="power-input-suggestion-group"
>
{getPartialArray((option.output as any) || [], listLength).map(
(suboption: any, index: number) => (
<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>
return (
<Paper elevation={0} sx={{ width: '100%' }}>
<Box px={2} pb={2}>
<SearchInput onChange={onSearchChange} />
</Box>
{filteredData.length > 0 && (
<List disablePadding>
{filteredData.map((option: IStep, index: number) => (
<React.Fragment key={`${index}-${option.name}`}>
<ListItemButton
divider
onClick={() =>
setCurrent((currentIndex) =>
currentIndex === index ? null : index
)
}
sx={{ py: 0.5 }}
>
<ListItemText primary={option.name} />
{!!option.output?.length &&
(current === index ? <ExpandLess /> : <ExpandMore />)}
</ListItemButton>
<Collapse in={current === index || filteredData.length === 1} timeout="auto" unmountOnExit>
<FixedSizeList
height={computeListHeight(getPartialArray((option.output as any) || [], listLength).length)}
width="100%"
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"
>
{renderItem}
</FixedSizeList>
{(option.output?.length || 0) > listLength && (
<Button fullWidth onClick={expandList}>
Show all
</Button>
)}
</List>
{(option.output?.length || 0) > listLength && (
<Button fullWidth onClick={expandList}>
Show all
</Button>
)}
{listLength === Infinity && (
<Button fullWidth onClick={collapseList}>
Show less
</Button>
)}
</Collapse>
</React.Fragment>
))}
</List>
)}
{listLength === Infinity && (
<Button fullWidth onClick={collapseList}>
Show less
</Button>
)}
</Collapse>
</>
))}
</List>
{filteredData.length === 0 && (
<Typography sx={{ p: (theme) => theme.spacing(0, 0, 2, 2) }}>
{formatMessage('powerInputSuggestions.noOptions')}
</Typography>
)}
</Paper>
);
};

View File

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

View File

@@ -1,25 +1,26 @@
import * as React from 'react';
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 InputLabel from '@mui/material/InputLabel';
import * as React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
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 {
serialize,
customizeEditor,
deserialize,
insertVariable,
customizeEditor,
} from './utils';
import Suggestions from './Suggestions';
serialize,
} from 'components/Slate/utils';
import { StepExecutionsContext } from 'contexts/StepExecutions';
import { FakeInput, InputLabelWrapper, ChildrenWrapper } from './style';
import { VariableElement } from './types';
import { VariableElement } from 'components/Slate/types';
import Popper from './Popper';
import { processStepWithExecutions } from './data';
import { ChildrenWrapper, FakeInput, InputLabelWrapper } from './style';
type PowerInputProps = {
onChange?: (value: string) => void;
@@ -59,6 +60,12 @@ const PowerInput = (props: PowerInputProps) => {
const [showVariableSuggestions, setShowVariableSuggestions] =
React.useState(false);
const disappearSuggestionsOnShift = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.code === 'Tab') {
setShowVariableSuggestions(false);
}
}
const stepsWithVariables = React.useMemo(() => {
return processStepWithExecutions(priorStepsWithExecutions);
}, [priorStepsWithExecutions]);
@@ -93,7 +100,7 @@ const PowerInput = (props: PowerInputProps) => {
}) => (
<Slate
editor={editor}
value={deserialize(value, stepsWithVariables)}
value={deserialize(value, [], stepsWithVariables)}
onChange={(value) => {
controllerOnChange(serialize(value));
}}
@@ -122,6 +129,7 @@ const PowerInput = (props: PowerInputProps) => {
readOnly={disabled}
style={{ width: '100%' }}
renderElement={renderElement}
onKeyDown={disappearSuggestionsOnShift}
onFocus={() => {
setShowVariableSuggestions(true);
}}
@@ -134,14 +142,14 @@ const PowerInput = (props: PowerInputProps) => {
{/* ghost placer for the variables popover */}
<div ref={editorRef} style={{ position: 'absolute', right: 16, left: 16 }} />
<FormHelperText variant="outlined">{description}</FormHelperText>
<SuggestionsPopper
<Popper
open={showVariableSuggestions}
anchorEl={editorRef.current}
data={stepsWithVariables}
onSuggestionClick={handleVariableSuggestionClick}
/>
<FormHelperText variant="outlined">{description}</FormHelperText>
</ChildrenWrapper>
</ClickAwayListener>
</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;

View File

@@ -1,3 +1,4 @@
import MuiTabs from '@mui/material/Tabs';
import { styled } from '@mui/material/styles';
export const ChildrenWrapper = styled('div')`
@@ -18,27 +19,41 @@ export const FakeInput = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
}) <{ disabled?: boolean }>`
border: 1px solid #eee;
min-height: 52px;
min-height: 56px;
width: 100%;
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-color: rgba(0, 0, 0, 0.23);
position: relative;
${({ disabled, theme }) =>
!!disabled &&
`
color: ${theme.palette.action.disabled},
border-color: ${theme.palette.action.disabled},
!!disabled && `
color: ${theme.palette.action.disabled};
border-color: ${theme.palette.action.disabled};
`}
&:hover {
border-color: ${({ theme }) => theme.palette.text.primary};
}
&:focus-within {
border-color: ${({ theme }) => theme.palette.primary.main};
border-width: 2px;
&:focus-within, &:focus {
&:before {
border-color: ${({ theme }) => theme.palette.primary.main};
border-radius: ${({ theme }) => theme.spacing(0.5)};
border-style: solid;
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,25 +0,0 @@
import type { BaseEditor, Text, Descendant } from 'slate';
import type { ReactEditor } from 'slate-react';
export type VariableElement = {
type: 'variable';
value?: unknown;
name?: string;
children: Text[];
};
export type ParagraphElement = {
type: 'paragraph';
children: Descendant[];
};
export type CustomEditor = BaseEditor & ReactEditor;
export type CustomElement = VariableElement | ParagraphElement;
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
}
}

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