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',
Select = 'SELECT',
Text = 'TEXT',
Uuid = 'UUID'
Uuid = 'UUID',
Array = 'ARRAY'
}
export enum FileFolder {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
import { ArrayFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ArrayFieldDisplay';
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
@ -10,6 +11,7 @@ import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
@ -104,6 +106,8 @@ export const FieldDisplay = () => {
<RichTextFieldDisplay />
) : isFieldActor(fieldDefinition) ? (
<ActorFieldDisplay />
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldDisplay />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay />
) : 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 { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput';
import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput';
@ -187,6 +189,8 @@ export const FieldInput = ({
/>
) : isFieldRichText(fieldDefinition) ? (
<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 { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue';
@ -124,6 +126,9 @@ export const usePersistField = () => {
isFieldRawJson(fieldDefinition) &&
isFieldRawJsonValue(valueToPersist);
const fieldIsArray =
isFieldArray(fieldDefinition) && isFieldArrayValue(valueToPersist);
const isValuePersistable =
fieldIsRelationToOneObject ||
fieldIsText ||
@ -143,7 +148,8 @@ export const usePersistField = () => {
fieldIsSelect ||
fieldIsMultiSelect ||
fieldIsAddress ||
fieldIsRawJson;
fieldIsRawJson ||
fieldIsArray;
if (isValuePersistable) {
const fieldName = fieldDefinition.metadata.fieldName;

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { FieldDefinition } from '../FieldDefinition';
import {
FieldActorMetadata,
FieldAddressMetadata,
FieldArrayMetadata,
FieldBooleanMetadata,
FieldCurrencyMetadata,
FieldDateMetadata,
@ -56,23 +57,25 @@ type AssertFieldMetadataFunction = <
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT'
? FieldTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: never,
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT'
? FieldTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: E extends 'ARRAY'
? FieldArrayMetadata
: E extends 'PHONES'
? FieldPhonesMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (

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

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 { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
@ -76,8 +78,9 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldMultiSelect(fieldDefinition)) {
if (isFieldMultiSelect(fieldDefinition) || isFieldArray(fieldDefinition)) {
return (
!isFieldArrayValue(fieldValue) ||
!isFieldMultiSelectValue(fieldValue, selectOptionValues) ||
!isDefined(fieldValue)
);

View File

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

View File

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

View File

@ -80,6 +80,7 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
`;
const previewableTypes = [
FieldMetadataType.Array,
FieldMetadataType.Address,
FieldMetadataType.Boolean,
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 { isNonEmptyString } from '@sniptt/guards';
import { MouseEvent, useContext } from 'react';
import { FONT_COMMON, THEME_COMMON, ThemeContext } from 'twenty-ui';
type RoundedLinkProps = {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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