From 4cc8769d7a0151ed2e19c543ee205af809002f18 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Thu, 31 Oct 2024 16:08:09 +0100 Subject: [PATCH] WIP --- .../components/MultiItemFieldInputV2.tsx | 212 ++++++++++++++++++ .../dropdown/components/DropdownMenuInput.tsx | 2 + 2 files changed, 214 insertions(+) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInputV2.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInputV2.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInputV2.tsx new file mode 100644 index 0000000000..6d2b75e3a0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInputV2.tsx @@ -0,0 +1,212 @@ +import React, { useRef, useState } from 'react'; +import { Key } from 'ts-key-enum'; +import { IconCheck, IconPlus, LightIconButton } from 'twenty-ui'; + +import { PhoneRecord } from '@/object-record/record-field/types/FieldMetadata'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { + DropdownMenuInput, + DropdownMenuInputProps, +} from '@/ui/layout/dropdown/components/DropdownMenuInput'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { moveArrayItem } from '~/utils/array/moveArrayItem'; +import { toSpliced } from '~/utils/array/toSpliced'; +import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; + +type MultiItemFieldInputV2Props = { + items: T[]; + onPersist: (updatedItems: T[]) => void; + onCancel?: () => void; + placeholder: string; + validateInput?: (input: string) => { isValid: boolean; errorMessage: string }; + formatInput?: (input: string) => T; + renderItem: (props: { + value: T; + index: number; + handleEdit: () => void; + handleSetPrimary: () => void; + handleDelete: () => void; + }) => React.ReactNode; + hotkeyScope: string; + newItemLabel?: string; + fieldMetadataType: FieldMetadataType; + renderInput?: DropdownMenuInputProps['renderInput']; +}; + +// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ... +// This should be refactored with a hook instead that exposes those events in a context around this component and its children. +export const MultiItemFieldInputV2 = ({ + items, + onPersist, + onCancel, + placeholder, + validateInput, + formatInput, + renderItem, + hotkeyScope, + newItemLabel, + fieldMetadataType, + renderInput, +}: MultiItemFieldInputV2Props) => { + const containerRef = useRef(null); + const handleDropdownClose = () => { + onCancel?.(); + }; + + useListenClickOutside({ + refs: [containerRef], + callback: handleDropdownClose, + }); + + useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope); + + const [isInputDisplayed, setIsInputDisplayed] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [itemToEditIndex, setItemToEditIndex] = useState(-1); + const [errorData, setErrorData] = useState({ + isValid: true, + errorMessage: '', + }); + const isAddingNewItem = itemToEditIndex === -1; + + const handleOnChange = (value: string) => { + setInputValue(value); + if (!validateInput) return; + + setErrorData( + errorData.isValid ? errorData : { isValid: true, errorMessage: '' }, + ); + }; + + const handleAddButtonClick = () => { + setItemToEditIndex(-1); + setIsInputDisplayed(true); + }; + + // Ugly + const handleEditButtonClick = (index: number) => { + let item; + switch (fieldMetadataType) { + case FieldMetadataType.Links: + item = items[index] as { label: string; url: string }; + setInputValue(item.url || ''); + break; + case FieldMetadataType.Phones: + item = items[index] as PhoneRecord; + setInputValue(item.countryCode + item.number); + break; + case FieldMetadataType.Emails: + item = items[index] as string; + setInputValue(item); + break; + default: + throw new Error(`Unsupported field type: ${fieldMetadataType}`); + } + + setItemToEditIndex(index); + setIsInputDisplayed(true); + }; + + const handleSubmitInput = () => { + if (validateInput !== undefined) { + const validationData = validateInput(inputValue) ?? { isValid: true }; + if (!validationData.isValid) { + setErrorData(validationData); + return; + } + } + + const newItem = formatInput + ? formatInput(inputValue) + : (inputValue as unknown as T); + + if (!isAddingNewItem && newItem === items[itemToEditIndex]) { + setIsInputDisplayed(false); + setInputValue(''); + return; + } + + const updatedItems = isAddingNewItem + ? [...items, newItem] + : toSpliced(items, itemToEditIndex, 1, newItem); + + onPersist(updatedItems); + setIsInputDisplayed(false); + setInputValue(''); + }; + + const handleSetPrimaryItem = (index: number) => { + const updatedItems = moveArrayItem(items, { fromIndex: index, toIndex: 0 }); + onPersist(updatedItems); + }; + + const handleDeleteItem = (index: number) => { + const updatedItems = toSpliced(items, index, 1); + onPersist(updatedItems); + }; + + return ( + + {!!items.length && ( + <> + + {items.map((item, index) => + renderItem({ + value: item, + index, + handleEdit: () => handleEditButtonClick(index), + handleSetPrimary: () => handleSetPrimaryItem(index), + handleDelete: () => handleDeleteItem(index), + }), + )} + + + + )} + {isInputDisplayed || !items.length ? ( + + renderInput({ + ...props, + onChange: (newValue) => + setInputValue(newValue as unknown as string), + }) + : undefined + } + onChange={(event) => + handleOnChange( + turnIntoEmptyStringIfWhitespacesOnly(event.target.value), + ) + } + onEnter={handleSubmitInput} + rightComponent={ + + } + /> + ) : ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx index 5b985fab33..5885a19f70 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx @@ -77,6 +77,8 @@ export type DropdownMenuInputProps = HTMLInputProps & { hasError?: boolean; }; +// TODO: refactor this +/** @deprecated */ export const DropdownMenuInput = forwardRef< HTMLInputElement, DropdownMenuInputProps