feat: add PowerInput component
This commit is contained in:

committed by
Ömer Faruk Aydın

parent
c864a1062d
commit
d06f21c927
133
packages/web/src/components/PowerInput/Suggestions.tsx
Normal file
133
packages/web/src/components/PowerInput/Suggestions.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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 ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import { Step } from 'types/step';
|
||||
|
||||
const ListItemText = styled(MuiListItemText)``;
|
||||
|
||||
type SuggestionsProps = {
|
||||
query?: string | null;
|
||||
index: number;
|
||||
data: any;
|
||||
onSuggestionClick: (variable: any) => void;
|
||||
};
|
||||
|
||||
const SHORT_LIST_LENGTH = 4;
|
||||
const LIST_HEIGHT = 256;
|
||||
|
||||
const getPartialFilteredArray = (array: any[], query = '', length = array.length) => {
|
||||
return array
|
||||
.filter((suboption: any) => suboption.name.includes(query))
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
const Suggestions = (props: SuggestionsProps) => {
|
||||
const {
|
||||
query = '',
|
||||
index: focusIndex,
|
||||
data,
|
||||
onSuggestionClick = () => null,
|
||||
} = props;
|
||||
const [current, setCurrent] = React.useState<number | null>(0);
|
||||
const [listLength, setListLength] = React.useState<number | undefined>(SHORT_LIST_LENGTH);
|
||||
|
||||
const expandList = () => {
|
||||
setListLength(undefined);
|
||||
};
|
||||
|
||||
const collapseList = () => {
|
||||
setListLength(SHORT_LIST_LENGTH);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
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: Step, index: number) => (
|
||||
<>
|
||||
<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} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding sx={{ maxHeight: LIST_HEIGHT, overflowY: 'auto' }}>
|
||||
{getPartialFilteredArray(option.output as any || [], query as string, listLength)
|
||||
.map((suboption: any, index: number) => (
|
||||
<ListItemButton
|
||||
sx={{ pl: 4 }}
|
||||
divider
|
||||
onClick={() => onSuggestionClick(suboption)}
|
||||
selected={focusIndex === index}>
|
||||
<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>
|
||||
|
||||
{listLength && (
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={expandList}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{listLength === undefined && (
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={collapseList}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
)}
|
||||
</Collapse>
|
||||
</>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Suggestions;
|
44
packages/web/src/components/PowerInput/data.ts
Normal file
44
packages/web/src/components/PowerInput/data.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Step } from 'types/step';
|
||||
|
||||
const joinBy = (delimiter = '.', ...args: string[]) => args.filter(Boolean).join(delimiter);
|
||||
|
||||
const process = (data: any, parentKey?: any, index?: number): any[] => {
|
||||
if (typeof data !== 'object') {
|
||||
return [
|
||||
{
|
||||
name: `${parentKey}.${index}`,
|
||||
value: data,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const entries = Object.entries(data);
|
||||
|
||||
return entries.flatMap(([name, value]) => {
|
||||
const fullName = joinBy('.', parentKey, (index as number)?.toString(), name);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) => process(item, fullName, index));
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return process(value, fullName);
|
||||
}
|
||||
|
||||
return [{
|
||||
name: fullName,
|
||||
value,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
export const processStepWithExecutions = (steps: Step[]): any[] => {
|
||||
if (!steps) return [];
|
||||
|
||||
return steps.map((step: Step, index: number) => ({
|
||||
id: step.id,
|
||||
// TODO: replace with step.name once introduced
|
||||
name: `${index + 1}. ${step.appKey}`,
|
||||
output: process(step.output, `step.${step.id}`),
|
||||
}));
|
||||
};
|
247
packages/web/src/components/PowerInput/index.tsx
Normal file
247
packages/web/src/components/PowerInput/index.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as React from 'react';
|
||||
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import { Controller, Control, FieldValues } from 'react-hook-form';
|
||||
import { Editor, Transforms, Range, createEditor } from 'slate';
|
||||
import {
|
||||
Slate,
|
||||
Editable,
|
||||
useSelected,
|
||||
useFocused,
|
||||
} from 'slate-react';
|
||||
|
||||
import {
|
||||
serialize,
|
||||
deserialize,
|
||||
insertVariable,
|
||||
customizeEditor,
|
||||
} from './utils';
|
||||
import Suggestions from './Suggestions';
|
||||
import { StepExecutionsContext } from 'contexts/StepExecutions';
|
||||
|
||||
import { FakeInput, InputLabelWrapper } from './style';
|
||||
import { VariableElement } from './types';
|
||||
import { processStepWithExecutions } from './data';
|
||||
|
||||
type PowerInputProps = {
|
||||
control?: Control<FieldValues>;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
description?: string;
|
||||
docUrl?: string;
|
||||
clickToCopy?: boolean;
|
||||
}
|
||||
|
||||
const PowerInput = (props: PowerInputProps) => {
|
||||
const {
|
||||
control,
|
||||
defaultValue = '',
|
||||
onBlur,
|
||||
name,
|
||||
label,
|
||||
required,
|
||||
description,
|
||||
} = props;
|
||||
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
|
||||
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [target, setTarget] = React.useState<Range | null>(null);
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [search, setSearch] = React.useState<string | null>(null);
|
||||
const renderElement = React.useCallback(props => <Element {...props} />, []);
|
||||
const [editor] = React.useState(() => customizeEditor(createEditor()));
|
||||
|
||||
const stepsWithVariables = React.useMemo(() => {
|
||||
return processStepWithExecutions(priorStepsWithExecutions);
|
||||
}, [priorStepsWithExecutions])
|
||||
|
||||
const handleBlur = React.useCallback((value) => {
|
||||
onBlur?.(value);
|
||||
}, [onBlur]);
|
||||
|
||||
const handleVariableSuggestionClick = React.useCallback(
|
||||
(variable: Pick<VariableElement, "name" | "value">) => {
|
||||
if (target) {
|
||||
Transforms.select(editor, target);
|
||||
insertVariable(editor, variable);
|
||||
setTarget(null);
|
||||
}
|
||||
},
|
||||
[index, target]
|
||||
);
|
||||
|
||||
const onKeyDown = React.useCallback(
|
||||
event => {
|
||||
if (target) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
setIndex((currentIndex) => currentIndex + 1);
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
setIndex((currentIndex) => currentIndex - 1 < 0 ? 0 : currentIndex - 1);
|
||||
break
|
||||
}
|
||||
case 'Tab':
|
||||
case 'Enter': {
|
||||
event.preventDefault();
|
||||
Transforms.select(editor, target);
|
||||
insertVariable(editor, stepsWithVariables[0].output[index]);
|
||||
setTarget(null);
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
event.preventDefault();
|
||||
setTarget(null);
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[index, search, target, stepsWithVariables]
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
rules={{ required }}
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
shouldUnregister={false}
|
||||
render={({ field: { value, ref, onChange: controllerOnChange, onBlur: controllerOnBlur, ...field } }) => (
|
||||
<Slate
|
||||
editor={editor}
|
||||
value={deserialize(value)}
|
||||
onChange={value => {
|
||||
controllerOnChange(serialize(value));
|
||||
const { selection } = editor
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const lineBefore = Editor.before(editor, start, { unit: 'line' });
|
||||
const before = lineBefore && Editor.before(editor, lineBefore);
|
||||
const beforeRange = (before || lineBefore) && Editor.range(editor, before || lineBefore, start);
|
||||
const beforeText = beforeRange && Editor.string(editor, beforeRange);
|
||||
const variableMatch = beforeText && beforeText.match(/@([\w.]*?)$/);
|
||||
|
||||
if (variableMatch) {
|
||||
const beginningOfVariable = Editor.before(
|
||||
editor,
|
||||
start,
|
||||
{
|
||||
unit: 'offset',
|
||||
distance: (variableMatch[1].length || 0) + 1
|
||||
}
|
||||
);
|
||||
if (beginningOfVariable) {
|
||||
const newTarget = Editor.range(editor, beginningOfVariable, start);
|
||||
if (newTarget) {
|
||||
setTarget(newTarget);
|
||||
}
|
||||
}
|
||||
setIndex(0);
|
||||
setSearch(variableMatch[1]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSearch(null);
|
||||
}}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setSearch(null)}>
|
||||
{/* ref-able single child for ClickAwayListener */}
|
||||
<div style={{ width: '100%' }}>
|
||||
<FakeInput>
|
||||
<InputLabelWrapper>
|
||||
<InputLabel
|
||||
shrink={true}
|
||||
// focused
|
||||
variant="outlined"
|
||||
sx={{ bgcolor: 'white', display: 'inline-block', px: .75 }}
|
||||
>
|
||||
{label}
|
||||
</InputLabel>
|
||||
</InputLabelWrapper>
|
||||
|
||||
<Editable
|
||||
style={{ width: '100%' }}
|
||||
renderElement={renderElement}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => { controllerOnBlur(); handleBlur(value); }}
|
||||
/>
|
||||
</FakeInput>
|
||||
{/* ghost placer for the variables popover */}
|
||||
<div ref={editorRef} style={{ width: '100%' }} />
|
||||
|
||||
<FormHelperText
|
||||
variant="outlined"
|
||||
>
|
||||
{description}
|
||||
</FormHelperText>
|
||||
|
||||
<Popper
|
||||
open={target !== null && search !== null}
|
||||
anchorEl={editorRef.current}
|
||||
style={{ width: editorRef.current?.clientWidth }}
|
||||
>
|
||||
<Suggestions
|
||||
query={search}
|
||||
index={index}
|
||||
data={stepsWithVariables}
|
||||
onSuggestionClick={handleVariableSuggestionClick}
|
||||
/>
|
||||
</Popper>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
</Slate>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
28
packages/web/src/components/PowerInput/style.ts
Normal file
28
packages/web/src/components/PowerInput/style.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const InputLabelWrapper = styled('div')`
|
||||
position: absolute;
|
||||
left: ${({ theme }) => theme.spacing(1.75)};
|
||||
inset: 0;
|
||||
left: -6px;
|
||||
`;
|
||||
|
||||
export const FakeInput = styled('div')`
|
||||
border: 1px solid #eee;
|
||||
min-height: 52px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: ${({ theme }) => theme.spacing(0, 1.75)};
|
||||
border-radius: ${({ theme }) => theme.spacing(.5)};
|
||||
border-color: rgba(0, 0, 0, 0.23);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ theme }) => theme.palette.text.primary};
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${({ theme }) => theme.palette.primary.main};
|
||||
border-width: 2px;
|
||||
}
|
||||
`;
|
25
packages/web/src/components/PowerInput/types.ts
Normal file
25
packages/web/src/components/PowerInput/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
}
|
91
packages/web/src/components/PowerInput/utils.ts
Normal file
91
packages/web/src/components/PowerInput/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Text, Descendant, Transforms } from 'slate';
|
||||
import { withHistory } from 'slate-history';
|
||||
import { withReact } from 'slate-react';
|
||||
|
||||
import type {
|
||||
CustomEditor,
|
||||
CustomElement,
|
||||
VariableElement,
|
||||
} from './types';
|
||||
|
||||
export const deserialize = (value: string): Descendant[] => {
|
||||
const variableRegExp = /({{.*?}})/g;
|
||||
|
||||
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: node.replace(/{{|}}/g, ''),
|
||||
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.name}}}`;
|
||||
}
|
||||
|
||||
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">) => {
|
||||
const variable: VariableElement = {
|
||||
type: 'variable',
|
||||
name: variableData.name,
|
||||
value: variableData.value,
|
||||
children: [{ text: '' }],
|
||||
};
|
||||
Transforms.insertNodes(editor, variable);
|
||||
Transforms.move(editor);
|
||||
}
|
||||
|
||||
export const customizeEditor = (editor: CustomEditor): CustomEditor => {
|
||||
return withVariables(withReact(withHistory(editor)));
|
||||
};
|
Reference in New Issue
Block a user