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 { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput';
|
||||||
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
|
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 { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
|
||||||
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
|
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
|
||||||
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
|
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 { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
|
||||||
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
|
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
|
||||||
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
||||||
@ -15,13 +16,15 @@ import {
|
|||||||
FieldFullNameValue,
|
FieldFullNameValue,
|
||||||
FieldLinksValue,
|
FieldLinksValue,
|
||||||
FieldMetadata,
|
FieldMetadata,
|
||||||
|
FieldMultiSelectValue,
|
||||||
} from '@/object-record/record-field/types/FieldMetadata';
|
} from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
|
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
|
||||||
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
|
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 { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
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 { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||||
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||||
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
|
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
|
||||||
@ -107,5 +110,13 @@ export const FormFieldInput = ({
|
|||||||
onPersist={onPersist}
|
onPersist={onPersist}
|
||||||
VariablePicker={VariablePicker}
|
VariablePicker={VariablePicker}
|
||||||
/>
|
/>
|
||||||
|
) : isFieldMultiSelect(field) ? (
|
||||||
|
<FormMultiSelectFieldInput
|
||||||
|
label={field.label}
|
||||||
|
defaultValue={defaultValue as FieldMultiSelectValue | string | undefined}
|
||||||
|
onPersist={onPersist}
|
||||||
|
VariablePicker={VariablePicker}
|
||||||
|
options={field.metadata.options}
|
||||||
|
/>
|
||||||
) : null;
|
) : 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 } from 'twenty-ui';
|
||||||
import { Tag, THEME_COMMON } from 'twenty-ui';
|
|
||||||
|
|
||||||
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
|
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
|
||||||
import { useMultiSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useMultiSelectFieldDisplay';
|
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';
|
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 = () => {
|
export const MultiSelectFieldDisplay = () => {
|
||||||
const { fieldValue, fieldDefinition } = useMultiSelectFieldDisplay();
|
const { fieldValue, fieldDefinition } = useMultiSelectFieldDisplay();
|
||||||
|
|
||||||
@ -44,15 +29,9 @@ export const MultiSelectFieldDisplay = () => {
|
|||||||
))}
|
))}
|
||||||
</ExpandableList>
|
</ExpandableList>
|
||||||
) : (
|
) : (
|
||||||
<StyledContainer>
|
<MultiSelectDisplay
|
||||||
{selectedOptions.map((selectedOption, index) => (
|
values={fieldValue}
|
||||||
<Tag
|
options={fieldDefinition.metadata.options}
|
||||||
preventShrink
|
/>
|
||||||
key={index}
|
|
||||||
color={selectedOption.color}
|
|
||||||
text={selectedOption.label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</StyledContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { 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 { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
|
||||||
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 MultiSelectFieldInputProps = {
|
type MultiSelectFieldInputProps = {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
@ -31,112 +8,16 @@ type MultiSelectFieldInputProps = {
|
|||||||
export const MultiSelectFieldInput = ({
|
export const MultiSelectFieldInput = ({
|
||||||
onCancel,
|
onCancel,
|
||||||
}: MultiSelectFieldInputProps) => {
|
}: 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 } =
|
const { persistField, fieldDefinition, fieldValues, hotkeyScope } =
|
||||||
useMultiSelectField();
|
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 (
|
return (
|
||||||
<SelectableList
|
<MultiSelectInput
|
||||||
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
|
|
||||||
selectableItemIdArray={optionIds}
|
|
||||||
hotkeyScope={hotkeyScope}
|
hotkeyScope={hotkeyScope}
|
||||||
onEnter={(itemId) => {
|
options={fieldDefinition.metadata.options}
|
||||||
const option = filteredOptionsInDropDown.find(
|
onCancel={onCancel}
|
||||||
(option) => option.value === itemId,
|
onOptionSelected={persistField}
|
||||||
);
|
values={fieldValues}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
|
||||||
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
|
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
|
||||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||||
|
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||||
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
||||||
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
|
||||||
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
|
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
|
||||||
@ -17,7 +18,6 @@ import {
|
|||||||
import { JsonValue } from 'type-fest';
|
import { JsonValue } from 'type-fest';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { FieldMetadataType } from '~/generated/graphql';
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
|
||||||
|
|
||||||
type WorkflowEditActionFormCreateRecordProps = {
|
type WorkflowEditActionFormCreateRecordProps = {
|
||||||
action: WorkflowCreateRecordAction;
|
action: WorkflowCreateRecordAction;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||||
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
|
||||||
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
|
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
|
||||||
|
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
|
||||||
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
|
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@ -12,9 +12,9 @@ import {
|
|||||||
useIcons,
|
useIcons,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
|
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
||||||
import { JsonValue } from 'type-fest';
|
import { JsonValue } from 'type-fest';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
|
|
||||||
|
|
||||||
type WorkflowEditActionFormUpdateRecordProps = {
|
type WorkflowEditActionFormUpdateRecordProps = {
|
||||||
action: WorkflowUpdateRecordAction;
|
action: WorkflowUpdateRecordAction;
|
||||||
|
|||||||
@ -8,5 +8,4 @@ export class WorkflowExecutorException extends CustomException {
|
|||||||
|
|
||||||
export enum WorkflowExecutorExceptionCode {
|
export enum WorkflowExecutorExceptionCode {
|
||||||
WORKFLOW_FAILED = 'WORKFLOW_FAILED',
|
WORKFLOW_FAILED = 'WORKFLOW_FAILED',
|
||||||
VARIABLE_EVALUATION_FAILED = 'VARIABLE_EVALUATION_FAILED',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ describe('resolveInput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-existent variables', () => {
|
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', () => {
|
it('should resolve variables in an array', () => {
|
||||||
@ -67,15 +67,11 @@ describe('resolveInput', () => {
|
|||||||
const expected = {
|
const expected = {
|
||||||
user: {
|
user: {
|
||||||
displayName: 'John Doe',
|
displayName: 'John Doe',
|
||||||
preferences: ['dark', 'true'],
|
preferences: ['dark', true],
|
||||||
},
|
},
|
||||||
staticData: [1, 2, 3],
|
staticData: [1, 2, 3],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(resolveInput(input, context)).toEqual(expected);
|
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 Handlebars from 'handlebars';
|
||||||
|
|
||||||
import {
|
|
||||||
WorkflowExecutorException,
|
|
||||||
WorkflowExecutorExceptionCode,
|
|
||||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-executor.exception';
|
|
||||||
|
|
||||||
const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g');
|
const VARIABLE_PATTERN = RegExp('\\{\\{(.*?)\\}\\}', 'g');
|
||||||
|
|
||||||
export const resolveInput = (
|
export const resolveInput = (
|
||||||
@ -81,18 +76,22 @@ const resolveString = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const evalFromContext = (
|
const evalFromContext = (input: string, context: Record<string, unknown>) => {
|
||||||
input: string,
|
|
||||||
context: Record<string, unknown>,
|
|
||||||
): string => {
|
|
||||||
try {
|
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) {
|
} catch (exception) {
|
||||||
throw new WorkflowExecutorException(
|
return undefined;
|
||||||
`Failed to evaluate variable ${input}: ${exception}`,
|
|
||||||
WorkflowExecutorExceptionCode.VARIABLE_EVALUATION_FAILED,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,7 +11,7 @@ type MenuItemMultiSelectTagProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
isKeySelected?: boolean;
|
isKeySelected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
color: ThemeColor;
|
color: ThemeColor | 'transparent';
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user