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