From 8208a3e976bc499b0046bb1548f2e74156729167 Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:07:55 +0200 Subject: [PATCH] Introduce ARRAY field type (#6862) This PR was created by \[GitStart\]() to address the requirements from this ticket: \[TWNTY-6447\](). This ticket was imported from: ### Description \- We added a new field type ### Refs #6447 ### Demo Fixes #6447 --------- Co-authored-by: gitstart-twenty Co-authored-by: Charles Bochet --- .../src/generated-metadata/graphql.ts | 3 +- .../twenty-front/src/generated/graphql.tsx | 1 + ...atFieldMetadataItemsAsFilterDefinitions.ts | 2 + .../utils/mapFieldMetadataToGraphQLQuery.ts | 1 + .../MultipleFiltersDropdownContent.tsx | 1 + .../types/FilterType.ts | 3 +- .../utils/getOperandsForFilterType.ts | 1 + .../record-field/components/FieldDisplay.tsx | 4 ++ .../record-field/components/FieldInput.tsx | 4 ++ .../record-field/hooks/usePersistField.ts | 8 ++- .../display/components/ArrayFieldDisplay.tsx | 34 ++++++++++ .../meta-types/hooks/useArrayField.ts | 44 +++++++++++++ .../meta-types/hooks/useArrayFieldDisplay.ts | 24 +++++++ .../input/components/ArrayFieldInput.tsx | 39 ++++++++++++ .../input/components/ArrayFieldMenuItem.tsx | 27 ++++++++ .../input/components/MultiItemFieldInput.tsx | 5 +- .../components/MultiItemFieldMenuItem.tsx | 4 +- .../record-field/types/FieldMetadata.ts | 11 +++- .../types/guards/assertFieldMetadata.ts | 37 ++++++----- .../record-field/types/guards/isFieldArray.ts | 9 +++ .../types/guards/isFieldArrayValue.ts | 8 +++ .../record-field/utils/getFieldButtonIcon.tsx | 2 + .../record-field/utils/isFieldValueEmpty.ts | 5 +- .../utils/generateEmptyFieldValue.ts | 3 + .../constants/SettingsFieldTypeConfigs.ts | 7 +++ ...SettingsDataModelFieldSettingsFormCard.tsx | 1 + .../field/display/components/ArrayDisplay.tsx | 62 +++++++++++++++++++ .../link/components/RoundedLink.tsx | 2 +- .../__mocks__/object-metadata-item.mock.ts | 8 +++ .../input/array-filter.input-type.ts | 10 +++ .../graphql-types/input/index.ts | 1 + .../services/type-mapper.service.ts | 10 ++- ...p-field-metadata-to-graphql-query.utils.ts | 1 + .../utils/__tests__/components.utils.spec.ts | 18 ++++++ .../open-api/utils/components.utils.ts | 10 ++- .../field-metadata/field-metadata.entity.ts | 1 + .../factories/basic-column-action.factory.ts | 6 +- ...field-metadata-type-to-column-type.util.ts | 1 + .../workspace-migration.factory.ts | 1 + .../display/icon/components/TablerIcons.ts | 1 + 40 files changed, 392 insertions(+), 28 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ArrayFieldDisplay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayFieldDisplay.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArray.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArrayValue.ts create mode 100644 packages/twenty-front/src/modules/ui/field/display/components/ArrayDisplay.tsx create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 18ef62e76..23d7caab7 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -376,7 +376,8 @@ export enum FieldMetadataType { RichText = 'RICH_TEXT', Select = 'SELECT', Text = 'TEXT', - Uuid = 'UUID' + Uuid = 'UUID', + Array = 'ARRAY' } export enum FileFolder { diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index b96a53cbf..88b52322e 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -260,6 +260,7 @@ export type FieldConnection = { export enum FieldMetadataType { Actor = 'ACTOR', Address = 'ADDRESS', + Array = 'ARRAY', Boolean = 'BOOLEAN', Currency = 'CURRENCY', Date = 'DATE', diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index ea5adb09c..7ebeb0afe 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -98,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => { return 'RATING'; case FieldMetadataType.Actor: return 'ACTOR'; + case FieldMetadataType.Array: + return 'ARRAY'; default: return 'TEXT'; } diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index 11da8c73d..d379313a0 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -38,6 +38,7 @@ export const mapFieldMetadataToGraphQLQuery = ({ FieldMetadataType.Position, FieldMetadataType.RawJson, FieldMetadataType.RichText, + FieldMetadataType.Array, ].includes(fieldType); if (fieldIsSimpleValue) { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index a735dc7fe..ec3df12f0 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({ 'LINKS', 'ADDRESS', 'ACTOR', + 'ARRAY', 'PHONES', ].includes(filterDefinitionUsedInDropdown.type) && ( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts index bc1c0489d..1803a88a5 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts @@ -16,4 +16,5 @@ export type FilterType = | 'SELECT' | 'RATING' | 'MULTI_SELECT' - | 'ACTOR'; + | 'ACTOR' + | 'ARRAY'; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 5b10825b4..f75dca40f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -22,6 +22,7 @@ export const getOperandsForFilterType = ( case 'LINK': case 'LINKS': case 'ACTOR': + case 'ARRAY': case 'PHONES': return [ ViewFilterOperand.Contains, diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 3f9297833..781ac98a6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -1,6 +1,7 @@ import { useContext } from 'react'; import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay'; +import { ArrayFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ArrayFieldDisplay'; import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay'; import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; @@ -10,6 +11,7 @@ import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta- import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay'; import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay'; import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails'; @@ -104,6 +106,8 @@ export const FieldDisplay = () => { ) : isFieldActor(fieldDefinition) ? ( + ) : isFieldArray(fieldDefinition) ? ( + ) : isFieldEmails(fieldDefinition) ? ( ) : isFieldPhones(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index 4e1537106..144a24a8b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -24,7 +24,9 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput'; import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; import { FieldContext } from '../contexts/FieldContext'; import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput'; @@ -187,6 +189,8 @@ export const FieldInput = ({ /> ) : isFieldRichText(fieldDefinition) ? ( + ) : isFieldArray(fieldDefinition) ? ( + ) : ( <> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index 0d3344e12..0aa155a86 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -26,6 +26,8 @@ import { isFieldSelectValue } from '@/object-record/record-field/types/guards/is import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; +import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue'; import { FieldContext } from '../contexts/FieldContext'; import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue'; @@ -124,6 +126,9 @@ export const usePersistField = () => { isFieldRawJson(fieldDefinition) && isFieldRawJsonValue(valueToPersist); + const fieldIsArray = + isFieldArray(fieldDefinition) && isFieldArrayValue(valueToPersist); + const isValuePersistable = fieldIsRelationToOneObject || fieldIsText || @@ -143,7 +148,8 @@ export const usePersistField = () => { fieldIsSelect || fieldIsMultiSelect || fieldIsAddress || - fieldIsRawJson; + fieldIsRawJson || + fieldIsArray; if (isValuePersistable) { const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ArrayFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ArrayFieldDisplay.tsx new file mode 100644 index 000000000..195d3bb87 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ArrayFieldDisplay.tsx @@ -0,0 +1,34 @@ +import { THEME_COMMON } from 'twenty-ui'; + +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; +import { useArrayFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useArrayFieldDisplay'; +import { ArrayDisplay } from '@/ui/field/display/components/ArrayDisplay'; +import styled from '@emotion/styled'; + +const spacing1 = THEME_COMMON.spacing(1); + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-wrap: wrap; + gap: ${spacing1}; + justify-content: flex-start; + max-width: 100%; + overflow: hidden; +`; + +export const ArrayFieldDisplay = () => { + const { fieldValue } = useArrayFieldDisplay(); + + const { isFocused } = useFieldFocus(); + + if (!Array.isArray(fieldValue)) { + return <>; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts new file mode 100644 index 000000000..7178d7aff --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts @@ -0,0 +1,44 @@ +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { FieldArrayValue } from '@/object-record/record-field/types/FieldMetadata'; +import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; +import { arraySchema } from '@/object-record/record-field/types/guards/isFieldArrayValue'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { useContext } from 'react'; +import { useRecoilState } from 'recoil'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useArrayField = () => { + const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata(FieldMetadataType.Array, isFieldArray, fieldDefinition); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId, + fieldName, + }), + ); + + const persistField = usePersistField(); + + const persistArrayField = (nextValue: string[]) => { + if (!nextValue) persistField(null); + + try { + persistField(arraySchema.parse(nextValue)); + } catch { + return; + } + }; + + return { + fieldValue, + setFieldValue, + persistArrayField, + hotkeyScope, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayFieldDisplay.ts new file mode 100644 index 000000000..e735febeb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayFieldDisplay.ts @@ -0,0 +1,24 @@ +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; +import { + FieldArrayMetadata, + FieldArrayValue, +} from '@/object-record/record-field/types/FieldMetadata'; +import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; +import { useContext } from 'react'; + +export const useArrayFieldDisplay = () => { + const { recordId, fieldDefinition } = useContext(FieldContext); + + const { fieldName } = fieldDefinition.metadata; + + const fieldValue = useRecordFieldValue( + recordId, + fieldName, + ); + + return { + fieldDefinition: fieldDefinition as FieldDefinition, + fieldValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx new file mode 100644 index 000000000..2353f7d2f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx @@ -0,0 +1,39 @@ +import { useArrayField } from '@/object-record/record-field/meta-types/hooks/useArrayField'; +import { ArrayFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem'; +import { MultiItemFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiItemFieldInput'; +import { useMemo } from 'react'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +type ArrayFieldInputProps = { + onCancel?: () => void; +}; + +export const ArrayFieldInput = ({ onCancel }: ArrayFieldInputProps) => { + const { persistArrayField, hotkeyScope, fieldValue } = useArrayField(); + + const arrayItems = useMemo>( + () => (Array.isArray(fieldValue) ? fieldValue : []), + [fieldValue], + ); + + return ( + ( + + )} + > + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem.tsx new file mode 100644 index 000000000..cfe06add3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem.tsx @@ -0,0 +1,27 @@ +import { MultiItemFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem'; +import { ArrayDisplay } from '@/ui/field/display/components/ArrayDisplay'; + +type ArrayFieldMenuItemProps = { + dropdownId: string; + onEdit?: () => void; + onDelete?: () => void; + value: string; +}; + +export const ArrayFieldMenuItem = ({ + dropdownId, + onEdit, + onDelete, + value, +}: ArrayFieldMenuItemProps) => { + return ( + } + hasPrimaryButton={false} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx index 4a5bcb5c7..a73e49498 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx @@ -40,10 +40,12 @@ type MultiItemFieldInputProps = { 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, ... export const MultiItemFieldInput = ({ items, onPersist, @@ -53,6 +55,7 @@ export const MultiItemFieldInput = ({ formatInput, renderItem, hotkeyScope, + newItemLabel, fieldMetadataType, renderInput, }: MultiItemFieldInputProps) => { @@ -181,7 +184,7 @@ export const MultiItemFieldInput = ({ )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx index 95fcf4e5a..383b47121 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx @@ -21,6 +21,7 @@ type MultiItemFieldMenuItemProps = { onSetAsPrimary?: () => void; onDelete?: () => void; DisplayComponent: React.ComponentType<{ value: T }>; + hasPrimaryButton?: boolean; }; const StyledIconBookmark = styled(IconBookmark)` @@ -37,6 +38,7 @@ export const MultiItemFieldMenuItem = ({ onSetAsPrimary, onDelete, DisplayComponent, + hasPrimaryButton = true, }: MultiItemFieldMenuItemProps) => { const [isHovered, setIsHovered] = useState(false); const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId); @@ -74,7 +76,7 @@ export const MultiItemFieldMenuItem = ({ clickableComponent={iconButton} dropdownComponents={ - {!isPrimary && ( + {hasPrimaryButton && !isPrimary && ( ( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArray.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArray.ts new file mode 100644 index 000000000..c179d2514 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArray.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldArrayMetadata, FieldMetadata } from '../FieldMetadata'; + +export const isFieldArray = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.Array; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArrayValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArrayValue.ts new file mode 100644 index 000000000..8be30f0e2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldArrayValue.ts @@ -0,0 +1,8 @@ +import { FieldArrayValue } from '@/object-record/record-field/types/FieldMetadata'; +import { z } from 'zod'; + +export const arraySchema = z.union([z.null(), z.array(z.string())]); + +export const isFieldArrayValue = ( + fieldValue: unknown, +): fieldValue is FieldArrayValue => arraySchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx index 6a02b6ae1..5743a74cb 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx @@ -10,6 +10,7 @@ import { isFieldPhones } from '@/object-record/record-field/types/guards/isField import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldLink } from '../types/guards/isFieldLink'; import { isFieldPhone } from '../types/guards/isFieldPhone'; @@ -33,6 +34,7 @@ export const getFieldButtonIcon = ( 'workspaceMember') || isFieldLinks(fieldDefinition) || isFieldEmails(fieldDefinition) || + isFieldArray(fieldDefinition) || isFieldPhones(fieldDefinition) ) { return IconPencil; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 0c366d1ef..0323ef19e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -6,6 +6,8 @@ import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldA import { isFieldActorValue } from '@/object-record/record-field/types/guards/isFieldActorValue'; import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; +import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; +import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; @@ -76,8 +78,9 @@ export const isFieldValueEmpty = ({ ); } - if (isFieldMultiSelect(fieldDefinition)) { + if (isFieldMultiSelect(fieldDefinition) || isFieldArray(fieldDefinition)) { return ( + !isFieldArrayValue(fieldValue) || !isFieldMultiSelectValue(fieldValue, selectOptionValues) || !isDefined(fieldValue) ); diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index bd07d173c..a0a69349f 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -83,6 +83,9 @@ export const generateEmptyFieldValue = ( case FieldMetadataType.MultiSelect: { return null; } + case FieldMetadataType.Array: { + return null; + } case FieldMetadataType.RawJson: { return null; } diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts index a6288fbe8..e604e2e34 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts @@ -1,4 +1,5 @@ import { + IconBracketsContain, IconComponent, IllustrationIconCalendarEvent, IllustrationIconCalendarTime, @@ -183,6 +184,12 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = { Icon: IllustrationIconSetting, category: 'Basic', }, + [FieldMetadataType.Array]: { + label: 'Array', + Icon: IconBracketsContain, + category: 'Basic', + exampleValue: ['value1', 'value2'], + }, } as const satisfies Record< SettingsSupportedFieldType, SettingsFieldTypeConfig diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index a39e2f603..139a1036a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -80,6 +80,7 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` `; const previewableTypes = [ + FieldMetadataType.Array, FieldMetadataType.Address, FieldMetadataType.Boolean, FieldMetadataType.Currency, diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ArrayDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ArrayDisplay.tsx new file mode 100644 index 000000000..4985a0a9a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/ArrayDisplay.tsx @@ -0,0 +1,62 @@ +import { + BORDER_COMMON, + OverflowingTextWithTooltip, + THEME_COMMON, +} from 'twenty-ui'; + +import { FieldArrayValue } from '@/object-record/record-field/types/FieldMetadata'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; +import styled from '@emotion/styled'; + +type ArrayDisplayProps = { + value: FieldArrayValue; + isFocused?: boolean; + isInputDisplay?: boolean; +}; + +const themeSpacing = THEME_COMMON.spacingMultiplicator; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeSpacing * 1}px; + justify-content: flex-start; + + max-width: 100%; + + overflow: hidden; + + width: 100%; +`; + +const StyledTag = styled.div<{ isInputDisplay?: boolean }>` + background-color: ${({ theme, isInputDisplay }) => + isInputDisplay ? 'transparent' : theme.background.tertiary}; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; + border-radius: ${BORDER_COMMON.radius.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; +`; + +export const ArrayDisplay = ({ + value, + isFocused, + isInputDisplay = false, +}: ArrayDisplayProps) => { + return isFocused ? ( + + {value?.map((item, index) => ( + + + + ))} + + ) : ( + + {value?.map((item, index) => ( + + + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx index 61480f17f..4db0e8dd2 100644 --- a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx @@ -1,6 +1,6 @@ -import { MouseEvent, useContext } from 'react'; import { styled } from '@linaria/react'; import { isNonEmptyString } from '@sniptt/guards'; +import { MouseEvent, useContext } from 'react'; import { FONT_COMMON, THEME_COMMON, ThemeContext } from 'twenty-ui'; type RoundedLinkProps = { diff --git a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts index 0a33b2e26..337cdda3e 100644 --- a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts +++ b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts @@ -230,6 +230,13 @@ const fieldEmailsMock = { defaultValue: [{ primaryEmail: '', additionalEmails: {} }], }; +const fieldArrayMock = { + name: 'fieldArray', + type: FieldMetadataType.ARRAY, + isNullable: true, + defaultValue: null, +}; + const fieldPhonesMock = { name: FIELD_PHONES_MOCK_NAME, type: FieldMetadataType.PHONES, @@ -267,6 +274,7 @@ export const fields = [ fieldRawJsonMock, fieldRichTextMock, fieldActorMock, + fieldArrayMock, ]; export const objectMetadataItemMock = { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts new file mode 100644 index 000000000..37b3ba829 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts @@ -0,0 +1,10 @@ +import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql'; + +export const ArrayFilterType = new GraphQLInputObjectType({ + name: 'ArrayFilter', + fields: { + contains: { type: new GraphQLList(GraphQLString) }, + contains_any: { type: new GraphQLList(GraphQLString) }, + not_contains: { type: new GraphQLList(GraphQLString) }, + }, +}); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/index.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/index.ts index 1d1f6f51f..cb0e63832 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/index.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/index.ts @@ -1,3 +1,4 @@ +export * from './array-filter.input-type'; export * from './big-float-filter.input-type'; export * from './big-int-filter.input-type'; export * from './boolean-filter.input-type'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 9656430d9..fb6597e19 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -18,6 +18,7 @@ import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadat import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum'; import { + ArrayFilterType, BigFloatFilterType, BooleanFilterType, DateFilterType, @@ -45,6 +46,8 @@ export interface TypeOptions { isIdField?: boolean; } +const StringArrayScalarType = new GraphQLList(GraphQLString); + @Injectable() export class TypeMapperService { mapToScalarType( @@ -55,7 +58,6 @@ export class TypeMapperService { if (isIdField || settings?.isForeignKey) { return GraphQLID; } - const typeScalarMapping = new Map([ [FieldMetadataType.UUID, UUIDScalarType], [FieldMetadataType.TEXT, GraphQLString], @@ -74,6 +76,10 @@ export class TypeMapperService { [FieldMetadataType.NUMERIC, BigFloatScalarType], [FieldMetadataType.POSITION, PositionScalarType], [FieldMetadataType.RAW_JSON, RawJSONScalar], + [ + FieldMetadataType.ARRAY, + StringArrayScalarType as unknown as GraphQLScalarType, + ], [FieldMetadataType.RICH_TEXT, GraphQLString], ]); @@ -111,6 +117,7 @@ export class TypeMapperService { [FieldMetadataType.POSITION, FloatFilterType], [FieldMetadataType.RAW_JSON, RawJsonFilterType], [FieldMetadataType.RICH_TEXT, StringFilterType], + [FieldMetadataType.ARRAY, ArrayFilterType], ]); return typeFilterMapping.get(fieldMetadataType); @@ -135,6 +142,7 @@ export class TypeMapperService { [FieldMetadataType.POSITION, OrderByDirectionType], [FieldMetadataType.RAW_JSON, OrderByDirectionType], [FieldMetadataType.RICH_TEXT, OrderByDirectionType], + [FieldMetadataType.ARRAY, OrderByDirectionType], ]); return typeOrderByMapping.get(fieldMetadataType); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index 0a7b879c6..6976586f5 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -31,6 +31,7 @@ export const mapFieldMetadataToGraphqlQuery = ( FieldMetadataType.POSITION, FieldMetadataType.RAW_JSON, FieldMetadataType.RICH_TEXT, + FieldMetadataType.ARRAY, ].includes(fieldType); if (fieldIsSimpleValue) { diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index 7a9f939bb..5c885c312 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -70,6 +70,12 @@ describe('computeSchemaComponents', () => { type: 'string', format: 'date', }, + fieldArray: { + items: { + type: 'string', + }, + type: 'array', + }, fieldBoolean: { type: 'boolean', }, @@ -246,6 +252,12 @@ describe('computeSchemaComponents', () => { type: 'string', format: 'date', }, + fieldArray: { + items: { + type: 'string', + }, + type: 'array', + }, fieldBoolean: { type: 'boolean', }, @@ -421,6 +433,12 @@ describe('computeSchemaComponents', () => { type: 'string', format: 'date', }, + fieldArray: { + items: { + type: 'string', + }, + type: 'array', + }, fieldBoolean: { type: 'boolean', }, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index 6841475c3..7e34e35a3 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -17,8 +17,8 @@ import { FieldMetadataType, } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { capitalize } from 'src/utils/capitalize'; import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { capitalize } from 'src/utils/capitalize'; type Property = OpenAPIV3_1.SchemaObject; @@ -124,6 +124,14 @@ const getSchemaComponentsProperties = ({ enum: field.options.map((option: { value: string }) => option.value), }; break; + case FieldMetadataType.ARRAY: + itemProperty = { + type: 'array', + items: { + type: 'string', + }, + }; + break; case FieldMetadataType.RATING: itemProperty = { type: 'string', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index a35b98815..3f5413aeb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -46,6 +46,7 @@ export enum FieldMetadataType { RAW_JSON = 'RAW_JSON', RICH_TEXT = 'RICH_TEXT', ACTOR = 'ACTOR', + ARRAY = 'ARRAY', } @Entity('fieldMetadata') diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory.ts index f44115669..643819ddc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory.ts @@ -29,7 +29,8 @@ export type BasicFieldMetadataType = | FieldMetadataType.POSITION | FieldMetadataType.DATE_TIME | FieldMetadataType.DATE - | FieldMetadataType.POSITION; + | FieldMetadataType.POSITION + | FieldMetadataType.ARRAY; @Injectable() export class BasicColumnActionFactory extends ColumnActionAbstractFactory { @@ -48,6 +49,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory( return 'uuid'; case FieldMetadataType.TEXT: case FieldMetadataType.RICH_TEXT: + case FieldMetadataType.ARRAY: return 'text'; case FieldMetadataType.PHONE: case FieldMetadataType.EMAIL: diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index 0ba0ee710..6f33cf013 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -97,6 +97,7 @@ export class WorkspaceMigrationFactory { ], [FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }], [FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }], + [FieldMetadataType.ARRAY, { factory: this.basicColumnActionFactory }], [ FieldMetadataType.EMAILS, { factory: this.compositeColumnActionFactory }, diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 2cb746951..190275cf9 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -22,6 +22,7 @@ export { IconBookmark, IconBookmarkPlus, IconBox, + IconBracketsContain, IconBrandGithub, IconBrandGoogle, IconBrandLinkedin,