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:
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user