Add Multiselect for forms (#9092)
- Add new FormMultiSelectField component - Factorize existing display / input into new ui components - Update the variable resolver to handle arrays properly <img width="526" alt="Capture d’écran 2024-12-17 à 11 46 38" src="https://github.com/user-attachments/assets/6d37b513-8caa-43d0-a27e-ab55dac21f6d" />
This commit is contained in:
@ -1,9 +1,10 @@
|
||||
import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput';
|
||||
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
|
||||
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
|
||||
import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
|
||||
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
|
||||
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
|
||||
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
|
||||
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
|
||||
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
|
||||
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
||||
@ -15,13 +16,15 @@ import {
|
||||
FieldFullNameValue,
|
||||
FieldLinksValue,
|
||||
FieldMetadata,
|
||||
FieldMultiSelectValue,
|
||||
} from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
|
||||
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
|
||||
@ -107,5 +110,13 @@ export const FormFieldInput = ({
|
||||
onPersist={onPersist}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
) : isFieldMultiSelect(field) ? (
|
||||
<FormMultiSelectFieldInput
|
||||
label={field.label}
|
||||
defaultValue={defaultValue as FieldMultiSelectValue | string | undefined}
|
||||
onPersist={onPersist}
|
||||
VariablePicker={VariablePicker}
|
||||
options={field.metadata.options}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
|
||||
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
|
||||
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
|
||||
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
|
||||
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
|
||||
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
|
||||
import { SelectOption } from '@/spreadsheet-import/types';
|
||||
import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay';
|
||||
import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
import { useId, useState } from 'react';
|
||||
import { VisibilityHidden } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
type FormMultiSelectFieldInputProps = {
|
||||
label?: string;
|
||||
defaultValue: FieldMultiSelectValue | string | undefined;
|
||||
onPersist: (value: FieldMultiSelectValue | string) => void;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
options: SelectOption[];
|
||||
};
|
||||
|
||||
const StyledDisplayModeContainer = styled.button`
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
padding-inline: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
&:hover,
|
||||
&[data-open='true'] {
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSelectInputContainer = styled.div`
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
export const FormMultiSelectFieldInput = ({
|
||||
label,
|
||||
defaultValue,
|
||||
onPersist,
|
||||
VariablePicker,
|
||||
options,
|
||||
}: FormMultiSelectFieldInputProps) => {
|
||||
const inputId = useId();
|
||||
|
||||
const hotkeyScope = MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID;
|
||||
|
||||
const {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
} = usePreviousHotkeyScope();
|
||||
|
||||
const [draftValue, setDraftValue] = useState<
|
||||
| {
|
||||
type: 'static';
|
||||
value: FieldMultiSelectValue;
|
||||
editingMode: 'view' | 'edit';
|
||||
}
|
||||
| {
|
||||
type: 'variable';
|
||||
value: string;
|
||||
}
|
||||
>(
|
||||
isStandaloneVariableString(defaultValue)
|
||||
? {
|
||||
type: 'variable',
|
||||
value: defaultValue,
|
||||
}
|
||||
: {
|
||||
type: 'static',
|
||||
value: isDefined(defaultValue) ? defaultValue : [],
|
||||
editingMode: 'view',
|
||||
},
|
||||
);
|
||||
|
||||
const handleDisplayModeClick = () => {
|
||||
if (draftValue.type !== 'static') {
|
||||
throw new Error(
|
||||
'This function can only be called when editing a static value.',
|
||||
);
|
||||
}
|
||||
|
||||
setDraftValue({
|
||||
...draftValue,
|
||||
editingMode: 'edit',
|
||||
});
|
||||
|
||||
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
|
||||
};
|
||||
|
||||
const onOptionSelected = (value: FieldMultiSelectValue) => {
|
||||
if (draftValue.type !== 'static') {
|
||||
throw new Error('Can only be called when editing a static value');
|
||||
}
|
||||
|
||||
setDraftValue({
|
||||
type: 'static',
|
||||
value,
|
||||
editingMode: 'edit',
|
||||
});
|
||||
|
||||
onPersist(value);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
if (draftValue.type !== 'static') {
|
||||
throw new Error('Can only be called when editing a static value');
|
||||
}
|
||||
|
||||
setDraftValue({
|
||||
...draftValue,
|
||||
editingMode: 'view',
|
||||
});
|
||||
|
||||
goBackToPreviousHotkeyScope();
|
||||
};
|
||||
|
||||
const handleVariableTagInsert = (variableName: string) => {
|
||||
setDraftValue({
|
||||
type: 'variable',
|
||||
value: variableName,
|
||||
});
|
||||
|
||||
onPersist(variableName);
|
||||
};
|
||||
|
||||
const handleUnlinkVariable = () => {
|
||||
setDraftValue({
|
||||
type: 'static',
|
||||
value: [],
|
||||
editingMode: 'view',
|
||||
});
|
||||
|
||||
onPersist([]);
|
||||
};
|
||||
|
||||
const selectedNames =
|
||||
draftValue.type === 'static' ? draftValue.value : undefined;
|
||||
|
||||
const selectedOptions =
|
||||
isDefined(selectedNames) && isDefined(options)
|
||||
? options.filter((option) =>
|
||||
selectedNames.some((name) => option.value === name),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<FormFieldInputContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
|
||||
<FormFieldInputRowContainer>
|
||||
<FormFieldInputInputContainer
|
||||
hasRightElement={isDefined(VariablePicker)}
|
||||
>
|
||||
{draftValue.type === 'static' ? (
|
||||
<StyledDisplayModeContainer
|
||||
data-open={draftValue.editingMode === 'edit'}
|
||||
onClick={handleDisplayModeClick}
|
||||
>
|
||||
<VisibilityHidden>Edit</VisibilityHidden>
|
||||
|
||||
{isDefined(selectedOptions) ? (
|
||||
<MultiSelectDisplay
|
||||
values={selectedNames}
|
||||
options={selectedOptions}
|
||||
/>
|
||||
) : null}
|
||||
</StyledDisplayModeContainer>
|
||||
) : (
|
||||
<VariableChip
|
||||
rawVariableName={draftValue.value}
|
||||
onRemove={handleUnlinkVariable}
|
||||
/>
|
||||
)}
|
||||
</FormFieldInputInputContainer>
|
||||
<StyledSelectInputContainer>
|
||||
{draftValue.type === 'static' &&
|
||||
draftValue.editingMode === 'edit' && (
|
||||
<MultiSelectInput
|
||||
hotkeyScope={hotkeyScope}
|
||||
options={options}
|
||||
onCancel={onCancel}
|
||||
onOptionSelected={onOptionSelected}
|
||||
values={draftValue.value}
|
||||
/>
|
||||
)}
|
||||
</StyledSelectInputContainer>
|
||||
|
||||
{VariablePicker && (
|
||||
<VariablePicker
|
||||
inputId={inputId}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
/>
|
||||
)}
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { within } from '@storybook/test';
|
||||
import { FormMultiSelectFieldInput } from '../FormMultiSelectFieldInput';
|
||||
|
||||
const meta: Meta<typeof FormMultiSelectFieldInput> = {
|
||||
title: 'UI/Data/Field/Form/Input/FormMultiSelectFieldInput',
|
||||
component: FormMultiSelectFieldInput,
|
||||
args: {},
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FormMultiSelectFieldInput>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Work Policy',
|
||||
defaultValue: ['WORK_POLICY_1', 'WORK_POLICY_2'],
|
||||
options: [
|
||||
{
|
||||
label: 'Work Policy 1',
|
||||
value: 'WORK_POLICY_1',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
label: 'Work Policy 2',
|
||||
value: 'WORK_POLICY_2',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
label: 'Work Policy 3',
|
||||
value: 'WORK_POLICY_3',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
label: 'Work Policy 4',
|
||||
value: 'WORK_POLICY_4',
|
||||
color: 'yellow',
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Work Policy');
|
||||
await canvas.findByText('Work Policy 1');
|
||||
await canvas.findByText('Work Policy 2');
|
||||
},
|
||||
};
|
||||
@ -1,25 +1,10 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Tag, THEME_COMMON } from 'twenty-ui';
|
||||
import { Tag } from 'twenty-ui';
|
||||
|
||||
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
|
||||
import { useMultiSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useMultiSelectFieldDisplay';
|
||||
import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay';
|
||||
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
|
||||
|
||||
const spacing1 = THEME_COMMON.spacing(1);
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${spacing1};
|
||||
justify-content: flex-start;
|
||||
|
||||
max-width: 100%;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const MultiSelectFieldDisplay = () => {
|
||||
const { fieldValue, fieldDefinition } = useMultiSelectFieldDisplay();
|
||||
|
||||
@ -44,15 +29,9 @@ export const MultiSelectFieldDisplay = () => {
|
||||
))}
|
||||
</ExpandableList>
|
||||
) : (
|
||||
<StyledContainer>
|
||||
{selectedOptions.map((selectedOption, index) => (
|
||||
<Tag
|
||||
preventShrink
|
||||
key={index}
|
||||
color={selectedOption.color}
|
||||
text={selectedOption.label}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
<MultiSelectDisplay
|
||||
values={fieldValue}
|
||||
options={fieldDefinition.metadata.options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,28 +1,5 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
|
||||
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { MenuItemMultiSelectTag } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||
|
||||
const StyledRelationPickerContainer = styled.div`
|
||||
left: -1px;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
`;
|
||||
import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
|
||||
|
||||
type MultiSelectFieldInputProps = {
|
||||
onCancel?: () => void;
|
||||
@ -31,112 +8,16 @@ type MultiSelectFieldInputProps = {
|
||||
export const MultiSelectFieldInput = ({
|
||||
onCancel,
|
||||
}: MultiSelectFieldInputProps) => {
|
||||
const { selectedItemIdState } = useSelectableListStates({
|
||||
selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
|
||||
});
|
||||
const { resetSelectedItem } = useSelectableList(
|
||||
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
|
||||
);
|
||||
const { persistField, fieldDefinition, fieldValues, hotkeyScope } =
|
||||
useMultiSelectField();
|
||||
const selectedItemId = useRecoilValue(selectedItemIdState);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOptions = fieldDefinition.metadata.options.filter((option) =>
|
||||
fieldValues?.includes(option.value),
|
||||
);
|
||||
|
||||
const filteredOptionsInDropDown = fieldDefinition.metadata.options.filter(
|
||||
(option) => option.label.toLowerCase().includes(searchFilter.toLowerCase()),
|
||||
);
|
||||
|
||||
const formatNewSelectedOptions = (value: string) => {
|
||||
const selectedOptionsValues = selectedOptions.map(
|
||||
(selectedOption) => selectedOption.value,
|
||||
);
|
||||
if (!selectedOptionsValues.includes(value)) {
|
||||
return [value, ...selectedOptionsValues];
|
||||
} else {
|
||||
return selectedOptionsValues.filter(
|
||||
(selectedOptionsValue) => selectedOptionsValue !== value,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onCancel?.();
|
||||
resetSelectedItem();
|
||||
},
|
||||
hotkeyScope,
|
||||
[onCancel, resetSelectedItem],
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const weAreNotInAnHTMLInput = !(
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.tagName === 'INPUT'
|
||||
);
|
||||
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
|
||||
onCancel();
|
||||
}
|
||||
resetSelectedItem();
|
||||
},
|
||||
listenerId: 'MultiSelectFieldInput',
|
||||
});
|
||||
|
||||
const optionIds = filteredOptionsInDropDown.map((option) => option.value);
|
||||
|
||||
return (
|
||||
<SelectableList
|
||||
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
|
||||
selectableItemIdArray={optionIds}
|
||||
<MultiSelectInput
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={(itemId) => {
|
||||
const option = filteredOptionsInDropDown.find(
|
||||
(option) => option.value === itemId,
|
||||
);
|
||||
if (isDefined(option)) {
|
||||
persistField(formatNewSelectedOptions(option.value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledRelationPickerContainer ref={containerRef}>
|
||||
<DropdownMenu data-select-disable>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={(event) =>
|
||||
setSearchFilter(
|
||||
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
|
||||
)
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{filteredOptionsInDropDown.map((option) => {
|
||||
return (
|
||||
<MenuItemMultiSelectTag
|
||||
key={option.value}
|
||||
selected={fieldValues?.includes(option.value) || false}
|
||||
text={option.label}
|
||||
color={option.color}
|
||||
onClick={() =>
|
||||
persistField(formatNewSelectedOptions(option.value))
|
||||
}
|
||||
isKeySelected={selectedItemId === option.value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
</StyledRelationPickerContainer>
|
||||
</SelectableList>
|
||||
options={fieldDefinition.metadata.options}
|
||||
onCancel={onCancel}
|
||||
onOptionSelected={persistField}
|
||||
values={fieldValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { Tag, THEME_COMMON } from 'twenty-ui';
|
||||
|
||||
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { SelectOption } from '@/spreadsheet-import/types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const spacing1 = THEME_COMMON.spacing(1);
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${spacing1};
|
||||
justify-content: flex-start;
|
||||
|
||||
max-width: 100%;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const MultiSelectDisplay = ({
|
||||
values,
|
||||
options,
|
||||
}: {
|
||||
values: FieldMultiSelectValue | undefined;
|
||||
options: SelectOption[];
|
||||
}) => {
|
||||
const selectedOptions = values
|
||||
? options?.filter((option) => values.includes(option.value))
|
||||
: [];
|
||||
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{selectedOptions.map((selectedOption, index) => (
|
||||
<Tag
|
||||
preventShrink
|
||||
key={index}
|
||||
color={selectedOption.color ?? 'transparent'}
|
||||
text={selectedOption.label}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,150 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
|
||||
import { SelectOption } from '@/spreadsheet-import/types';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { MenuItemMultiSelectTag } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||
|
||||
const StyledRelationPickerContainer = styled.div`
|
||||
left: -1px;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
`;
|
||||
|
||||
type MultiSelectInputProps = {
|
||||
values: FieldMultiSelectValue;
|
||||
hotkeyScope: string;
|
||||
onCancel?: () => void;
|
||||
options: SelectOption[];
|
||||
onOptionSelected: (value: FieldMultiSelectValue) => void;
|
||||
};
|
||||
|
||||
export const MultiSelectInput = ({
|
||||
values,
|
||||
options,
|
||||
hotkeyScope,
|
||||
onCancel,
|
||||
onOptionSelected,
|
||||
}: MultiSelectInputProps) => {
|
||||
const { selectedItemIdState } = useSelectableListStates({
|
||||
selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
|
||||
});
|
||||
const { resetSelectedItem } = useSelectableList(
|
||||
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
|
||||
);
|
||||
|
||||
const selectedItemId = useRecoilValue(selectedItemIdState);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOptions = options.filter((option) =>
|
||||
values?.includes(option.value),
|
||||
);
|
||||
|
||||
const filteredOptionsInDropDown = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
|
||||
);
|
||||
|
||||
const formatNewSelectedOptions = (value: string) => {
|
||||
const selectedOptionsValues = selectedOptions.map(
|
||||
(selectedOption) => selectedOption.value,
|
||||
);
|
||||
if (!selectedOptionsValues.includes(value)) {
|
||||
return [value, ...selectedOptionsValues];
|
||||
} else {
|
||||
return selectedOptionsValues.filter(
|
||||
(selectedOptionsValue) => selectedOptionsValue !== value,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onCancel?.();
|
||||
resetSelectedItem();
|
||||
},
|
||||
hotkeyScope,
|
||||
[onCancel, resetSelectedItem],
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const weAreNotInAnHTMLInput = !(
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.tagName === 'INPUT'
|
||||
);
|
||||
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
|
||||
onCancel();
|
||||
}
|
||||
resetSelectedItem();
|
||||
},
|
||||
listenerId: 'MultiSelectFieldInput',
|
||||
});
|
||||
|
||||
const optionIds = filteredOptionsInDropDown.map((option) => option.value);
|
||||
|
||||
return (
|
||||
<SelectableList
|
||||
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
|
||||
selectableItemIdArray={optionIds}
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={(itemId) => {
|
||||
const option = filteredOptionsInDropDown.find(
|
||||
(option) => option.value === itemId,
|
||||
);
|
||||
if (isDefined(option)) {
|
||||
onOptionSelected(formatNewSelectedOptions(option.value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledRelationPickerContainer ref={containerRef}>
|
||||
<DropdownMenu data-select-disable>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={(event) =>
|
||||
setSearchFilter(
|
||||
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
|
||||
)
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{filteredOptionsInDropDown.map((option) => {
|
||||
return (
|
||||
<MenuItemMultiSelectTag
|
||||
key={option.value}
|
||||
selected={values?.includes(option.value) || false}
|
||||
text={option.label}
|
||||
color={option.color ?? 'transparent'}
|
||||
onClick={() =>
|
||||
onOptionSelected(formatNewSelectedOptions(option.value))
|
||||
}
|
||||
isKeySelected={selectedItemId === option.value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
</StyledRelationPickerContainer>
|
||||
</SelectableList>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
|
||||
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
||||
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
|
||||
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
|
||||
@ -17,7 +18,6 @@ import {
|
||||
import { JsonValue } from 'type-fest';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||
|
||||
type WorkflowEditActionFormCreateRecordProps = {
|
||||
action: WorkflowCreateRecordAction;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
||||
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
|
||||
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
||||
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
@ -12,9 +12,9 @@ import {
|
||||
useIcons,
|
||||
} from 'twenty-ui';
|
||||
|
||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||
import { JsonValue } from 'type-fest';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||
|
||||
type WorkflowEditActionFormUpdateRecordProps = {
|
||||
action: WorkflowUpdateRecordAction;
|
||||
|
||||
@ -8,5 +8,4 @@ export class WorkflowExecutorException extends CustomException {
|
||||
|
||||
export enum WorkflowExecutorExceptionCode {
|
||||
WORKFLOW_FAILED = 'WORKFLOW_FAILED',
|
||||
VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED',
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ describe('resolveInput', () => {
|
||||
});
|
||||
|
||||
it('should handle non-existent variables', () => {
|
||||
expect(resolveInput('{{user.email}}', context)).toBe('');
|
||||
expect(resolveInput('{{user.email}}', context)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve variables in an array', () => {
|
||||
@ -67,15 +67,11 @@ describe('resolveInput', () => {
|
||||
const expected = {
|
||||
user: {
|
||||
displayName: 'John Doe',
|
||||
preferences: ['dark', 'true'],
|
||||
preferences: ['dark', true],
|
||||
},
|
||||
staticData: [1, 2, 3],
|
||||
};
|
||||
|
||||
expect(resolveInput(input, context)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid expressions', () => {
|
||||
expect(() => resolveInput('{{invalidFunction()}}', context)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,11 +2,6 @@ import { isNil, isString } from '@nestjs/common/utils/shared.utils';
|
||||
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
import {
|
||||
WorkflowExecutorException,
|
||||
WorkflowExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception';
|
||||
|
||||
const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g');
|
||||
|
||||
export const resolveInput = (
|
||||
@ -81,18 +76,22 @@ const resolveString = (
|
||||
});
|
||||
};
|
||||
|
||||
const evalFromContext = (
|
||||
input: string,
|
||||
context: Record<string, unknown>,
|
||||
): string => {
|
||||
const evalFromContext = (input: string, context: Record<string, unknown>) => {
|
||||
try {
|
||||
const inferredInput = Handlebars.compile(input)(context);
|
||||
Handlebars.registerHelper('json', (input: string) => JSON.stringify(input));
|
||||
|
||||
return inferredInput ?? '';
|
||||
const inputWithHelper = input
|
||||
.replace('{{', '{{{ json ')
|
||||
.replace('}}', ' }}}');
|
||||
|
||||
const inferredInput = Handlebars.compile(inputWithHelper)(context, {
|
||||
helpers: {
|
||||
json: (input: string) => JSON.stringify(input),
|
||||
},
|
||||
});
|
||||
|
||||
return JSON.parse(inferredInput) ?? '';
|
||||
} catch (exception) {
|
||||
throw new WorkflowExecutorException(
|
||||
`Failed to evaluate variable ${input}: ${exception}`,
|
||||
WorkflowExecutorExceptionCode.VARIABLE_EVALUATION_FAILED,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@ -11,7 +11,7 @@ type MenuItemMultiSelectTagProps = {
|
||||
className?: string;
|
||||
isKeySelected?: boolean;
|
||||
onClick?: () => void;
|
||||
color: ThemeColor;
|
||||
color: ThemeColor | 'transparent';
|
||||
text: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user