From a7fcc5d47ec64c1cadc4b006c30bb03cf4d2bb8c Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 11 Apr 2024 12:57:08 +0200 Subject: [PATCH] 4778 multi select field front implement multi select type (#4887) --- .../src/modules/auth/services/AuthService.ts | 11 - .../hooks/useFieldMetadataItem.ts | 35 ++-- .../formatFieldMetadataItemInput.test.ts | 67 ++++++ .../utils/formatFieldMetadataItemInput.ts | 39 ++-- ...atFieldMetadataItemsAsFilterDefinitions.ts | 3 + .../utils/formatRelationMetadataInput.ts | 4 +- .../utils/mapFieldMetadataToGraphQLQuery.ts | 1 + .../cache/utils/getRecordFromRecordNode.ts | 18 +- .../cache/utils/getRecordNodeFromRecord.ts | 19 +- .../types/FilterType.ts | 3 +- .../record-field/components/FieldDisplay.tsx | 9 +- .../record-field/components/FieldInput.tsx | 4 + .../record-field/hooks/usePersistField.ts | 14 +- .../components/MultiSelectFieldDisplay.tsx | 32 +++ .../meta-types/hooks/useMultiSelectField.ts | 50 +++++ .../components/MultiSelectFieldInput.tsx | 93 +++++++++ .../types/FieldInputDraftValue.ts | 14 +- .../record-field/types/FieldMetadata.ts | 8 + .../types/guards/assertFieldMetadata.ts | 45 ++-- .../types/guards/isFieldMultiSelect.ts | 11 + .../types/guards/isFieldMultiSelectValue.ts | 9 + .../record-field/utils/isFieldValueEmpty.ts | 9 + .../multiSelectFieldValueSchema.ts | 10 + .../utils/isRecordMatchingFilter.ts | 1 + .../utils/generateEmptyFieldValue.ts | 2 +- .../SettingsObjectFieldSelectForm.tsx | 54 +++-- .../constants/SettingsFieldTypeConfigs.ts | 5 +- ...SettingsDataModelFieldSettingsFormCard.tsx | 16 +- ...DataModelFieldSettingsFormCard.stories.tsx | 1 + .../__tests__/useFieldMetadataForm.test.ts | 3 + .../forms/hooks/useFieldMetadataForm.ts | 41 +++- .../utils/getFieldDefaultPreviewValue.ts | 10 +- .../components/ShowPageLeftContainer.tsx | 1 + .../components/MenuItemMultiSelectTag.tsx | 41 ++++ .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../data-model/SettingsObjectFieldEdit.tsx | 23 ++- .../SettingsObjectNewFieldStep2.tsx | 20 +- .../src/database/typeorm/typeorm.service.ts | 16 -- .../field-metadata/field-metadata.service.ts | 8 +- .../workspace-migration.entity.ts | 4 +- .../workspace-migration-enum.service.ts | 194 ++++++++---------- .../display/icon/components/TablerIcons.ts | 1 + 42 files changed, 698 insertions(+), 254 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts create mode 100644 packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx diff --git a/packages/twenty-front/src/modules/auth/services/AuthService.ts b/packages/twenty-front/src/modules/auth/services/AuthService.ts index 27a59a2e4..c4c8f1f43 100644 --- a/packages/twenty-front/src/modules/auth/services/AuthService.ts +++ b/packages/twenty-front/src/modules/auth/services/AuthService.ts @@ -18,12 +18,6 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; const logger = loggerLink(() => 'Twenty-Refresh'); -/** - * Renew token mutation with custom apollo client - * @param uri string | UriFunction | undefined - * @param refreshToken string - * @returns RenewTokenMutation - */ const renewTokenMutation = async ( uri: string | UriFunction | undefined, refreshToken: string, @@ -54,11 +48,6 @@ const renewTokenMutation = async ( return data; }; -/** - * Renew token and update cookie storage - * @param uri string | UriFunction | undefined - * @returns TokenPair - */ export const renewToken = async ( uri: string | UriFunction | undefined, tokenPair: AuthTokenPair | undefined | null, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts index c33c1b288..f8a61900f 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts @@ -1,10 +1,9 @@ import { v4 } from 'uuid'; +import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption.ts'; import { Field } from '~/generated/graphql'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataItem } from '../types/FieldMetadataItem'; -import { FieldMetadataOption } from '../types/FieldMetadataOption'; import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput'; import { useCreateOneFieldMetadataItem } from './useCreateOneFieldMetadataItem'; @@ -17,22 +16,22 @@ export const useFieldMetadataItem = () => { const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem(); const createMetadataField = ( - input: Pick & { - defaultValue?: unknown; + input: Pick< + Field, + 'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options' + > & { objectMetadataId: string; - options?: Omit[]; - type: FieldMetadataType; }, ) => { - const formatedInput = formatFieldMetadataItemInput(input); + const formattedInput = formatFieldMetadataItemInput(input); const defaultValue = input.defaultValue ? typeof input.defaultValue == 'string' ? `'${input.defaultValue}'` : input.defaultValue - : formatedInput.defaultValue ?? undefined; + : formattedInput.defaultValue ?? undefined; return createOneFieldMetadataItem({ - ...formatedInput, + ...formattedInput, defaultValue, objectMetadataId: input.objectMetadataId, type: input.type, @@ -42,17 +41,21 @@ export const useFieldMetadataItem = () => { const editMetadataField = ( input: Pick< Field, - 'id' | 'label' | 'icon' | 'description' | 'defaultValue' - > & { - options?: FieldMetadataOption[]; - }, + | 'id' + | 'label' + | 'icon' + | 'description' + | 'defaultValue' + | 'type' + | 'options' + >, ) => { - const formatedInput = formatFieldMetadataItemInput(input); + const formattedInput = formatFieldMetadataItemInput(input); const defaultValue = input.defaultValue ? typeof input.defaultValue == 'string' ? `'${input.defaultValue}'` : input.defaultValue - : formatedInput.defaultValue ?? undefined; + : formattedInput.defaultValue ?? undefined; return updateOneFieldMetadataItem({ fieldMetadataIdToUpdate: input.id, @@ -61,7 +64,7 @@ export const useFieldMetadataItem = () => { defaultValue, // In Edit mode, all options need an id, // so we generate an id for newly created options. - options: input.options?.map((option) => + options: input.options?.map((option: FieldMetadataOption) => option.id ? option : { ...option, id: v4() }, ), }), diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts index 301a8e8e5..a85741c63 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts @@ -1,3 +1,5 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { formatFieldMetadataItemInput, getOptionValueFromLabel, @@ -46,6 +48,7 @@ describe('formatFieldMetadataItemInput', () => { const input = { label: 'Example Label', icon: 'example-icon', + type: FieldMetadataType.Select, description: 'Example description', options: [ { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, @@ -86,6 +89,70 @@ describe('formatFieldMetadataItemInput', () => { const input = { label: 'Example Label', icon: 'example-icon', + type: FieldMetadataType.Select, + description: 'Example description', + }; + + const expected = { + description: 'Example description', + icon: 'example-icon', + label: 'Example Label', + name: 'exampleLabel', + options: undefined, + defaultValue: undefined, + }; + + const result = formatFieldMetadataItemInput(input); + + expect(result).toEqual(expected); + }); + + it('should format the field metadata item multi select input correctly', () => { + const input = { + label: 'Example Label', + icon: 'example-icon', + type: FieldMetadataType.MultiSelect, + description: 'Example description', + options: [ + { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, + { id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true }, + ], + }; + + const expected = { + description: 'Example description', + icon: 'example-icon', + label: 'Example Label', + name: 'exampleLabel', + options: [ + { + id: '1', + label: 'Option 1', + color: 'red', + position: 0, + value: 'OPTION_1', + }, + { + id: '2', + label: 'Option 2', + color: 'blue', + position: 1, + value: 'OPTION_2', + }, + ], + defaultValue: ["'OPTION_1'", "'OPTION_2'"], + }; + + const result = formatFieldMetadataItemInput(input); + + expect(result).toEqual(expected); + }); + + it('should handle multi select input without options', () => { + const input = { + label: 'Example Label', + icon: 'example-icon', + type: FieldMetadataType.MultiSelect, description: 'Example description', }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts index 3107b96e4..26f2def0b 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts @@ -1,7 +1,8 @@ import toCamelCase from 'lodash.camelcase'; import toSnakeCase from 'lodash.snakecase'; -import { Field } from '~/generated-metadata/graphql'; +import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined.ts'; import { FieldMetadataOption } from '../types/FieldMetadataOption'; @@ -20,20 +21,36 @@ export const getOptionValueFromLabel = (label: string) => { }; export const formatFieldMetadataItemInput = ( - input: Pick & { - options?: FieldMetadataOption[]; - }, + input: Pick< + Field, + 'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options' + >, ) => { - const defaultOption = input.options?.find((option) => option.isDefault); + const options = input.options as FieldMetadataOption[]; + let defaultValue = input.defaultValue; + if (input.type === FieldMetadataType.MultiSelect) { + const defaultOptions = options?.filter((option) => option.isDefault); + if (isDefined(defaultOptions)) { + defaultValue = defaultOptions.map( + (defaultOption) => `'${getOptionValueFromLabel(defaultOption.label)}'`, + ); + } + } + if (input.type === FieldMetadataType.Select) { + const defaultOption = options?.find((option) => option.isDefault); + defaultValue = isDefined(defaultOption) + ? `'${getOptionValueFromLabel(defaultOption.label)}'` + : undefined; + } // Check if options has unique values - if (input.options !== undefined) { + if (options !== undefined) { // Compute the values based on the label - const values = input.options.map((option) => + const values = options.map((option) => getOptionValueFromLabel(option.label), ); - if (new Set(values).size !== input.options.length) { + if (new Set(values).size !== options.length) { throw new Error( `Options must have unique values, but contains the following duplicates ${values.join( ', ', @@ -43,14 +60,12 @@ export const formatFieldMetadataItemInput = ( } return { - defaultValue: defaultOption - ? `'${getOptionValueFromLabel(defaultOption.label)}'` - : input.defaultValue, + defaultValue, description: input.description?.trim() ?? null, icon: input.icon, label: input.label.trim(), name: toCamelCase(input.label.trim()), - options: input.options?.map((option, index) => ({ + options: options?.map((option, index) => ({ color: option.color, id: option.id, label: option.label.trim(), 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 69092bf39..9b80562da 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -21,6 +21,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ FieldMetadataType.Address, FieldMetadataType.Relation, FieldMetadataType.Select, + FieldMetadataType.MultiSelect, FieldMetadataType.Currency, ].includes(field.type) ) { @@ -76,6 +77,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => { return 'RELATION'; case FieldMetadataType.Select: return 'SELECT'; + case FieldMetadataType.MultiSelect: + return 'MULTI_SELECT'; case FieldMetadataType.Address: return 'ADDRESS'; default: diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts index 94de1d58a..12db079c1 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts @@ -9,10 +9,10 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput'; export type FormatRelationMetadataInputParams = { relationType: RelationType; - field: Pick; + field: Pick; objectMetadataId: string; connect: { - field: Pick; + field: Pick; objectMetadataId: string; }; }; 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 70f9f5ce2..14e1bfcf8 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -36,6 +36,7 @@ export const mapFieldMetadataToGraphQLQuery = ({ 'BOOLEAN', 'RATING', 'SELECT', + 'MULTI_SELECT', 'POSITION', 'RAW_JSON', ] as FieldMetadataType[] diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts index 5de407423..9a7d59538 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts @@ -15,18 +15,20 @@ export const getRecordFromRecordNode = ({ return [fieldName, value]; } - if (typeof value === 'object' && isDefined(value.edges)) { - return [ - fieldName, - getRecordsFromRecordConnection({ recordConnection: value }), - ]; + if (Array.isArray(value)) { + return [fieldName, value]; } - if (typeof value === 'object' && !isDefined(value.edges)) { - return [fieldName, getRecordFromRecordNode({ recordNode: value })]; + if (typeof value !== 'object') { + return [fieldName, value]; } - return [fieldName, value]; + return isDefined(value.edges) + ? [ + fieldName, + getRecordsFromRecordConnection({ recordConnection: value }), + ] + : [fieldName, getRecordFromRecordNode({ recordNode: value })]; }), ), id: recordNode.id, diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts index 73b093095..886cbd6b9 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts @@ -6,7 +6,10 @@ import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { + FieldMetadataType, + RelationDefinitionType, +} from '~/generated-metadata/graphql'; import { isDefined } from '~/utils/isDefined'; import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize'; @@ -65,12 +68,16 @@ export const getRecordNodeFromRecord = ({ return undefined; } - if (Array.isArray(value)) { - const objectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => objectMetadataItem.namePlural === fieldName, + if ( + field.type === FieldMetadataType.Relation && + field.relationDefinition?.direction === + RelationDefinitionType.OneToMany + ) { + const oneToManyObjectMetadataItem = objectMetadataItems.find( + (item) => item.namePlural === fieldName, ); - if (!objectMetadataItem) { + if (!oneToManyObjectMetadataItem) { return undefined; } @@ -78,7 +85,7 @@ export const getRecordNodeFromRecord = ({ fieldName, getRecordConnectionFromRecords({ objectMetadataItems, - objectMetadataItem: objectMetadataItem, + objectMetadataItem: oneToManyObjectMetadataItem, records: value as ObjectRecord[], queryFields: queryFields?.[fieldName] === true || 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 b55986058..650223884 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 @@ -9,4 +9,5 @@ export type FilterType = | 'LINK' | 'RELATION' | 'ADDRESS' - | 'SELECT'; + | 'SELECT' + | 'MULTI_SELECT'; 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 9096626cf..47a0b7a03 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,8 +1,5 @@ import { useContext } from 'react'; -import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay'; -import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; - import { FieldContext } from '../contexts/FieldContext'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; @@ -10,7 +7,9 @@ import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyF import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay'; import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay'; import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay'; +import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay'; import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay'; +import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx'; import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay'; import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay'; import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay'; @@ -23,8 +22,10 @@ import { isFieldDateTime } from '../types/guards/isFieldDateTime'; import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldFullName } from '../types/guards/isFieldFullName'; import { isFieldLink } from '../types/guards/isFieldLink'; +import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect.ts'; import { isFieldNumber } from '../types/guards/isFieldNumber'; import { isFieldPhone } from '../types/guards/isFieldPhone'; +import { isFieldRawJson } from '../types/guards/isFieldRawJson'; import { isFieldRelation } from '../types/guards/isFieldRelation'; import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldText } from '../types/guards/isFieldText'; @@ -60,6 +61,8 @@ export const FieldDisplay = () => { ) : isFieldSelect(fieldDefinition) ? ( + ) : isFieldMultiSelect(fieldDefinition) ? ( + ) : isFieldAddress(fieldDefinition) ? ( ) : isFieldRawJson(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 ad2279e82..97a957d4e 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 @@ -2,10 +2,12 @@ import { useContext } from 'react'; import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; +import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx'; import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; @@ -131,6 +133,8 @@ export const FieldInput = ({ ) : isFieldSelect(fieldDefinition) ? ( + ) : isFieldMultiSelect(fieldDefinition) ? ( + ) : isFieldAddress(fieldDefinition) ? ( { const fieldIsSelect = isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); + const fieldIsMultiSelect = + isFieldMultiSelect(fieldDefinition) && + isFieldMultiSelectValue(valueToPersist); + const fieldIsAddress = isFieldAddress(fieldDefinition) && isFieldAddressValue(valueToPersist); @@ -94,7 +100,7 @@ export const usePersistField = () => { isFieldRawJson(fieldDefinition) && isFieldRawJsonValue(valueToPersist); - if ( + const isValuePersistable = fieldIsRelation || fieldIsText || fieldIsBoolean || @@ -107,9 +113,11 @@ export const usePersistField = () => { fieldIsCurrency || fieldIsFullName || fieldIsSelect || + fieldIsMultiSelect || fieldIsAddress || - fieldIsRawJson - ) { + fieldIsRawJson; + + if (isValuePersistable === true) { const fieldName = fieldDefinition.metadata.fieldName; set( recordStoreFamilySelector({ recordId: entityId, fieldName }), diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx new file mode 100644 index 000000000..7f885da43 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; + +import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts'; +import { Tag } from '@/ui/display/tag/components/Tag'; + +const StyledTagContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; +export const MultiSelectFieldDisplay = () => { + const { fieldValues, fieldDefinition } = useMultiSelectField(); + + const selectedOptions = fieldValues + ? fieldDefinition.metadata.options.filter((option) => + fieldValues.includes(option.value), + ) + : []; + + return selectedOptions ? ( + + {selectedOptions.map((selectedOption, index) => ( + + ))} + + ) : ( + <> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts new file mode 100644 index 000000000..5a4a2eacd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts @@ -0,0 +1,50 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext.ts'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField.ts'; +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput.ts'; +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts'; +import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata.ts'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector.ts'; +import { FieldMetadataType } from '~/generated/graphql.tsx'; + +export const useMultiSelectField = () => { + const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.MultiSelect, + isFieldMultiSelect, + fieldDefinition, + ); + + const { fieldName } = fieldDefinition.metadata; + + const [fieldValues, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + + const fieldMultiSelectValues = isFieldMultiSelectValue(fieldValues) + ? fieldValues + : null; + const persistField = usePersistField(); + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${entityId}-${fieldName}`); + const draftValue = useRecoilValue(getDraftValueSelector()); + + return { + fieldDefinition, + persistField, + fieldValues: fieldMultiSelectValues, + draftValue, + setDraftValue, + setFieldValue, + hotkeyScope, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx new file mode 100644 index 000000000..864384918 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx @@ -0,0 +1,93 @@ +import { useRef, useState } from 'react'; +import styled from '@emotion/styled'; + +import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts'; +import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; +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 { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { isDefined } from '~/utils/isDefined'; + +const StyledRelationPickerContainer = styled.div` + left: -1px; + position: absolute; + top: -1px; +`; + +export type MultiSelectFieldInputProps = { + onSubmit?: FieldInputEvent; + onCancel?: () => void; +}; + +export const MultiSelectFieldInput = ({ + onCancel, +}: MultiSelectFieldInputProps) => { + const { persistField, fieldDefinition, fieldValues } = useMultiSelectField(); + const [searchFilter, setSearchFilter] = useState(''); + const containerRef = useRef(null); + + const selectedOptions = fieldDefinition.metadata.options.filter( + (option) => fieldValues?.includes(option.value), + ); + + const optionsInDropDown = fieldDefinition.metadata.options; + + 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, + ); + } + }; + + useListenClickOutside({ + refs: [containerRef], + callback: (event) => { + event.stopImmediatePropagation(); + + const weAreNotInAnHTMLInput = !( + event.target instanceof HTMLInputElement && + event.target.tagName === 'INPUT' + ); + if (weAreNotInAnHTMLInput && isDefined(onCancel)) { + onCancel(); + } + }, + }); + + return ( + + + setSearchFilter(event.currentTarget.value)} + autoFocus + /> + + + {optionsInDropDown.map((option) => { + return ( + + persistField(formatNewSelectedOptions(option.value)) + } + /> + ); + })} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 1639e3d6d..103bb49db 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -7,6 +7,7 @@ import { FieldEmailValue, FieldFullNameValue, FieldLinkValue, + FieldMultiSelectValue, FieldNumberValue, FieldPhoneValue, FieldRatingValue, @@ -22,6 +23,7 @@ export type FieldDateTimeDraftValue = string; export type FieldPhoneDraftValue = string; export type FieldEmailDraftValue = string; export type FieldSelectDraftValue = string; +export type FieldMultiSelectDraftValue = string[]; export type FieldRelationDraftValue = string; export type FieldLinkDraftValue = { url: string; label: string }; export type FieldCurrencyDraftValue = { @@ -64,8 +66,10 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldRatingValue : FieldValue extends FieldSelectValue ? FieldSelectDraftValue - : FieldValue extends FieldRelationValue - ? FieldRelationDraftValue - : FieldValue extends FieldAddressValue - ? FieldAddressDraftValue - : never; + : FieldValue extends FieldMultiSelectValue + ? FieldMultiSelectDraftValue + : FieldValue extends FieldRelationValue + ? FieldRelationDraftValue + : FieldValue extends FieldAddressValue + ? FieldAddressDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index d65d17bf6..e04a10850 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -103,6 +103,12 @@ export type FieldSelectMetadata = { options: { label: string; color: ThemeColor; value: string }[]; }; +export type FieldMultiSelectMetadata = { + objectMetadataNameSingular?: string; + fieldName: string; + options: { label: string; color: ThemeColor; value: string }[]; +}; + export type FieldMetadata = | FieldBooleanMetadata | FieldCurrencyMetadata @@ -115,6 +121,7 @@ export type FieldMetadata = | FieldRatingMetadata | FieldRelationMetadata | FieldSelectMetadata + | FieldMultiSelectMetadata | FieldTextMetadata | FieldUuidMetadata | FieldAddressMetadata; @@ -145,6 +152,7 @@ export type FieldAddressValue = { }; export type FieldRatingValue = (typeof RATING_VALUES)[number]; export type FieldSelectValue = string | null; +export type FieldMultiSelectValue = string[] | null; export type FieldRelationValue = EntityForSelect | null; export type FieldJsonValue = string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 37d4fc84b..a99f44173 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -10,6 +10,7 @@ import { FieldFullNameMetadata, FieldLinkMetadata, FieldMetadata, + FieldMultiSelectMetadata, FieldNumberMetadata, FieldPhoneMetadata, FieldRatingMetadata, @@ -34,27 +35,29 @@ type AssertFieldMetadataFunction = < ? FieldEmailMetadata : E extends 'SELECT' ? FieldSelectMetadata - : E extends 'RATING' - ? FieldRatingMetadata - : E extends 'LINK' - ? FieldLinkMetadata - : E extends 'NUMBER' - ? FieldNumberMetadata - : E extends 'PHONE' - ? FieldPhoneMetadata - : E extends 'PROBABILITY' - ? FieldRatingMetadata - : E extends 'RELATION' - ? FieldRelationMetadata - : E extends 'TEXT' - ? FieldTextMetadata - : E extends 'UUID' - ? FieldUuidMetadata - : E extends 'ADDRESS' - ? FieldAddressMetadata - : E extends 'RAW_JSON' - ? FieldRawJsonMetadata - : never, + : E extends 'MULTI_SELECT' + ? FieldMultiSelectMetadata + : E extends 'RATING' + ? FieldRatingMetadata + : E extends 'LINK' + ? FieldLinkMetadata + : E extends 'NUMBER' + ? FieldNumberMetadata + : E extends 'PHONE' + ? FieldPhoneMetadata + : E extends 'PROBABILITY' + ? FieldRatingMetadata + : E extends 'RELATION' + ? FieldRelationMetadata + : E extends 'TEXT' + ? FieldTextMetadata + : E extends 'UUID' + ? FieldUuidMetadata + : E extends 'ADDRESS' + ? FieldAddressMetadata + : E extends 'RAW_JSON' + ? FieldRawJsonMetadata + : never, >( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts new file mode 100644 index 000000000..e799738e6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts @@ -0,0 +1,11 @@ +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition.ts'; +import { + FieldMetadata, + FieldMultiSelectMetadata, +} from '@/object-record/record-field/types/FieldMetadata.ts'; +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + +export const isFieldMultiSelect = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.MultiSelect; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts new file mode 100644 index 000000000..01d2b47ff --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts @@ -0,0 +1,9 @@ +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts'; +import { multiSelectFieldValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts'; + +export const isFieldMultiSelectValue = ( + fieldValue: unknown, + options?: string[], +): fieldValue is FieldMultiSelectValue => { + return multiSelectFieldValueSchema(options).safeParse(fieldValue).success; +}; 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 c20b06742..776ae51cc 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 @@ -11,6 +11,8 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; @@ -54,6 +56,13 @@ export const isFieldValueEmpty = ({ ); } + if (isFieldMultiSelect(fieldDefinition)) { + return ( + !isFieldMultiSelectValue(fieldValue, selectOptionValues) || + !isDefined(fieldValue) + ); + } + if (isFieldCurrency(fieldDefinition)) { return ( !isFieldCurrencyValue(fieldValue) || diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts new file mode 100644 index 000000000..c4da1fc75 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata'; + +export const multiSelectFieldValueSchema = ( + options?: string[], +): z.ZodType => + options?.length + ? z.array(z.enum(options as [string, ...string[]])).nullable() + : z.array(z.string()).nullable(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 13e93c06a..53a803ef2 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -143,6 +143,7 @@ export const isRecordMatchingFilter = ({ case FieldMetadataType.Email: case FieldMetadataType.Phone: case FieldMetadataType.Select: + case FieldMetadataType.MultiSelect: case FieldMetadataType.Text: { return isMatchingStringFilter({ stringFilter: filterValue as StringFilter, 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 dd5d98f65..3ab9cc857 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -73,7 +73,7 @@ export const generateEmptyFieldValue = ( return null; } case FieldMetadataType.MultiSelect: { - throw new Error('Not implemented yet'); + return null; } case FieldMetadataType.RawJson: { return null; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx index 4577aca5e..f60deeeaa 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx @@ -24,6 +24,7 @@ export type SettingsObjectFieldSelectFormValues = type SettingsObjectFieldSelectFormProps = { onChange: (values: SettingsObjectFieldSelectFormValues) => void; values: SettingsObjectFieldSelectFormValues; + isMultiSelect?: boolean; }; const StyledContainer = styled(CardContent)` @@ -60,6 +61,7 @@ const getNextColor = (currentColor: ThemeColor) => { export const SettingsObjectFieldSelectForm = ({ onChange, values, + isMultiSelect = false, }: SettingsObjectFieldSelectFormProps) => { const handleDragEnd = (result: DropResult) => { if (!result.destination) return; @@ -72,6 +74,38 @@ export const SettingsObjectFieldSelectForm = ({ onChange(nextOptions); }; + const handleDefaultValueChange = ( + index: number, + option: SettingsObjectFieldSelectFormOption, + nextOption: SettingsObjectFieldSelectFormOption, + forceUniqueDefaultValue: boolean, + ) => { + const computeUniqueDefaultValue = + forceUniqueDefaultValue && option.isDefault !== nextOption.isDefault; + + const nextOptions = computeUniqueDefaultValue + ? values.map((value) => ({ + ...value, + isDefault: false, + })) + : [...values]; + + nextOptions.splice(index, 1, nextOption); + onChange(nextOptions); + }; + + const findNewLabel = () => { + let optionIndex = values.length + 1; + while (optionIndex < 100) { + const newLabel = `Option ${optionIndex}`; + if (!values.map((value) => value.label).includes(newLabel)) { + return newLabel; + } + optionIndex += 1; + } + return `Option 100`; + }; + return ( <> @@ -91,18 +125,12 @@ export const SettingsObjectFieldSelectForm = ({ key={option.value} isDefault={option.isDefault} onChange={(nextOption) => { - const hasDefaultOptionChanged = - !option.isDefault && nextOption.isDefault; - const nextOptions = hasDefaultOptionChanged - ? values.map((value) => ({ - ...value, - isDefault: false, - })) - : [...values]; - - nextOptions.splice(index, 1, nextOption); - - onChange(nextOptions); + handleDefaultValueChange( + index, + option, + nextOption, + !isMultiSelect, + ); }} onRemove={ values.length > 1 @@ -131,7 +159,7 @@ export const SettingsObjectFieldSelectForm = ({ ...values, { color: getNextColor(values[values.length - 1].color), - label: `Option ${values.length + 1}`, + label: findNewLabel(), value: v4(), }, ]) 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 c4e2d798d..409a231e2 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 @@ -11,6 +11,7 @@ import { IconPhone, IconRelationManyToMany, IconTag, + IconTags, IconTextSize, IconUser, } from 'twenty-ui'; @@ -75,8 +76,8 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record< Icon: IconTag, }, [FieldMetadataType.MultiSelect]: { - label: 'MultiSelect', - Icon: IconTag, + label: 'Multi-select', + Icon: IconTags, }, [FieldMetadataType.Currency]: { label: 'Currency', 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 8a344827f..95bcbe773 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 @@ -27,6 +27,7 @@ export type SettingsDataModelFieldSettingsFormValues = { currency: SettingsObjectFieldCurrencyFormValues; relation: SettingsObjectFieldRelationFormValues; select: SettingsObjectFieldSelectFormValues; + multiSelect: SettingsObjectFieldSelectFormValues; defaultValue: any; }; @@ -63,6 +64,7 @@ const previewableTypes = [ FieldMetadataType.Currency, FieldMetadataType.DateTime, FieldMetadataType.Select, + FieldMetadataType.MultiSelect, FieldMetadataType.Link, FieldMetadataType.Number, FieldMetadataType.Rating, @@ -98,7 +100,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({ shrink={fieldMetadataItem.type === FieldMetadataType.Relation} objectMetadataItem={objectMetadataItem} relationObjectMetadataItem={relationObjectMetadataItem} - selectOptions={values.select} + selectOptions={ + fieldMetadataItem.type === FieldMetadataType.MultiSelect + ? values.multiSelect + : values.select + } /> {fieldMetadataItem.type === FieldMetadataType.Relation && !!relationObjectMetadataItem && ( @@ -155,6 +161,14 @@ export const SettingsDataModelFieldSettingsFormCard = ({ onChange({ select: nextSelectValues }) } /> + ) : fieldMetadataItem.type === FieldMetadataType.MultiSelect ? ( + + onChange({ multiSelect: nextMultiSelectValues }) + } + isMultiSelect={true} + /> ) : fieldMetadataItem.type === FieldMetadataType.Boolean ? ( { select: [ { color: 'green', label: 'Option 1', value: expect.any(String) }, ], + multiSelect: [ + { color: 'green', label: 'Option 1', value: expect.any(String) }, + ], }); }); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts index 842fd0b66..c462b88ba 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts @@ -34,13 +34,19 @@ export const fieldMetadataFormDefaultValues: FormValues = { }, defaultValue: null, select: [{ color: 'green', label: 'Option 1', value: v4() }], + multiSelect: [{ color: 'green', label: 'Option 1', value: v4() }], }; - const fieldSchema = z.object({ description: z.string().optional(), icon: z.string().startsWith('Icon'), label: z.string().min(1), defaultValue: z.any(), + type: z.enum( + Object.values(FieldMetadataType) as [ + FieldMetadataType, + ...FieldMetadataType[], + ], + ), }); const currencySchema = fieldSchema.merge( @@ -83,10 +89,27 @@ const selectSchema = fieldSchema.merge( }), ); +const multiSelectSchema = fieldSchema.merge( + z.object({ + type: z.literal(FieldMetadataType.MultiSelect), + multiSelect: z + .array( + z.object({ + color: themeColorSchema, + id: z.string().optional(), + isDefault: z.boolean().optional(), + label: z.string().min(1), + }), + ) + .nonempty(), + }), +); + const { Currency: _Currency, Relation: _Relation, Select: _Select, + MultiSelect: _MultiSelect, ...otherFieldTypes } = FieldMetadataType; @@ -95,6 +118,7 @@ type OtherFieldType = Exclude< | FieldMetadataType.Currency | FieldMetadataType.Relation | FieldMetadataType.Select + | FieldMetadataType.MultiSelect >; const otherFieldTypesSchema = fieldSchema.merge( @@ -109,6 +133,7 @@ const schema = z.discriminatedUnion('type', [ currencySchema, relationSchema, selectSchema, + multiSelectSchema, otherFieldTypesSchema, ]); @@ -127,6 +152,8 @@ export const useFieldMetadataForm = () => { const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false); const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false); const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false); + const [hasMultiSelectFormChanged, setHasMultiSelectFormChanged] = + useState(false); const [hasDefaultValueChanged, setHasDefaultValueFormChanged] = useState(false); const [validationResult, setValidationResult] = useState( @@ -174,13 +201,15 @@ export const useFieldMetadataForm = () => { currency: initialCurrencyFormValues, relation: initialRelationFormValues, select: initialSelectFormValues, - defaultValue: initalDefaultValue, + multiSelect: initialMultiSelectFormValues, + defaultValue: initialDefaultValue, ...initialFieldFormValues } = initialFormValues; const { currency: nextCurrencyFormValues, relation: nextRelationFormValues, select: nextSelectFormValues, + multiSelect: nextMultiSelectFormValues, defaultValue: nextDefaultValue, ...nextFieldFormValues } = nextFormValues; @@ -200,9 +229,13 @@ export const useFieldMetadataForm = () => { nextFieldFormValues.type === FieldMetadataType.Select && !isDeeplyEqual(initialSelectFormValues, nextSelectFormValues), ); + setHasMultiSelectFormChanged( + nextFieldFormValues.type === FieldMetadataType.MultiSelect && + !isDeeplyEqual(initialMultiSelectFormValues, nextMultiSelectFormValues), + ); setHasDefaultValueFormChanged( nextFieldFormValues.type === FieldMetadataType.Boolean && - !isDeeplyEqual(initalDefaultValue, nextDefaultValue), + !isDeeplyEqual(initialDefaultValue, nextDefaultValue), ); }; @@ -215,9 +248,11 @@ export const useFieldMetadataForm = () => { hasCurrencyFormChanged || hasRelationFormChanged || hasSelectFormChanged || + hasMultiSelectFormChanged || hasDefaultValueChanged, hasRelationFormChanged, hasSelectFormChanged, + hasMultiSelectFormChanged, hasDefaultValueChanged, initForm, isInitialized, diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts index 3994ab36e..a9777ffda 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts @@ -21,7 +21,6 @@ export const getFieldDefaultPreviewValue = ({ relationObjectMetadataItem?: ObjectMetadataItem; selectOptions?: SettingsObjectFieldSelectFormValues; }) => { - // Select field if ( fieldMetadataItem.type === FieldMetadataType.Select && isDefined(selectOptions) @@ -31,7 +30,13 @@ export const getFieldDefaultPreviewValue = ({ return defaultSelectOption.value; } - // Relation field + if ( + fieldMetadataItem.type === FieldMetadataType.MultiSelect && + isDefined(selectOptions) + ) { + return selectOptions.map((selectOption) => selectOption.value); + } + if ( fieldMetadataItem.type === FieldMetadataType.Relation && isDefined(relationObjectMetadataItem) @@ -60,7 +65,6 @@ export const getFieldDefaultPreviewValue = ({ return defaultRelationRecord; } - // Other fields const isLabelIdentifier = !!fieldMetadataItem.id && !!fieldMetadataItem.name && diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx index fa5d5c1e8..d6fa4c6f8 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx @@ -20,6 +20,7 @@ const StyledInnerContainer = styled.div` display: flex; flex-direction: column; width: ${() => (useIsMobile() ? `100%` : '348px')}; + overflow-x: hidden; `; const StyledIntermediateContainer = styled.div` diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx new file mode 100644 index 000000000..14ebec217 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx @@ -0,0 +1,41 @@ +import { Tag } from '@/ui/display/tag/components/Tag'; +import { + Checkbox, + CheckboxShape, + CheckboxSize, +} from '@/ui/input/components/Checkbox'; +import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; + +import { + StyledMenuItemBase, + StyledMenuItemLeftContent, +} from '../internals/components/StyledMenuItemBase'; + +type MenuItemMultiSelectTagProps = { + selected: boolean; + className?: string; + onClick?: () => void; + color: ThemeColor; + text: string; +}; + +export const MenuItemMultiSelectTag = ({ + color, + selected, + className, + onClick, + text, +}: MenuItemMultiSelectTagProps) => { + return ( + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 1b1021c1c..10c009f0c 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -4,4 +4,5 @@ export type FeatureFlagKey = | 'IS_QUICK_ACTIONS_ENABLED' | 'IS_EVENT_OBJECT_ENABLED' | 'IS_AIRTABLE_INTEGRATION_ENABLED' - | 'IS_POSTGRESQL_INTEGRATION_ENABLED'; + | 'IS_POSTGRESQL_INTEGRATION_ENABLED' + | 'IS_MULTI_SELECT_ENABLED'; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index 16b2c1756..02328b3e7 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -42,7 +42,8 @@ const canPersistFieldMetadataItemUpdate = ( ) => { return ( fieldMetadataItem.isCustom || - fieldMetadataItem.type === FieldMetadataType.Select + fieldMetadataItem.type === FieldMetadataType.Select || + fieldMetadataItem.type === FieldMetadataType.MultiSelect ); }; @@ -87,6 +88,7 @@ export const SettingsObjectFieldEdit = () => { hasFormChanged, hasRelationFormChanged, hasSelectFormChanged, + hasMultiSelectFormChanged, initForm, isInitialized, isValid, @@ -114,6 +116,14 @@ export const SettingsObjectFieldEdit = () => { (optionA, optionB) => optionA.position - optionB.position, ); + const multiSelectOptions = activeMetadataField.options?.map((option) => ({ + ...option, + isDefault: defaultValue?.includes(`'${option.value}'`) || false, + })); + multiSelectOptions?.sort( + (optionA, optionB) => optionA.position - optionB.position, + ); + const fieldType = activeMetadataField.type; const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType); @@ -135,6 +145,9 @@ export const SettingsObjectFieldEdit = () => { }, defaultValue: activeMetadataField.defaultValue, ...(selectOptions?.length ? { select: selectOptions } : {}), + ...(multiSelectOptions?.length + ? { multiSelect: multiSelectOptions } + : {}), }); }, [ activeMetadataField, @@ -170,11 +183,13 @@ export const SettingsObjectFieldEdit = () => { icon: validatedFormValues.relation.field.icon, id: relationFieldMetadataItem?.id, label: validatedFormValues.relation.field.label, + type: validatedFormValues.type, }); } if ( hasFieldFormChanged || hasSelectFormChanged || + hasMultiSelectFormChanged || hasDefaultValueChanged ) { await editMetadataField({ @@ -183,10 +198,13 @@ export const SettingsObjectFieldEdit = () => { id: activeMetadataField.id, label: validatedFormValues.label, defaultValue: validatedFormValues.defaultValue, + type: validatedFormValues.type, options: validatedFormValues.type === FieldMetadataType.Select ? validatedFormValues.select - : undefined, + : validatedFormValues.type === FieldMetadataType.MultiSelect + ? validatedFormValues.multiSelect + : undefined, }); } @@ -261,6 +279,7 @@ export const SettingsObjectFieldEdit = () => { currency: formValues.currency, relation: formValues.relation, select: formValues.select, + multiSelect: formValues.multiSelect, defaultValue: formValues.defaultValue, }} /> diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index 5feeaee1d..9c009f392 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -28,6 +28,7 @@ import { Section } from '@/ui/layout/section/components/Section'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { View } from '@/views/types/View'; import { ViewType } from '@/views/types/ViewType'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled.ts'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -41,6 +42,7 @@ export const SettingsObjectNewFieldStep2 = () => { const navigate = useNavigate(); const { objectSlug = '' } = useParams(); const { enqueueSnackBar } = useSnackBar(); + const isMultiSelectEnabled = useIsFeatureEnabled('IS_MULTI_SELECT_ENABLED'); const { findActiveObjectMetadataItemBySlug, @@ -132,12 +134,14 @@ export const SettingsObjectNewFieldStep2 = () => { description: validatedFormValues.description, icon: validatedFormValues.icon, label: validatedFormValues.label, + type: validatedFormValues.type, }, objectMetadataId: activeObjectMetadataItem.id, connect: { field: { icon: validatedFormValues.relation.field.icon, label: validatedFormValues.relation.field.label, + type: validatedFormValues.relation.field.type, }, objectMetadataId: validatedFormValues.relation.objectMetadataId, }, @@ -147,7 +151,7 @@ export const SettingsObjectNewFieldStep2 = () => { validatedFormValues.relation.objectMetadataId, ); - objectViews.forEach(async (view) => { + objectViews.map(async (view) => { const viewFieldToCreate = { viewId: view.id, fieldMetadataId: @@ -180,7 +184,7 @@ export const SettingsObjectNewFieldStep2 = () => { recordId: view.id, }); - relationObjectViews.forEach(async (view) => { + relationObjectViews.map(async (view) => { const viewFieldToCreate = { viewId: view.id, fieldMetadataId: @@ -230,10 +234,12 @@ export const SettingsObjectNewFieldStep2 = () => { options: validatedFormValues.type === FieldMetadataType.Select ? validatedFormValues.select - : undefined, + : validatedFormValues.type === FieldMetadataType.MultiSelect + ? validatedFormValues.multiSelect + : undefined, }); - objectViews.forEach(async (view) => { + objectViews.map(async (view) => { const viewFieldToCreate = { viewId: view.id, fieldMetadataId: createdMetadataField.data?.createOneField.id, @@ -278,13 +284,16 @@ export const SettingsObjectNewFieldStep2 = () => { FieldMetadataType.Email, FieldMetadataType.FullName, FieldMetadataType.Link, - FieldMetadataType.MultiSelect, FieldMetadataType.Numeric, FieldMetadataType.Phone, FieldMetadataType.Probability, FieldMetadataType.Uuid, ]; + if (!isMultiSelectEnabled) { + excludedFieldTypes.push(FieldMetadataType.MultiSelect); + } + return ( @@ -335,6 +344,7 @@ export const SettingsObjectNewFieldStep2 = () => { currency: formValues.currency, relation: formValues.relation, select: formValues.select, + multiSelect: formValues.multiSelect, defaultValue: formValues.defaultValue, }} /> diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 7d51820de..cc542d60b 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -40,11 +40,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { return this.mainDataSource; } - /** - * Connects to a data source using metadata. Returns a cached connection if it exists. - * @param dataSource DataSourceEntity - * @returns Promise - */ public async connectToDataSource( dataSource: DataSourceEntity, ): Promise { @@ -96,12 +91,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { return workspaceDataSource; } - /** - * Disconnects from a workspace data source. - * @param dataSourceId - * @returns Promise - * - */ public async disconnectFromDataSource(dataSourceId: string) { if (!this.dataSources.has(dataSourceId)) { return; @@ -114,11 +103,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { this.dataSources.delete(dataSourceId); } - /** - * Creates a new schema - * @param workspaceId - * @returns Promise - */ public async createSchema(schemaName: string): Promise { const queryRunner = this.mainDataSource.createQueryRunner(); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index cab253bfb..38b2d4b57 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -306,9 +306,11 @@ export class FieldMetadataService extends TypeOrmQueryService { if (typeof enumValue === 'string') { @@ -38,62 +43,61 @@ export class WorkspaceMigrationEnumService { return enumValue.to; }) ?? []; + const renamedEnumValues = columnDefinition.enum?.filter( + (enumValue): enumValue is WorkspaceMigrationRenamedEnum => + typeof enumValue !== 'string', + ); if (!columnDefinition.isNullable && !columnDefinition.defaultValue) { columnDefinition.defaultValue = serializeDefaultValue(enumValues[0]); } - // Create new enum type with new values - await this.createNewEnumType( - newEnumTypeName, - queryRunner, - schemaName, - enumValues, - ); + const oldColumnName = `${columnDefinition.columnName}_old_${v4()}`; - // Temporarily change column type to text - await queryRunner.query(` - ALTER TABLE "${schemaName}"."${tableName}" - ALTER COLUMN "${columnDefinition.columnName}" TYPE TEXT - `); - - // Migrate existing values to new values - await this.migrateEnumValues( - queryRunner, - schemaName, - tableName, - migrationColumn, - ); - - // Update existing rows to handle missing values - await this.handleMissingEnumValues( - queryRunner, - schemaName, - tableName, - migrationColumn, - enumValues, - ); - - // Alter column type to new enum - await this.updateColumnToNewEnum( + // Rename old column + await this.renameColumn( queryRunner, schemaName, tableName, columnDefinition.columnName, - newEnumTypeName, - columnDefinition.defaultValue, + oldColumnName, ); - - // Drop old enum type - await this.dropOldEnumType(queryRunner, schemaName, oldEnumTypeName); - - // Rename new enum type to old enum type name await this.renameEnumType( queryRunner, schemaName, oldEnumTypeName, - newEnumTypeName, + tempEnumTypeName, ); + + await queryRunner.addColumn( + `${schemaName}.${tableName}`, + new TableColumn({ + name: columnDefinition.columnName, + type: columnDefinition.columnType, + default: columnDefinition.defaultValue, + enum: enumValues, + isArray: columnDefinition.isArray, + isNullable: columnDefinition.isNullable, + }), + ); + + await this.migrateEnumValues( + queryRunner, + schemaName, + migrationColumn, + tableName, + oldColumnName, + enumValues, + renamedEnumValues, + ); + + // Drop old column + await queryRunner.query(` + ALTER TABLE "${schemaName}"."${tableName}" + DROP COLUMN "${oldColumnName}" + `); + // Drop temp enum type + await this.dropOldEnumType(queryRunner, schemaName, tempEnumTypeName); } private async renameColumn( @@ -109,90 +113,52 @@ export class WorkspaceMigrationEnumService { `); } - private async createNewEnumType( - name: string, - queryRunner: QueryRunner, - schemaName: string, - newValues: string[], + private migrateEnumValue( + value: string, + renamedEnumValues?: WorkspaceMigrationRenamedEnum[], ) { - const enumValues = newValues - .map((value) => `'${value.replace(/'/g, "''")}'`) - .join(', '); - - await queryRunner.query( - `CREATE TYPE "${schemaName}"."${name}" AS ENUM (${enumValues})`, + return ( + renamedEnumValues?.find((enumVal) => enumVal?.from === value)?.to || value ); } private async migrateEnumValues( queryRunner: QueryRunner, schemaName: string, - tableName: string, migrationColumn: WorkspaceMigrationColumnAlter, + tableName: string, + oldColumnName: string, + enumValues: string[], + renamedEnumValues?: WorkspaceMigrationRenamedEnum[], ) { const columnDefinition = migrationColumn.alteredColumnDefinition; - if (!columnDefinition.enum) { - return; - } + const values = await queryRunner.query( + `SELECT id, "${oldColumnName}" FROM "${schemaName}"."${tableName}"`, + ); - for (const enumValue of columnDefinition.enum) { - // Skip string values - if (typeof enumValue === 'string') { - continue; + values.map(async (value) => { + let val = value[oldColumnName]; + + if (/^\{.*\}$/.test(val)) { + val = serializeDefaultValue( + val + .slice(1, -1) + .split(',') + .map((v: string) => v.trim()) + .map((v: string) => this.migrateEnumValue(v, renamedEnumValues)) + .filter((v: string) => enumValues.includes(v)), + ); + } else if (typeof val === 'string') { + val = `'${this.migrateEnumValue(val, renamedEnumValues)}'`; } + await queryRunner.query(` UPDATE "${schemaName}"."${tableName}" - SET "${columnDefinition.columnName}" = '${enumValue.to}' - WHERE "${columnDefinition.columnName}" = '${enumValue.from}' + SET "${columnDefinition.columnName}" = ${val} + WHERE id='${value.id}' `); - } - } - - private async handleMissingEnumValues( - queryRunner: QueryRunner, - schemaName: string, - tableName: string, - migrationColumn: WorkspaceMigrationColumnAlter, - enumValues: string[], - ) { - const columnDefinition = migrationColumn.alteredColumnDefinition; - - // Set missing values to null or default value - let defaultValue = 'NULL'; - - if (columnDefinition.defaultValue) { - if (Array.isArray(columnDefinition.defaultValue)) { - defaultValue = `ARRAY[${columnDefinition.defaultValue - .map((e) => `'${e}'`) - .join(', ')}]`; - } else { - defaultValue = columnDefinition.defaultValue; - } - } - - await queryRunner.query(` - UPDATE "${schemaName}"."${tableName}" - SET "${columnDefinition.columnName}" = ${defaultValue} - WHERE "${columnDefinition.columnName}" NOT IN (${enumValues - .map((e) => `'${e}'`) - .join(', ')}) - `); - } - - private async updateColumnToNewEnum( - queryRunner: QueryRunner, - schemaName: string, - tableName: string, - columnName: string, - newEnumTypeName: string, - newDefaultValue: string, - ) { - await queryRunner.query( - `ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT, - ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}"), - ALTER COLUMN "${columnName}" SET DEFAULT ${newDefaultValue}`, - ); + }); } private async dropOldEnumType( @@ -212,8 +178,8 @@ export class WorkspaceMigrationEnumService { newEnumTypeName: string, ) { await queryRunner.query(` - ALTER TYPE "${schemaName}"."${newEnumTypeName}" - RENAME TO "${oldEnumTypeName}" + ALTER TYPE "${schemaName}"."${oldEnumTypeName}" + RENAME TO "${newEnumTypeName}" `); } } diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index f16f99dd2..50826f172 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -124,6 +124,7 @@ export { IconSortDescending, IconTable, IconTag, + IconTags, IconTarget, IconTargetArrow, IconTextSize,