mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-26 13:42:44 +03:00
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:
parent
bc99cfec98
commit
8208a3e976
@ -376,7 +376,8 @@ export enum FieldMetadataType {
|
||||
RichText = 'RICH_TEXT',
|
||||
Select = 'SELECT',
|
||||
Text = 'TEXT',
|
||||
Uuid = 'UUID'
|
||||
Uuid = 'UUID',
|
||||
Array = 'ARRAY'
|
||||
}
|
||||
|
||||
export enum FileFolder {
|
||||
|
@ -260,6 +260,7 @@ export type FieldConnection = {
|
||||
export enum FieldMetadataType {
|
||||
Actor = 'ACTOR',
|
||||
Address = 'ADDRESS',
|
||||
Array = 'ARRAY',
|
||||
Boolean = 'BOOLEAN',
|
||||
Currency = 'CURRENCY',
|
||||
Date = 'DATE',
|
||||
|
@ -98,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
|
||||
return 'RATING';
|
||||
case FieldMetadataType.Actor:
|
||||
return 'ACTOR';
|
||||
case FieldMetadataType.Array:
|
||||
return 'ARRAY';
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
|
||||
FieldMetadataType.Position,
|
||||
FieldMetadataType.RawJson,
|
||||
FieldMetadataType.RichText,
|
||||
FieldMetadataType.Array,
|
||||
].includes(fieldType);
|
||||
|
||||
if (fieldIsSimpleValue) {
|
||||
|
@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({
|
||||
'LINKS',
|
||||
'ADDRESS',
|
||||
'ACTOR',
|
||||
'ARRAY',
|
||||
'PHONES',
|
||||
].includes(filterDefinitionUsedInDropdown.type) && (
|
||||
<ObjectFilterDropdownTextSearchInput />
|
||||
|
@ -16,4 +16,5 @@ export type FilterType =
|
||||
| 'SELECT'
|
||||
| 'RATING'
|
||||
| 'MULTI_SELECT'
|
||||
| 'ACTOR';
|
||||
| 'ACTOR'
|
||||
| 'ARRAY';
|
||||
|
@ -22,6 +22,7 @@ export const getOperandsForFilterType = (
|
||||
case 'LINK':
|
||||
case 'LINKS':
|
||||
case 'ACTOR':
|
||||
case 'ARRAY':
|
||||
case 'PHONES':
|
||||
return [
|
||||
ViewFilterOperand.Contains,
|
||||
|
@ -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) ? (
|
||||
|
@ -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} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
)}
|
||||
|
@ -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"
|
||||
|
@ -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 = {
|
||||
|
@ -4,6 +4,7 @@ import { FieldDefinition } from '../FieldDefinition';
|
||||
import {
|
||||
FieldActorMetadata,
|
||||
FieldAddressMetadata,
|
||||
FieldArrayMetadata,
|
||||
FieldBooleanMetadata,
|
||||
FieldCurrencyMetadata,
|
||||
FieldDateMetadata,
|
||||
@ -56,8 +57,6 @@ type AssertFieldMetadataFunction = <
|
||||
? FieldNumberMetadata
|
||||
: E extends 'PHONE'
|
||||
? FieldPhoneMetadata
|
||||
: E extends 'PHONES'
|
||||
? FieldPhonesMetadata
|
||||
: E extends 'RELATION'
|
||||
? FieldRelationMetadata
|
||||
: E extends 'TEXT'
|
||||
@ -72,6 +71,10 @@ type AssertFieldMetadataFunction = <
|
||||
? FieldTextMetadata
|
||||
: E extends 'ACTOR'
|
||||
? FieldActorMetadata
|
||||
: E extends 'ARRAY'
|
||||
? FieldArrayMetadata
|
||||
: E extends 'PHONES'
|
||||
? FieldPhonesMetadata
|
||||
: never,
|
||||
>(
|
||||
fieldType: E,
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -83,6 +83,9 @@ export const generateEmptyFieldValue = (
|
||||
case FieldMetadataType.MultiSelect: {
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.Array: {
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.RawJson: {
|
||||
return null;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -80,6 +80,7 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
|
||||
`;
|
||||
|
||||
const previewableTypes = [
|
||||
FieldMetadataType.Array,
|
||||
FieldMetadataType.Address,
|
||||
FieldMetadataType.Boolean,
|
||||
FieldMetadataType.Currency,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 = {
|
||||
|
@ -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 = {
|
||||
|
@ -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) },
|
||||
},
|
||||
});
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -31,6 +31,7 @@ export const mapFieldMetadataToGraphqlQuery = (
|
||||
FieldMetadataType.POSITION,
|
||||
FieldMetadataType.RAW_JSON,
|
||||
FieldMetadataType.RICH_TEXT,
|
||||
FieldMetadataType.ARRAY,
|
||||
].includes(fieldType);
|
||||
|
||||
if (fieldIsSimpleValue) {
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -46,6 +46,7 @@ export enum FieldMetadataType {
|
||||
RAW_JSON = 'RAW_JSON',
|
||||
RICH_TEXT = 'RICH_TEXT',
|
||||
ACTOR = 'ACTOR',
|
||||
ARRAY = 'ARRAY',
|
||||
}
|
||||
|
||||
@Entity('fieldMetadata')
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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:
|
||||
|
@ -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 },
|
||||
|
@ -22,6 +22,7 @@ export {
|
||||
IconBookmark,
|
||||
IconBookmarkPlus,
|
||||
IconBox,
|
||||
IconBracketsContain,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
IconBrandLinkedin,
|
||||
|
Loading…
Reference in New Issue
Block a user