Introduce ARRAY field type (#6862)

This PR was created by \[GitStart\](<https://gitstart.com/>) to address
the requirements from this ticket:
\[TWNTY-6447\](<https://clients.gitstart.com/twenty/5449/tickets/TWNTY-6447>).

This ticket was imported from:
<https://github.com/twentyhq/twenty/issues/6447>

### Description

\- We added a new field type

### Refs

#6447

### Demo

<https://jam.dev/c/2b4d7853-ea89-4e9d-a561-6edcb4fdb34b>

Fixes #6447

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
gitstart-app[bot] 2024-09-16 14:07:55 +02:00 committed by GitHub
parent bc99cfec98
commit 8208a3e976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 392 additions and 28 deletions

View File

@ -376,7 +376,8 @@ export enum FieldMetadataType {
RichText = 'RICH_TEXT', RichText = 'RICH_TEXT',
Select = 'SELECT', Select = 'SELECT',
Text = 'TEXT', Text = 'TEXT',
Uuid = 'UUID' Uuid = 'UUID',
Array = 'ARRAY'
} }
export enum FileFolder { export enum FileFolder {

View File

@ -260,6 +260,7 @@ export type FieldConnection = {
export enum FieldMetadataType { export enum FieldMetadataType {
Actor = 'ACTOR', Actor = 'ACTOR',
Address = 'ADDRESS', Address = 'ADDRESS',
Array = 'ARRAY',
Boolean = 'BOOLEAN', Boolean = 'BOOLEAN',
Currency = 'CURRENCY', Currency = 'CURRENCY',
Date = 'DATE', Date = 'DATE',

View File

@ -98,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'RATING'; return 'RATING';
case FieldMetadataType.Actor: case FieldMetadataType.Actor:
return 'ACTOR'; return 'ACTOR';
case FieldMetadataType.Array:
return 'ARRAY';
default: default:
return 'TEXT'; return 'TEXT';
} }

View File

@ -38,6 +38,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
FieldMetadataType.Position, FieldMetadataType.Position,
FieldMetadataType.RawJson, FieldMetadataType.RawJson,
FieldMetadataType.RichText, FieldMetadataType.RichText,
FieldMetadataType.Array,
].includes(fieldType); ].includes(fieldType);
if (fieldIsSimpleValue) { if (fieldIsSimpleValue) {

View File

@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({
'LINKS', 'LINKS',
'ADDRESS', 'ADDRESS',
'ACTOR', 'ACTOR',
'ARRAY',
'PHONES', 'PHONES',
].includes(filterDefinitionUsedInDropdown.type) && ( ].includes(filterDefinitionUsedInDropdown.type) && (
<ObjectFilterDropdownTextSearchInput /> <ObjectFilterDropdownTextSearchInput />

View File

@ -16,4 +16,5 @@ export type FilterType =
| 'SELECT' | 'SELECT'
| 'RATING' | 'RATING'
| 'MULTI_SELECT' | 'MULTI_SELECT'
| 'ACTOR'; | 'ACTOR'
| 'ARRAY';

View File

@ -22,6 +22,7 @@ export const getOperandsForFilterType = (
case 'LINK': case 'LINK':
case 'LINKS': case 'LINKS':
case 'ACTOR': case 'ACTOR':
case 'ARRAY':
case 'PHONES': case 'PHONES':
return [ return [
ViewFilterOperand.Contains, ViewFilterOperand.Contains,

View File

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay'; 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 { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay'; import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; 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 { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay'; import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; 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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails'; import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
@ -104,6 +106,8 @@ export const FieldDisplay = () => {
<RichTextFieldDisplay /> <RichTextFieldDisplay />
) : isFieldActor(fieldDefinition) ? ( ) : isFieldActor(fieldDefinition) ? (
<ActorFieldDisplay /> <ActorFieldDisplay />
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldDisplay />
) : isFieldEmails(fieldDefinition) ? ( ) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay /> <EmailsFieldDisplay />
) : isFieldPhones(fieldDefinition) ? ( ) : isFieldPhones(fieldDefinition) ? (

View File

@ -24,7 +24,9 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; 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 { 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 { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput'; import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput';
@ -187,6 +189,8 @@ export const FieldInput = ({
/> />
) : isFieldRichText(fieldDefinition) ? ( ) : isFieldRichText(fieldDefinition) ? (
<RichTextFieldInput /> <RichTextFieldInput />
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput onCancel={onCancel} />
) : ( ) : (
<></> <></>
)} )}

View File

@ -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 { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; 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 { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue'; import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue';
@ -124,6 +126,9 @@ export const usePersistField = () => {
isFieldRawJson(fieldDefinition) && isFieldRawJson(fieldDefinition) &&
isFieldRawJsonValue(valueToPersist); isFieldRawJsonValue(valueToPersist);
const fieldIsArray =
isFieldArray(fieldDefinition) && isFieldArrayValue(valueToPersist);
const isValuePersistable = const isValuePersistable =
fieldIsRelationToOneObject || fieldIsRelationToOneObject ||
fieldIsText || fieldIsText ||
@ -143,7 +148,8 @@ export const usePersistField = () => {
fieldIsSelect || fieldIsSelect ||
fieldIsMultiSelect || fieldIsMultiSelect ||
fieldIsAddress || fieldIsAddress ||
fieldIsRawJson; fieldIsRawJson ||
fieldIsArray;
if (isValuePersistable) { if (isValuePersistable) {
const fieldName = fieldDefinition.metadata.fieldName; const fieldName = fieldDefinition.metadata.fieldName;

View File

@ -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 (
<StyledContainer>
<ArrayDisplay value={fieldValue} isFocused={isFocused} />
</StyledContainer>
);
};

View File

@ -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<FieldArrayValue>(
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,
};
};

View File

@ -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<FieldArrayValue | undefined>(
recordId,
fieldName,
);
return {
fieldDefinition: fieldDefinition as FieldDefinition<FieldArrayMetadata>,
fieldValue,
};
};

View File

@ -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<string>>(
() => (Array.isArray(fieldValue) ? fieldValue : []),
[fieldValue],
);
return (
<MultiItemFieldInput
hotkeyScope={hotkeyScope}
newItemLabel="Add Item"
items={arrayItems}
onPersist={persistArrayField}
onCancel={onCancel}
placeholder="Enter value"
fieldMetadataType={FieldMetadataType.Array}
renderItem={({ value, index, handleEdit, handleDelete }) => (
<ArrayFieldMenuItem
key={index}
dropdownId={`${hotkeyScope}-array-${index}`}
value={value}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
></MultiItemFieldInput>
);
};

View File

@ -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 (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
value={value}
onEdit={onEdit}
onDelete={onDelete}
DisplayComponent={() => <ArrayDisplay value={[value]} isInputDisplay />}
hasPrimaryButton={false}
/>
);
};

View File

@ -40,10 +40,12 @@ type MultiItemFieldInputProps<T> = {
handleDelete: () => void; handleDelete: () => void;
}) => React.ReactNode; }) => React.ReactNode;
hotkeyScope: string; hotkeyScope: string;
newItemLabel?: string;
fieldMetadataType: FieldMetadataType; fieldMetadataType: FieldMetadataType;
renderInput?: DropdownMenuInputProps['renderInput']; renderInput?: DropdownMenuInputProps['renderInput'];
}; };
// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ...
export const MultiItemFieldInput = <T,>({ export const MultiItemFieldInput = <T,>({
items, items,
onPersist, onPersist,
@ -53,6 +55,7 @@ export const MultiItemFieldInput = <T,>({
formatInput, formatInput,
renderItem, renderItem,
hotkeyScope, hotkeyScope,
newItemLabel,
fieldMetadataType, fieldMetadataType,
renderInput, renderInput,
}: MultiItemFieldInputProps<T>) => { }: MultiItemFieldInputProps<T>) => {
@ -181,7 +184,7 @@ export const MultiItemFieldInput = <T,>({
<MenuItem <MenuItem
onClick={handleAddButtonClick} onClick={handleAddButtonClick}
LeftIcon={IconPlus} LeftIcon={IconPlus}
text={`Add ${placeholder}`} text={newItemLabel || `Add ${placeholder}`}
/> />
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
)} )}

View File

@ -21,6 +21,7 @@ type MultiItemFieldMenuItemProps<T> = {
onSetAsPrimary?: () => void; onSetAsPrimary?: () => void;
onDelete?: () => void; onDelete?: () => void;
DisplayComponent: React.ComponentType<{ value: T }>; DisplayComponent: React.ComponentType<{ value: T }>;
hasPrimaryButton?: boolean;
}; };
const StyledIconBookmark = styled(IconBookmark)` const StyledIconBookmark = styled(IconBookmark)`
@ -37,6 +38,7 @@ export const MultiItemFieldMenuItem = <T,>({
onSetAsPrimary, onSetAsPrimary,
onDelete, onDelete,
DisplayComponent, DisplayComponent,
hasPrimaryButton = true,
}: MultiItemFieldMenuItemProps<T>) => { }: MultiItemFieldMenuItemProps<T>) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId); const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
@ -74,7 +76,7 @@ export const MultiItemFieldMenuItem = <T,>({
clickableComponent={iconButton} clickableComponent={iconButton}
dropdownComponents={ dropdownComponents={
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{!isPrimary && ( {hasPrimaryButton && !isPrimary && (
<MenuItem <MenuItem
LeftIcon={IconBookmarkPlus} LeftIcon={IconBookmarkPlus}
text="Set as Primary" text="Set as Primary"

View File

@ -139,6 +139,12 @@ export type FieldActorMetadata = {
fieldName: string; fieldName: string;
}; };
export type FieldArrayMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
values: { label: string; value: string }[];
};
export type FieldPhonesMetadata = { export type FieldPhonesMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
@ -161,7 +167,8 @@ export type FieldMetadata =
| FieldTextMetadata | FieldTextMetadata
| FieldUuidMetadata | FieldUuidMetadata
| FieldAddressMetadata | FieldAddressMetadata
| FieldActorMetadata; | FieldActorMetadata
| FieldArrayMetadata;
export type FieldTextValue = string; export type FieldTextValue = string;
export type FieldUUidValue = string; export type FieldUUidValue = string;
@ -218,6 +225,8 @@ export type FieldActorValue = {
name: string; name: string;
}; };
export type FieldArrayValue = string[];
export type PhoneRecord = { number: string; countryCode: string }; export type PhoneRecord = { number: string; countryCode: string };
export type FieldPhonesValue = { export type FieldPhonesValue = {

View File

@ -4,6 +4,7 @@ import { FieldDefinition } from '../FieldDefinition';
import { import {
FieldActorMetadata, FieldActorMetadata,
FieldAddressMetadata, FieldAddressMetadata,
FieldArrayMetadata,
FieldBooleanMetadata, FieldBooleanMetadata,
FieldCurrencyMetadata, FieldCurrencyMetadata,
FieldDateMetadata, FieldDateMetadata,
@ -56,8 +57,6 @@ type AssertFieldMetadataFunction = <
? FieldNumberMetadata ? FieldNumberMetadata
: E extends 'PHONE' : E extends 'PHONE'
? FieldPhoneMetadata ? FieldPhoneMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: E extends 'RELATION' : E extends 'RELATION'
? FieldRelationMetadata ? FieldRelationMetadata
: E extends 'TEXT' : E extends 'TEXT'
@ -72,6 +71,10 @@ type AssertFieldMetadataFunction = <
? FieldTextMetadata ? FieldTextMetadata
: E extends 'ACTOR' : E extends 'ACTOR'
? FieldActorMetadata ? FieldActorMetadata
: E extends 'ARRAY'
? FieldArrayMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: never, : never,
>( >(
fieldType: E, fieldType: E,

View File

@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldArrayMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldArray = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldArrayMetadata> =>
field.type === FieldMetadataType.Array;

View File

@ -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;

View File

@ -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 { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldLink } from '../types/guards/isFieldLink'; import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldPhone } from '../types/guards/isFieldPhone'; import { isFieldPhone } from '../types/guards/isFieldPhone';
@ -33,6 +34,7 @@ export const getFieldButtonIcon = (
'workspaceMember') || 'workspaceMember') ||
isFieldLinks(fieldDefinition) || isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition) || isFieldEmails(fieldDefinition) ||
isFieldArray(fieldDefinition) ||
isFieldPhones(fieldDefinition) isFieldPhones(fieldDefinition)
) { ) {
return IconPencil; return IconPencil;

View File

@ -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 { isFieldActorValue } from '@/object-record/record-field/types/guards/isFieldActorValue';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; 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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; 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 ( return (
!isFieldArrayValue(fieldValue) ||
!isFieldMultiSelectValue(fieldValue, selectOptionValues) || !isFieldMultiSelectValue(fieldValue, selectOptionValues) ||
!isDefined(fieldValue) !isDefined(fieldValue)
); );

View File

@ -83,6 +83,9 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.MultiSelect: { case FieldMetadataType.MultiSelect: {
return null; return null;
} }
case FieldMetadataType.Array: {
return null;
}
case FieldMetadataType.RawJson: { case FieldMetadataType.RawJson: {
return null; return null;
} }

View File

@ -1,4 +1,5 @@
import { import {
IconBracketsContain,
IconComponent, IconComponent,
IllustrationIconCalendarEvent, IllustrationIconCalendarEvent,
IllustrationIconCalendarTime, IllustrationIconCalendarTime,
@ -183,6 +184,12 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = {
Icon: IllustrationIconSetting, Icon: IllustrationIconSetting,
category: 'Basic', category: 'Basic',
}, },
[FieldMetadataType.Array]: {
label: 'Array',
Icon: IconBracketsContain,
category: 'Basic',
exampleValue: ['value1', 'value2'],
},
} as const satisfies Record< } as const satisfies Record<
SettingsSupportedFieldType, SettingsSupportedFieldType,
SettingsFieldTypeConfig SettingsFieldTypeConfig

View File

@ -80,6 +80,7 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
`; `;
const previewableTypes = [ const previewableTypes = [
FieldMetadataType.Array,
FieldMetadataType.Address, FieldMetadataType.Address,
FieldMetadataType.Boolean, FieldMetadataType.Boolean,
FieldMetadataType.Currency, FieldMetadataType.Currency,

View File

@ -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 ? (
<ExpandableList isChipCountDisplayed>
{value?.map((item, index) => (
<StyledTag key={index}>
<OverflowingTextWithTooltip text={item} />
</StyledTag>
))}
</ExpandableList>
) : (
<StyledContainer>
{value?.map((item, index) => (
<StyledTag key={index} isInputDisplay={isInputDisplay}>
<OverflowingTextWithTooltip text={item} />
</StyledTag>
))}
</StyledContainer>
);
};

View File

@ -1,6 +1,6 @@
import { MouseEvent, useContext } from 'react';
import { styled } from '@linaria/react'; import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { MouseEvent, useContext } from 'react';
import { FONT_COMMON, THEME_COMMON, ThemeContext } from 'twenty-ui'; import { FONT_COMMON, THEME_COMMON, ThemeContext } from 'twenty-ui';
type RoundedLinkProps = { type RoundedLinkProps = {

View File

@ -230,6 +230,13 @@ const fieldEmailsMock = {
defaultValue: [{ primaryEmail: '', additionalEmails: {} }], defaultValue: [{ primaryEmail: '', additionalEmails: {} }],
}; };
const fieldArrayMock = {
name: 'fieldArray',
type: FieldMetadataType.ARRAY,
isNullable: true,
defaultValue: null,
};
const fieldPhonesMock = { const fieldPhonesMock = {
name: FIELD_PHONES_MOCK_NAME, name: FIELD_PHONES_MOCK_NAME,
type: FieldMetadataType.PHONES, type: FieldMetadataType.PHONES,
@ -267,6 +274,7 @@ export const fields = [
fieldRawJsonMock, fieldRawJsonMock,
fieldRichTextMock, fieldRichTextMock,
fieldActorMock, fieldActorMock,
fieldArrayMock,
]; ];
export const objectMetadataItemMock = { export const objectMetadataItemMock = {

View File

@ -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) },
},
});

View File

@ -1,3 +1,4 @@
export * from './array-filter.input-type';
export * from './big-float-filter.input-type'; export * from './big-float-filter.input-type';
export * from './big-int-filter.input-type'; export * from './big-int-filter.input-type';
export * from './boolean-filter.input-type'; export * from './boolean-filter.input-type';

View File

@ -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 { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
import { import {
ArrayFilterType,
BigFloatFilterType, BigFloatFilterType,
BooleanFilterType, BooleanFilterType,
DateFilterType, DateFilterType,
@ -45,6 +46,8 @@ export interface TypeOptions<T = any> {
isIdField?: boolean; isIdField?: boolean;
} }
const StringArrayScalarType = new GraphQLList(GraphQLString);
@Injectable() @Injectable()
export class TypeMapperService { export class TypeMapperService {
mapToScalarType( mapToScalarType(
@ -55,7 +58,6 @@ export class TypeMapperService {
if (isIdField || settings?.isForeignKey) { if (isIdField || settings?.isForeignKey) {
return GraphQLID; return GraphQLID;
} }
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([ const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
[FieldMetadataType.UUID, UUIDScalarType], [FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString], [FieldMetadataType.TEXT, GraphQLString],
@ -74,6 +76,10 @@ export class TypeMapperService {
[FieldMetadataType.NUMERIC, BigFloatScalarType], [FieldMetadataType.NUMERIC, BigFloatScalarType],
[FieldMetadataType.POSITION, PositionScalarType], [FieldMetadataType.POSITION, PositionScalarType],
[FieldMetadataType.RAW_JSON, RawJSONScalar], [FieldMetadataType.RAW_JSON, RawJSONScalar],
[
FieldMetadataType.ARRAY,
StringArrayScalarType as unknown as GraphQLScalarType,
],
[FieldMetadataType.RICH_TEXT, GraphQLString], [FieldMetadataType.RICH_TEXT, GraphQLString],
]); ]);
@ -111,6 +117,7 @@ export class TypeMapperService {
[FieldMetadataType.POSITION, FloatFilterType], [FieldMetadataType.POSITION, FloatFilterType],
[FieldMetadataType.RAW_JSON, RawJsonFilterType], [FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType], [FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.ARRAY, ArrayFilterType],
]); ]);
return typeFilterMapping.get(fieldMetadataType); return typeFilterMapping.get(fieldMetadataType);
@ -135,6 +142,7 @@ export class TypeMapperService {
[FieldMetadataType.POSITION, OrderByDirectionType], [FieldMetadataType.POSITION, OrderByDirectionType],
[FieldMetadataType.RAW_JSON, OrderByDirectionType], [FieldMetadataType.RAW_JSON, OrderByDirectionType],
[FieldMetadataType.RICH_TEXT, OrderByDirectionType], [FieldMetadataType.RICH_TEXT, OrderByDirectionType],
[FieldMetadataType.ARRAY, OrderByDirectionType],
]); ]);
return typeOrderByMapping.get(fieldMetadataType); return typeOrderByMapping.get(fieldMetadataType);

View File

@ -31,6 +31,7 @@ export const mapFieldMetadataToGraphqlQuery = (
FieldMetadataType.POSITION, FieldMetadataType.POSITION,
FieldMetadataType.RAW_JSON, FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT, FieldMetadataType.RICH_TEXT,
FieldMetadataType.ARRAY,
].includes(fieldType); ].includes(fieldType);
if (fieldIsSimpleValue) { if (fieldIsSimpleValue) {

View File

@ -70,6 +70,12 @@ describe('computeSchemaComponents', () => {
type: 'string', type: 'string',
format: 'date', format: 'date',
}, },
fieldArray: {
items: {
type: 'string',
},
type: 'array',
},
fieldBoolean: { fieldBoolean: {
type: 'boolean', type: 'boolean',
}, },
@ -246,6 +252,12 @@ describe('computeSchemaComponents', () => {
type: 'string', type: 'string',
format: 'date', format: 'date',
}, },
fieldArray: {
items: {
type: 'string',
},
type: 'array',
},
fieldBoolean: { fieldBoolean: {
type: 'boolean', type: 'boolean',
}, },
@ -421,6 +433,12 @@ describe('computeSchemaComponents', () => {
type: 'string', type: 'string',
format: 'date', format: 'date',
}, },
fieldArray: {
items: {
type: 'string',
},
type: 'array',
},
fieldBoolean: { fieldBoolean: {
type: 'boolean', type: 'boolean',
}, },

View File

@ -17,8 +17,8 @@ import {
FieldMetadataType, FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-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 { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { capitalize } from 'src/utils/capitalize';
type Property = OpenAPIV3_1.SchemaObject; type Property = OpenAPIV3_1.SchemaObject;
@ -124,6 +124,14 @@ const getSchemaComponentsProperties = ({
enum: field.options.map((option: { value: string }) => option.value), enum: field.options.map((option: { value: string }) => option.value),
}; };
break; break;
case FieldMetadataType.ARRAY:
itemProperty = {
type: 'array',
items: {
type: 'string',
},
};
break;
case FieldMetadataType.RATING: case FieldMetadataType.RATING:
itemProperty = { itemProperty = {
type: 'string', type: 'string',

View File

@ -46,6 +46,7 @@ export enum FieldMetadataType {
RAW_JSON = 'RAW_JSON', RAW_JSON = 'RAW_JSON',
RICH_TEXT = 'RICH_TEXT', RICH_TEXT = 'RICH_TEXT',
ACTOR = 'ACTOR', ACTOR = 'ACTOR',
ARRAY = 'ARRAY',
} }
@Entity('fieldMetadata') @Entity('fieldMetadata')

View File

@ -29,7 +29,8 @@ export type BasicFieldMetadataType =
| FieldMetadataType.POSITION | FieldMetadataType.POSITION
| FieldMetadataType.DATE_TIME | FieldMetadataType.DATE_TIME
| FieldMetadataType.DATE | FieldMetadataType.DATE
| FieldMetadataType.POSITION; | FieldMetadataType.POSITION
| FieldMetadataType.ARRAY;
@Injectable() @Injectable()
export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicFieldMetadataType> { export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicFieldMetadataType> {
@ -48,6 +49,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
action: WorkspaceMigrationColumnActionType.CREATE, action: WorkspaceMigrationColumnActionType.CREATE,
columnName, columnName,
columnType: fieldMetadataTypeToColumnType(fieldMetadata.type), columnType: fieldMetadataTypeToColumnType(fieldMetadata.type),
isArray: fieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: fieldMetadata.isNullable ?? true, isNullable: fieldMetadata.isNullable ?? true,
defaultValue: serializedDefaultValue, defaultValue: serializedDefaultValue,
}, },
@ -81,6 +83,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
currentColumnDefinition: { currentColumnDefinition: {
columnName: currentColumnName, columnName: currentColumnName,
columnType: fieldMetadataTypeToColumnType(currentFieldMetadata.type), columnType: fieldMetadataTypeToColumnType(currentFieldMetadata.type),
isArray: currentFieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: currentFieldMetadata.isNullable ?? true, isNullable: currentFieldMetadata.isNullable ?? true,
defaultValue: serializeDefaultValue( defaultValue: serializeDefaultValue(
currentFieldMetadata.defaultValue, currentFieldMetadata.defaultValue,
@ -89,6 +92,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
alteredColumnDefinition: { alteredColumnDefinition: {
columnName: alteredColumnName, columnName: alteredColumnName,
columnType: fieldMetadataTypeToColumnType(alteredFieldMetadata.type), columnType: fieldMetadataTypeToColumnType(alteredFieldMetadata.type),
isArray: alteredFieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: alteredFieldMetadata.isNullable ?? true, isNullable: alteredFieldMetadata.isNullable ?? true,
defaultValue: serializedDefaultValue, defaultValue: serializedDefaultValue,
}, },

View File

@ -16,6 +16,7 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
return 'uuid'; return 'uuid';
case FieldMetadataType.TEXT: case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT: case FieldMetadataType.RICH_TEXT:
case FieldMetadataType.ARRAY:
return 'text'; return 'text';
case FieldMetadataType.PHONE: case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL: case FieldMetadataType.EMAIL:

View File

@ -97,6 +97,7 @@ export class WorkspaceMigrationFactory {
], ],
[FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }], [FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }],
[FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }], [FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }],
[FieldMetadataType.ARRAY, { factory: this.basicColumnActionFactory }],
[ [
FieldMetadataType.EMAILS, FieldMetadataType.EMAILS,
{ factory: this.compositeColumnActionFactory }, { factory: this.compositeColumnActionFactory },

View File

@ -22,6 +22,7 @@ export {
IconBookmark, IconBookmark,
IconBookmarkPlus, IconBookmarkPlus,
IconBox, IconBox,
IconBracketsContain,
IconBrandGithub, IconBrandGithub,
IconBrandGoogle, IconBrandGoogle,
IconBrandLinkedin, IconBrandLinkedin,