4778 multi select field front implement multi select type (#4887)

This commit is contained in:
martmull
2024-04-11 12:57:08 +02:00
committed by GitHub
parent aecf8783a0
commit a7fcc5d47e
42 changed files with 698 additions and 254 deletions

View File

@@ -18,12 +18,6 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const logger = loggerLink(() => 'Twenty-Refresh'); const logger = loggerLink(() => 'Twenty-Refresh');
/**
* Renew token mutation with custom apollo client
* @param uri string | UriFunction | undefined
* @param refreshToken string
* @returns RenewTokenMutation
*/
const renewTokenMutation = async ( const renewTokenMutation = async (
uri: string | UriFunction | undefined, uri: string | UriFunction | undefined,
refreshToken: string, refreshToken: string,
@@ -54,11 +48,6 @@ const renewTokenMutation = async (
return data; return data;
}; };
/**
* Renew token and update cookie storage
* @param uri string | UriFunction | undefined
* @returns TokenPair
*/
export const renewToken = async ( export const renewToken = async (
uri: string | UriFunction | undefined, uri: string | UriFunction | undefined,
tokenPair: AuthTokenPair | undefined | null, tokenPair: AuthTokenPair | undefined | null,

View File

@@ -1,10 +1,9 @@
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption.ts';
import { Field } from '~/generated/graphql'; import { Field } from '~/generated/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldMetadataItem } from '../types/FieldMetadataItem'; import { FieldMetadataItem } from '../types/FieldMetadataItem';
import { FieldMetadataOption } from '../types/FieldMetadataOption';
import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput'; import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput';
import { useCreateOneFieldMetadataItem } from './useCreateOneFieldMetadataItem'; import { useCreateOneFieldMetadataItem } from './useCreateOneFieldMetadataItem';
@@ -17,22 +16,22 @@ export const useFieldMetadataItem = () => {
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem(); const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
const createMetadataField = ( const createMetadataField = (
input: Pick<Field, 'label' | 'icon' | 'description' | 'defaultValue'> & { input: Pick<
defaultValue?: unknown; Field,
'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options'
> & {
objectMetadataId: string; objectMetadataId: string;
options?: Omit<FieldMetadataOption, 'id'>[];
type: FieldMetadataType;
}, },
) => { ) => {
const formatedInput = formatFieldMetadataItemInput(input); const formattedInput = formatFieldMetadataItemInput(input);
const defaultValue = input.defaultValue const defaultValue = input.defaultValue
? typeof input.defaultValue == 'string' ? typeof input.defaultValue == 'string'
? `'${input.defaultValue}'` ? `'${input.defaultValue}'`
: input.defaultValue : input.defaultValue
: formatedInput.defaultValue ?? undefined; : formattedInput.defaultValue ?? undefined;
return createOneFieldMetadataItem({ return createOneFieldMetadataItem({
...formatedInput, ...formattedInput,
defaultValue, defaultValue,
objectMetadataId: input.objectMetadataId, objectMetadataId: input.objectMetadataId,
type: input.type, type: input.type,
@@ -42,17 +41,21 @@ export const useFieldMetadataItem = () => {
const editMetadataField = ( const editMetadataField = (
input: Pick< input: Pick<
Field, Field,
'id' | 'label' | 'icon' | 'description' | 'defaultValue' | 'id'
> & { | 'label'
options?: FieldMetadataOption[]; | 'icon'
}, | 'description'
| 'defaultValue'
| 'type'
| 'options'
>,
) => { ) => {
const formatedInput = formatFieldMetadataItemInput(input); const formattedInput = formatFieldMetadataItemInput(input);
const defaultValue = input.defaultValue const defaultValue = input.defaultValue
? typeof input.defaultValue == 'string' ? typeof input.defaultValue == 'string'
? `'${input.defaultValue}'` ? `'${input.defaultValue}'`
: input.defaultValue : input.defaultValue
: formatedInput.defaultValue ?? undefined; : formattedInput.defaultValue ?? undefined;
return updateOneFieldMetadataItem({ return updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: input.id, fieldMetadataIdToUpdate: input.id,
@@ -61,7 +64,7 @@ export const useFieldMetadataItem = () => {
defaultValue, defaultValue,
// In Edit mode, all options need an id, // In Edit mode, all options need an id,
// so we generate an id for newly created options. // so we generate an id for newly created options.
options: input.options?.map((option) => options: input.options?.map((option: FieldMetadataOption) =>
option.id ? option : { ...option, id: v4() }, option.id ? option : { ...option, id: v4() },
), ),
}), }),

View File

@@ -1,3 +1,5 @@
import { FieldMetadataType } from '~/generated-metadata/graphql.ts';
import { import {
formatFieldMetadataItemInput, formatFieldMetadataItemInput,
getOptionValueFromLabel, getOptionValueFromLabel,
@@ -46,6 +48,7 @@ describe('formatFieldMetadataItemInput', () => {
const input = { const input = {
label: 'Example Label', label: 'Example Label',
icon: 'example-icon', icon: 'example-icon',
type: FieldMetadataType.Select,
description: 'Example description', description: 'Example description',
options: [ options: [
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
@@ -86,6 +89,70 @@ describe('formatFieldMetadataItemInput', () => {
const input = { const input = {
label: 'Example Label', label: 'Example Label',
icon: 'example-icon', icon: 'example-icon',
type: FieldMetadataType.Select,
description: 'Example description',
};
const expected = {
description: 'Example description',
icon: 'example-icon',
label: 'Example Label',
name: 'exampleLabel',
options: undefined,
defaultValue: undefined,
};
const result = formatFieldMetadataItemInput(input);
expect(result).toEqual(expected);
});
it('should format the field metadata item multi select input correctly', () => {
const input = {
label: 'Example Label',
icon: 'example-icon',
type: FieldMetadataType.MultiSelect,
description: 'Example description',
options: [
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
{ id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true },
],
};
const expected = {
description: 'Example description',
icon: 'example-icon',
label: 'Example Label',
name: 'exampleLabel',
options: [
{
id: '1',
label: 'Option 1',
color: 'red',
position: 0,
value: 'OPTION_1',
},
{
id: '2',
label: 'Option 2',
color: 'blue',
position: 1,
value: 'OPTION_2',
},
],
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
};
const result = formatFieldMetadataItemInput(input);
expect(result).toEqual(expected);
});
it('should handle multi select input without options', () => {
const input = {
label: 'Example Label',
icon: 'example-icon',
type: FieldMetadataType.MultiSelect,
description: 'Example description', description: 'Example description',
}; };

View File

@@ -1,7 +1,8 @@
import toCamelCase from 'lodash.camelcase'; import toCamelCase from 'lodash.camelcase';
import toSnakeCase from 'lodash.snakecase'; import toSnakeCase from 'lodash.snakecase';
import { Field } from '~/generated-metadata/graphql'; import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined.ts';
import { FieldMetadataOption } from '../types/FieldMetadataOption'; import { FieldMetadataOption } from '../types/FieldMetadataOption';
@@ -20,20 +21,36 @@ export const getOptionValueFromLabel = (label: string) => {
}; };
export const formatFieldMetadataItemInput = ( export const formatFieldMetadataItemInput = (
input: Pick<Field, 'label' | 'icon' | 'description' | 'defaultValue'> & { input: Pick<
options?: FieldMetadataOption[]; Field,
}, 'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options'
>,
) => { ) => {
const defaultOption = input.options?.find((option) => option.isDefault); const options = input.options as FieldMetadataOption[];
let defaultValue = input.defaultValue;
if (input.type === FieldMetadataType.MultiSelect) {
const defaultOptions = options?.filter((option) => option.isDefault);
if (isDefined(defaultOptions)) {
defaultValue = defaultOptions.map(
(defaultOption) => `'${getOptionValueFromLabel(defaultOption.label)}'`,
);
}
}
if (input.type === FieldMetadataType.Select) {
const defaultOption = options?.find((option) => option.isDefault);
defaultValue = isDefined(defaultOption)
? `'${getOptionValueFromLabel(defaultOption.label)}'`
: undefined;
}
// Check if options has unique values // Check if options has unique values
if (input.options !== undefined) { if (options !== undefined) {
// Compute the values based on the label // Compute the values based on the label
const values = input.options.map((option) => const values = options.map((option) =>
getOptionValueFromLabel(option.label), getOptionValueFromLabel(option.label),
); );
if (new Set(values).size !== input.options.length) { if (new Set(values).size !== options.length) {
throw new Error( throw new Error(
`Options must have unique values, but contains the following duplicates ${values.join( `Options must have unique values, but contains the following duplicates ${values.join(
', ', ', ',
@@ -43,14 +60,12 @@ export const formatFieldMetadataItemInput = (
} }
return { return {
defaultValue: defaultOption defaultValue,
? `'${getOptionValueFromLabel(defaultOption.label)}'`
: input.defaultValue,
description: input.description?.trim() ?? null, description: input.description?.trim() ?? null,
icon: input.icon, icon: input.icon,
label: input.label.trim(), label: input.label.trim(),
name: toCamelCase(input.label.trim()), name: toCamelCase(input.label.trim()),
options: input.options?.map((option, index) => ({ options: options?.map((option, index) => ({
color: option.color, color: option.color,
id: option.id, id: option.id,
label: option.label.trim(), label: option.label.trim(),

View File

@@ -21,6 +21,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Address, FieldMetadataType.Address,
FieldMetadataType.Relation, FieldMetadataType.Relation,
FieldMetadataType.Select, FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Currency, FieldMetadataType.Currency,
].includes(field.type) ].includes(field.type)
) { ) {
@@ -76,6 +77,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'RELATION'; return 'RELATION';
case FieldMetadataType.Select: case FieldMetadataType.Select:
return 'SELECT'; return 'SELECT';
case FieldMetadataType.MultiSelect:
return 'MULTI_SELECT';
case FieldMetadataType.Address: case FieldMetadataType.Address:
return 'ADDRESS'; return 'ADDRESS';
default: default:

View File

@@ -9,10 +9,10 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput';
export type FormatRelationMetadataInputParams = { export type FormatRelationMetadataInputParams = {
relationType: RelationType; relationType: RelationType;
field: Pick<Field, 'label' | 'icon' | 'description'>; field: Pick<Field, 'label' | 'icon' | 'description' | 'type'>;
objectMetadataId: string; objectMetadataId: string;
connect: { connect: {
field: Pick<Field, 'label' | 'icon'>; field: Pick<Field, 'label' | 'icon' | 'type'>;
objectMetadataId: string; objectMetadataId: string;
}; };
}; };

View File

@@ -36,6 +36,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
'BOOLEAN', 'BOOLEAN',
'RATING', 'RATING',
'SELECT', 'SELECT',
'MULTI_SELECT',
'POSITION', 'POSITION',
'RAW_JSON', 'RAW_JSON',
] as FieldMetadataType[] ] as FieldMetadataType[]

View File

@@ -15,18 +15,20 @@ export const getRecordFromRecordNode = <T extends ObjectRecord>({
return [fieldName, value]; return [fieldName, value];
} }
if (typeof value === 'object' && isDefined(value.edges)) { if (Array.isArray(value)) {
return [ return [fieldName, value];
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
];
} }
if (typeof value === 'object' && !isDefined(value.edges)) { if (typeof value !== 'object') {
return [fieldName, getRecordFromRecordNode<T>({ recordNode: value })]; return [fieldName, value];
} }
return [fieldName, value]; return isDefined(value.edges)
? [
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
]
: [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
}), }),
), ),
id: recordNode.id, id: recordNode.id,

View File

@@ -6,7 +6,10 @@ import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import {
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize'; import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize';
@@ -65,12 +68,16 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
return undefined; return undefined;
} }
if (Array.isArray(value)) { if (
const objectMetadataItem = objectMetadataItems.find( field.type === FieldMetadataType.Relation &&
(objectMetadataItem) => objectMetadataItem.namePlural === fieldName, field.relationDefinition?.direction ===
RelationDefinitionType.OneToMany
) {
const oneToManyObjectMetadataItem = objectMetadataItems.find(
(item) => item.namePlural === fieldName,
); );
if (!objectMetadataItem) { if (!oneToManyObjectMetadataItem) {
return undefined; return undefined;
} }
@@ -78,7 +85,7 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
fieldName, fieldName,
getRecordConnectionFromRecords({ getRecordConnectionFromRecords({
objectMetadataItems, objectMetadataItems,
objectMetadataItem: objectMetadataItem, objectMetadataItem: oneToManyObjectMetadataItem,
records: value as ObjectRecord[], records: value as ObjectRecord[],
queryFields: queryFields:
queryFields?.[fieldName] === true || queryFields?.[fieldName] === true ||

View File

@@ -9,4 +9,5 @@ export type FilterType =
| 'LINK' | 'LINK'
| 'RELATION' | 'RELATION'
| 'ADDRESS' | 'ADDRESS'
| 'SELECT'; | 'SELECT'
| 'MULTI_SELECT';

View File

@@ -1,8 +1,5 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@@ -10,7 +7,9 @@ import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyF
import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay'; import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay';
import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay'; import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay';
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay'; import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay';
import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay'; import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay';
import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx';
import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay'; import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay';
import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay'; import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay';
import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay'; import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay';
@@ -23,8 +22,10 @@ import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldFullName } from '../types/guards/isFieldFullName'; import { isFieldFullName } from '../types/guards/isFieldFullName';
import { isFieldLink } from '../types/guards/isFieldLink'; import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect.ts';
import { isFieldNumber } from '../types/guards/isFieldNumber'; import { isFieldNumber } from '../types/guards/isFieldNumber';
import { isFieldPhone } from '../types/guards/isFieldPhone'; import { isFieldPhone } from '../types/guards/isFieldPhone';
import { isFieldRawJson } from '../types/guards/isFieldRawJson';
import { isFieldRelation } from '../types/guards/isFieldRelation'; import { isFieldRelation } from '../types/guards/isFieldRelation';
import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText'; import { isFieldText } from '../types/guards/isFieldText';
@@ -60,6 +61,8 @@ export const FieldDisplay = () => {
<PhoneFieldDisplay /> <PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay /> <SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay /> <AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? ( ) : isFieldRawJson(fieldDefinition) ? (

View File

@@ -2,10 +2,12 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput'; import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
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';
@@ -131,6 +133,8 @@ export const FieldInput = ({
<RatingFieldInput onSubmit={onSubmit} /> <RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} /> <SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput <AddressFieldInput
onEnter={onEnter} onEnter={onEnter}

View File

@@ -5,6 +5,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@@ -86,6 +88,10 @@ export const usePersistField = () => {
const fieldIsSelect = const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
const fieldIsMultiSelect =
isFieldMultiSelect(fieldDefinition) &&
isFieldMultiSelectValue(valueToPersist);
const fieldIsAddress = const fieldIsAddress =
isFieldAddress(fieldDefinition) && isFieldAddress(fieldDefinition) &&
isFieldAddressValue(valueToPersist); isFieldAddressValue(valueToPersist);
@@ -94,7 +100,7 @@ export const usePersistField = () => {
isFieldRawJson(fieldDefinition) && isFieldRawJson(fieldDefinition) &&
isFieldRawJsonValue(valueToPersist); isFieldRawJsonValue(valueToPersist);
if ( const isValuePersistable =
fieldIsRelation || fieldIsRelation ||
fieldIsText || fieldIsText ||
fieldIsBoolean || fieldIsBoolean ||
@@ -107,9 +113,11 @@ export const usePersistField = () => {
fieldIsCurrency || fieldIsCurrency ||
fieldIsFullName || fieldIsFullName ||
fieldIsSelect || fieldIsSelect ||
fieldIsMultiSelect ||
fieldIsAddress || fieldIsAddress ||
fieldIsRawJson fieldIsRawJson;
) {
if (isValuePersistable === true) {
const fieldName = fieldDefinition.metadata.fieldName; const fieldName = fieldDefinition.metadata.fieldName;
set( set(
recordStoreFamilySelector({ recordId: entityId, fieldName }), recordStoreFamilySelector({ recordId: entityId, fieldName }),

View File

@@ -0,0 +1,32 @@
import styled from '@emotion/styled';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts';
import { Tag } from '@/ui/display/tag/components/Tag';
const StyledTagContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const MultiSelectFieldDisplay = () => {
const { fieldValues, fieldDefinition } = useMultiSelectField();
const selectedOptions = fieldValues
? fieldDefinition.metadata.options.filter((option) =>
fieldValues.includes(option.value),
)
: [];
return selectedOptions ? (
<StyledTagContainer>
{selectedOptions.map((selectedOption, index) => (
<Tag
key={index}
color={selectedOption.color}
text={selectedOption.label}
/>
))}
</StyledTagContainer>
) : (
<></>
);
};

View File

@@ -0,0 +1,50 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext.ts';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField.ts';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput.ts';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata.ts';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector.ts';
import { FieldMetadataType } from '~/generated/graphql.tsx';
export const useMultiSelectField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MultiSelect,
isFieldMultiSelect,
fieldDefinition,
);
const { fieldName } = fieldDefinition.metadata;
const [fieldValues, setFieldValue] = useRecoilState<FieldMultiSelectValue>(
recordStoreFamilySelector({
recordId: entityId,
fieldName: fieldName,
}),
);
const fieldMultiSelectValues = isFieldMultiSelectValue(fieldValues)
? fieldValues
: null;
const persistField = usePersistField();
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldMultiSelectValue>(`${entityId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
return {
fieldDefinition,
persistField,
fieldValues: fieldMultiSelectValues,
draftValue,
setDraftValue,
setFieldValue,
hotkeyScope,
};
};

View File

@@ -0,0 +1,93 @@
import { useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
export type MultiSelectFieldInputProps = {
onSubmit?: FieldInputEvent;
onCancel?: () => void;
};
export const MultiSelectFieldInput = ({
onCancel,
}: MultiSelectFieldInputProps) => {
const { persistField, fieldDefinition, fieldValues } = useMultiSelectField();
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOptions = fieldDefinition.metadata.options.filter(
(option) => fieldValues?.includes(option.value),
);
const optionsInDropDown = fieldDefinition.metadata.options;
const formatNewSelectedOptions = (value: string) => {
const selectedOptionsValues = selectedOptions.map(
(selectedOption) => selectedOption.value,
);
if (!selectedOptionsValues.includes(value)) {
return [value, ...selectedOptionsValues];
} else {
return selectedOptionsValues.filter(
(selectedOptionsValue) => selectedOptionsValue !== value,
);
}
};
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
}
},
});
return (
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={fieldValues?.includes(option.value) || false}
text={option.label}
color={option.color}
onClick={() =>
persistField(formatNewSelectedOptions(option.value))
}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
);
};

View File

@@ -7,6 +7,7 @@ import {
FieldEmailValue, FieldEmailValue,
FieldFullNameValue, FieldFullNameValue,
FieldLinkValue, FieldLinkValue,
FieldMultiSelectValue,
FieldNumberValue, FieldNumberValue,
FieldPhoneValue, FieldPhoneValue,
FieldRatingValue, FieldRatingValue,
@@ -22,6 +23,7 @@ export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string; export type FieldPhoneDraftValue = string;
export type FieldEmailDraftValue = string; export type FieldEmailDraftValue = string;
export type FieldSelectDraftValue = string; export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string; export type FieldRelationDraftValue = string;
export type FieldLinkDraftValue = { url: string; label: string }; export type FieldLinkDraftValue = { url: string; label: string };
export type FieldCurrencyDraftValue = { export type FieldCurrencyDraftValue = {
@@ -64,8 +66,10 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldRatingValue ? FieldRatingValue
: FieldValue extends FieldSelectValue : FieldValue extends FieldSelectValue
? FieldSelectDraftValue ? FieldSelectDraftValue
: FieldValue extends FieldRelationValue : FieldValue extends FieldMultiSelectValue
? FieldRelationDraftValue ? FieldMultiSelectDraftValue
: FieldValue extends FieldAddressValue : FieldValue extends FieldRelationValue
? FieldAddressDraftValue ? FieldRelationDraftValue
: never; : FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;

View File

@@ -103,6 +103,12 @@ export type FieldSelectMetadata = {
options: { label: string; color: ThemeColor; value: string }[]; options: { label: string; color: ThemeColor; value: string }[];
}; };
export type FieldMultiSelectMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
options: { label: string; color: ThemeColor; value: string }[];
};
export type FieldMetadata = export type FieldMetadata =
| FieldBooleanMetadata | FieldBooleanMetadata
| FieldCurrencyMetadata | FieldCurrencyMetadata
@@ -115,6 +121,7 @@ export type FieldMetadata =
| FieldRatingMetadata | FieldRatingMetadata
| FieldRelationMetadata | FieldRelationMetadata
| FieldSelectMetadata | FieldSelectMetadata
| FieldMultiSelectMetadata
| FieldTextMetadata | FieldTextMetadata
| FieldUuidMetadata | FieldUuidMetadata
| FieldAddressMetadata; | FieldAddressMetadata;
@@ -145,6 +152,7 @@ export type FieldAddressValue = {
}; };
export type FieldRatingValue = (typeof RATING_VALUES)[number]; export type FieldRatingValue = (typeof RATING_VALUES)[number];
export type FieldSelectValue = string | null; export type FieldSelectValue = string | null;
export type FieldMultiSelectValue = string[] | null;
export type FieldRelationValue = EntityForSelect | null; export type FieldRelationValue = EntityForSelect | null;
export type FieldJsonValue = string; export type FieldJsonValue = string;

View File

@@ -10,6 +10,7 @@ import {
FieldFullNameMetadata, FieldFullNameMetadata,
FieldLinkMetadata, FieldLinkMetadata,
FieldMetadata, FieldMetadata,
FieldMultiSelectMetadata,
FieldNumberMetadata, FieldNumberMetadata,
FieldPhoneMetadata, FieldPhoneMetadata,
FieldRatingMetadata, FieldRatingMetadata,
@@ -34,27 +35,29 @@ type AssertFieldMetadataFunction = <
? FieldEmailMetadata ? FieldEmailMetadata
: E extends 'SELECT' : E extends 'SELECT'
? FieldSelectMetadata ? FieldSelectMetadata
: E extends 'RATING' : E extends 'MULTI_SELECT'
? FieldRatingMetadata ? FieldMultiSelectMetadata
: E extends 'LINK' : E extends 'RATING'
? FieldLinkMetadata ? FieldRatingMetadata
: E extends 'NUMBER' : E extends 'LINK'
? FieldNumberMetadata ? FieldLinkMetadata
: E extends 'PHONE' : E extends 'NUMBER'
? FieldPhoneMetadata ? FieldNumberMetadata
: E extends 'PROBABILITY' : E extends 'PHONE'
? FieldRatingMetadata ? FieldPhoneMetadata
: E extends 'RELATION' : E extends 'PROBABILITY'
? FieldRelationMetadata ? FieldRatingMetadata
: E extends 'TEXT' : E extends 'RELATION'
? FieldTextMetadata ? FieldRelationMetadata
: E extends 'UUID' : E extends 'TEXT'
? FieldUuidMetadata ? FieldTextMetadata
: E extends 'ADDRESS' : E extends 'UUID'
? FieldAddressMetadata ? FieldUuidMetadata
: E extends 'RAW_JSON' : E extends 'ADDRESS'
? FieldRawJsonMetadata ? FieldAddressMetadata
: never, : E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
>( >(
fieldType: E, fieldType: E,
fieldTypeGuard: ( fieldTypeGuard: (

View File

@@ -0,0 +1,11 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition.ts';
import {
FieldMetadata,
FieldMultiSelectMetadata,
} from '@/object-record/record-field/types/FieldMetadata.ts';
import { FieldMetadataType } from '~/generated-metadata/graphql.ts';
export const isFieldMultiSelect = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldMultiSelectMetadata> =>
field.type === FieldMetadataType.MultiSelect;

View File

@@ -0,0 +1,9 @@
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts';
import { multiSelectFieldValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts';
export const isFieldMultiSelectValue = (
fieldValue: unknown,
options?: string[],
): fieldValue is FieldMultiSelectValue => {
return multiSelectFieldValueSchema(options).safeParse(fieldValue).success;
};

View File

@@ -11,6 +11,8 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue'; import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
@@ -54,6 +56,13 @@ export const isFieldValueEmpty = ({
); );
} }
if (isFieldMultiSelect(fieldDefinition)) {
return (
!isFieldMultiSelectValue(fieldValue, selectOptionValues) ||
!isDefined(fieldValue)
);
}
if (isFieldCurrency(fieldDefinition)) { if (isFieldCurrency(fieldDefinition)) {
return ( return (
!isFieldCurrencyValue(fieldValue) || !isFieldCurrencyValue(fieldValue) ||

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
export const multiSelectFieldValueSchema = (
options?: string[],
): z.ZodType<FieldMultiSelectValue> =>
options?.length
? z.array(z.enum(options as [string, ...string[]])).nullable()
: z.array(z.string()).nullable();

View File

@@ -143,6 +143,7 @@ export const isRecordMatchingFilter = ({
case FieldMetadataType.Email: case FieldMetadataType.Email:
case FieldMetadataType.Phone: case FieldMetadataType.Phone:
case FieldMetadataType.Select: case FieldMetadataType.Select:
case FieldMetadataType.MultiSelect:
case FieldMetadataType.Text: { case FieldMetadataType.Text: {
return isMatchingStringFilter({ return isMatchingStringFilter({
stringFilter: filterValue as StringFilter, stringFilter: filterValue as StringFilter,

View File

@@ -73,7 +73,7 @@ export const generateEmptyFieldValue = (
return null; return null;
} }
case FieldMetadataType.MultiSelect: { case FieldMetadataType.MultiSelect: {
throw new Error('Not implemented yet'); return null;
} }
case FieldMetadataType.RawJson: { case FieldMetadataType.RawJson: {
return null; return null;

View File

@@ -24,6 +24,7 @@ export type SettingsObjectFieldSelectFormValues =
type SettingsObjectFieldSelectFormProps = { type SettingsObjectFieldSelectFormProps = {
onChange: (values: SettingsObjectFieldSelectFormValues) => void; onChange: (values: SettingsObjectFieldSelectFormValues) => void;
values: SettingsObjectFieldSelectFormValues; values: SettingsObjectFieldSelectFormValues;
isMultiSelect?: boolean;
}; };
const StyledContainer = styled(CardContent)` const StyledContainer = styled(CardContent)`
@@ -60,6 +61,7 @@ const getNextColor = (currentColor: ThemeColor) => {
export const SettingsObjectFieldSelectForm = ({ export const SettingsObjectFieldSelectForm = ({
onChange, onChange,
values, values,
isMultiSelect = false,
}: SettingsObjectFieldSelectFormProps) => { }: SettingsObjectFieldSelectFormProps) => {
const handleDragEnd = (result: DropResult) => { const handleDragEnd = (result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
@@ -72,6 +74,38 @@ export const SettingsObjectFieldSelectForm = ({
onChange(nextOptions); onChange(nextOptions);
}; };
const handleDefaultValueChange = (
index: number,
option: SettingsObjectFieldSelectFormOption,
nextOption: SettingsObjectFieldSelectFormOption,
forceUniqueDefaultValue: boolean,
) => {
const computeUniqueDefaultValue =
forceUniqueDefaultValue && option.isDefault !== nextOption.isDefault;
const nextOptions = computeUniqueDefaultValue
? values.map((value) => ({
...value,
isDefault: false,
}))
: [...values];
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
};
const findNewLabel = () => {
let optionIndex = values.length + 1;
while (optionIndex < 100) {
const newLabel = `Option ${optionIndex}`;
if (!values.map((value) => value.label).includes(newLabel)) {
return newLabel;
}
optionIndex += 1;
}
return `Option 100`;
};
return ( return (
<> <>
<StyledContainer> <StyledContainer>
@@ -91,18 +125,12 @@ export const SettingsObjectFieldSelectForm = ({
key={option.value} key={option.value}
isDefault={option.isDefault} isDefault={option.isDefault}
onChange={(nextOption) => { onChange={(nextOption) => {
const hasDefaultOptionChanged = handleDefaultValueChange(
!option.isDefault && nextOption.isDefault; index,
const nextOptions = hasDefaultOptionChanged option,
? values.map((value) => ({ nextOption,
...value, !isMultiSelect,
isDefault: false, );
}))
: [...values];
nextOptions.splice(index, 1, nextOption);
onChange(nextOptions);
}} }}
onRemove={ onRemove={
values.length > 1 values.length > 1
@@ -131,7 +159,7 @@ export const SettingsObjectFieldSelectForm = ({
...values, ...values,
{ {
color: getNextColor(values[values.length - 1].color), color: getNextColor(values[values.length - 1].color),
label: `Option ${values.length + 1}`, label: findNewLabel(),
value: v4(), value: v4(),
}, },
]) ])

View File

@@ -11,6 +11,7 @@ import {
IconPhone, IconPhone,
IconRelationManyToMany, IconRelationManyToMany,
IconTag, IconTag,
IconTags,
IconTextSize, IconTextSize,
IconUser, IconUser,
} from 'twenty-ui'; } from 'twenty-ui';
@@ -75,8 +76,8 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
Icon: IconTag, Icon: IconTag,
}, },
[FieldMetadataType.MultiSelect]: { [FieldMetadataType.MultiSelect]: {
label: 'MultiSelect', label: 'Multi-select',
Icon: IconTag, Icon: IconTags,
}, },
[FieldMetadataType.Currency]: { [FieldMetadataType.Currency]: {
label: 'Currency', label: 'Currency',

View File

@@ -27,6 +27,7 @@ export type SettingsDataModelFieldSettingsFormValues = {
currency: SettingsObjectFieldCurrencyFormValues; currency: SettingsObjectFieldCurrencyFormValues;
relation: SettingsObjectFieldRelationFormValues; relation: SettingsObjectFieldRelationFormValues;
select: SettingsObjectFieldSelectFormValues; select: SettingsObjectFieldSelectFormValues;
multiSelect: SettingsObjectFieldSelectFormValues;
defaultValue: any; defaultValue: any;
}; };
@@ -63,6 +64,7 @@ const previewableTypes = [
FieldMetadataType.Currency, FieldMetadataType.Currency,
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Select, FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Link, FieldMetadataType.Link,
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Rating, FieldMetadataType.Rating,
@@ -98,7 +100,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({
shrink={fieldMetadataItem.type === FieldMetadataType.Relation} shrink={fieldMetadataItem.type === FieldMetadataType.Relation}
objectMetadataItem={objectMetadataItem} objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem} relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={values.select} selectOptions={
fieldMetadataItem.type === FieldMetadataType.MultiSelect
? values.multiSelect
: values.select
}
/> />
{fieldMetadataItem.type === FieldMetadataType.Relation && {fieldMetadataItem.type === FieldMetadataType.Relation &&
!!relationObjectMetadataItem && ( !!relationObjectMetadataItem && (
@@ -155,6 +161,14 @@ export const SettingsDataModelFieldSettingsFormCard = ({
onChange({ select: nextSelectValues }) onChange({ select: nextSelectValues })
} }
/> />
) : fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
<SettingsObjectFieldSelectForm
values={values.multiSelect}
onChange={(nextMultiSelectValues) =>
onChange({ multiSelect: nextMultiSelectValues })
}
isMultiSelect={true}
/>
) : fieldMetadataItem.type === FieldMetadataType.Boolean ? ( ) : fieldMetadataItem.type === FieldMetadataType.Boolean ? (
<SettingsDataModelDefaultValueForm <SettingsDataModelDefaultValueForm
value={values.defaultValue} value={values.defaultValue}

View File

@@ -26,6 +26,7 @@ const defaultValues = {
currency: fieldMetadataFormDefaultValues.currency, currency: fieldMetadataFormDefaultValues.currency,
relation: fieldMetadataFormDefaultValues.relation, relation: fieldMetadataFormDefaultValues.relation,
select: fieldMetadataFormDefaultValues.select, select: fieldMetadataFormDefaultValues.select,
multiSelect: fieldMetadataFormDefaultValues.multiSelect,
defaultValue: fieldMetadataFormDefaultValues.defaultValue, defaultValue: fieldMetadataFormDefaultValues.defaultValue,
}; };

View File

@@ -27,6 +27,9 @@ describe('useFieldMetadataForm', () => {
select: [ select: [
{ color: 'green', label: 'Option 1', value: expect.any(String) }, { color: 'green', label: 'Option 1', value: expect.any(String) },
], ],
multiSelect: [
{ color: 'green', label: 'Option 1', value: expect.any(String) },
],
}); });
}); });

View File

@@ -34,13 +34,19 @@ export const fieldMetadataFormDefaultValues: FormValues = {
}, },
defaultValue: null, defaultValue: null,
select: [{ color: 'green', label: 'Option 1', value: v4() }], select: [{ color: 'green', label: 'Option 1', value: v4() }],
multiSelect: [{ color: 'green', label: 'Option 1', value: v4() }],
}; };
const fieldSchema = z.object({ const fieldSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
icon: z.string().startsWith('Icon'), icon: z.string().startsWith('Icon'),
label: z.string().min(1), label: z.string().min(1),
defaultValue: z.any(), defaultValue: z.any(),
type: z.enum(
Object.values(FieldMetadataType) as [
FieldMetadataType,
...FieldMetadataType[],
],
),
}); });
const currencySchema = fieldSchema.merge( const currencySchema = fieldSchema.merge(
@@ -83,10 +89,27 @@ const selectSchema = fieldSchema.merge(
}), }),
); );
const multiSelectSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.MultiSelect),
multiSelect: z
.array(
z.object({
color: themeColorSchema,
id: z.string().optional(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.nonempty(),
}),
);
const { const {
Currency: _Currency, Currency: _Currency,
Relation: _Relation, Relation: _Relation,
Select: _Select, Select: _Select,
MultiSelect: _MultiSelect,
...otherFieldTypes ...otherFieldTypes
} = FieldMetadataType; } = FieldMetadataType;
@@ -95,6 +118,7 @@ type OtherFieldType = Exclude<
| FieldMetadataType.Currency | FieldMetadataType.Currency
| FieldMetadataType.Relation | FieldMetadataType.Relation
| FieldMetadataType.Select | FieldMetadataType.Select
| FieldMetadataType.MultiSelect
>; >;
const otherFieldTypesSchema = fieldSchema.merge( const otherFieldTypesSchema = fieldSchema.merge(
@@ -109,6 +133,7 @@ const schema = z.discriminatedUnion('type', [
currencySchema, currencySchema,
relationSchema, relationSchema,
selectSchema, selectSchema,
multiSelectSchema,
otherFieldTypesSchema, otherFieldTypesSchema,
]); ]);
@@ -127,6 +152,8 @@ export const useFieldMetadataForm = () => {
const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false); const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false); const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false); const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false);
const [hasMultiSelectFormChanged, setHasMultiSelectFormChanged] =
useState(false);
const [hasDefaultValueChanged, setHasDefaultValueFormChanged] = const [hasDefaultValueChanged, setHasDefaultValueFormChanged] =
useState(false); useState(false);
const [validationResult, setValidationResult] = useState( const [validationResult, setValidationResult] = useState(
@@ -174,13 +201,15 @@ export const useFieldMetadataForm = () => {
currency: initialCurrencyFormValues, currency: initialCurrencyFormValues,
relation: initialRelationFormValues, relation: initialRelationFormValues,
select: initialSelectFormValues, select: initialSelectFormValues,
defaultValue: initalDefaultValue, multiSelect: initialMultiSelectFormValues,
defaultValue: initialDefaultValue,
...initialFieldFormValues ...initialFieldFormValues
} = initialFormValues; } = initialFormValues;
const { const {
currency: nextCurrencyFormValues, currency: nextCurrencyFormValues,
relation: nextRelationFormValues, relation: nextRelationFormValues,
select: nextSelectFormValues, select: nextSelectFormValues,
multiSelect: nextMultiSelectFormValues,
defaultValue: nextDefaultValue, defaultValue: nextDefaultValue,
...nextFieldFormValues ...nextFieldFormValues
} = nextFormValues; } = nextFormValues;
@@ -200,9 +229,13 @@ export const useFieldMetadataForm = () => {
nextFieldFormValues.type === FieldMetadataType.Select && nextFieldFormValues.type === FieldMetadataType.Select &&
!isDeeplyEqual(initialSelectFormValues, nextSelectFormValues), !isDeeplyEqual(initialSelectFormValues, nextSelectFormValues),
); );
setHasMultiSelectFormChanged(
nextFieldFormValues.type === FieldMetadataType.MultiSelect &&
!isDeeplyEqual(initialMultiSelectFormValues, nextMultiSelectFormValues),
);
setHasDefaultValueFormChanged( setHasDefaultValueFormChanged(
nextFieldFormValues.type === FieldMetadataType.Boolean && nextFieldFormValues.type === FieldMetadataType.Boolean &&
!isDeeplyEqual(initalDefaultValue, nextDefaultValue), !isDeeplyEqual(initialDefaultValue, nextDefaultValue),
); );
}; };
@@ -215,9 +248,11 @@ export const useFieldMetadataForm = () => {
hasCurrencyFormChanged || hasCurrencyFormChanged ||
hasRelationFormChanged || hasRelationFormChanged ||
hasSelectFormChanged || hasSelectFormChanged ||
hasMultiSelectFormChanged ||
hasDefaultValueChanged, hasDefaultValueChanged,
hasRelationFormChanged, hasRelationFormChanged,
hasSelectFormChanged, hasSelectFormChanged,
hasMultiSelectFormChanged,
hasDefaultValueChanged, hasDefaultValueChanged,
initForm, initForm,
isInitialized, isInitialized,

View File

@@ -21,7 +21,6 @@ export const getFieldDefaultPreviewValue = ({
relationObjectMetadataItem?: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues; selectOptions?: SettingsObjectFieldSelectFormValues;
}) => { }) => {
// Select field
if ( if (
fieldMetadataItem.type === FieldMetadataType.Select && fieldMetadataItem.type === FieldMetadataType.Select &&
isDefined(selectOptions) isDefined(selectOptions)
@@ -31,7 +30,13 @@ export const getFieldDefaultPreviewValue = ({
return defaultSelectOption.value; return defaultSelectOption.value;
} }
// Relation field if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
isDefined(selectOptions)
) {
return selectOptions.map((selectOption) => selectOption.value);
}
if ( if (
fieldMetadataItem.type === FieldMetadataType.Relation && fieldMetadataItem.type === FieldMetadataType.Relation &&
isDefined(relationObjectMetadataItem) isDefined(relationObjectMetadataItem)
@@ -60,7 +65,6 @@ export const getFieldDefaultPreviewValue = ({
return defaultRelationRecord; return defaultRelationRecord;
} }
// Other fields
const isLabelIdentifier = const isLabelIdentifier =
!!fieldMetadataItem.id && !!fieldMetadataItem.id &&
!!fieldMetadataItem.name && !!fieldMetadataItem.name &&

View File

@@ -20,6 +20,7 @@ const StyledInnerContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: ${() => (useIsMobile() ? `100%` : '348px')}; width: ${() => (useIsMobile() ? `100%` : '348px')};
overflow-x: hidden;
`; `;
const StyledIntermediateContainer = styled.div` const StyledIntermediateContainer = styled.div`

View File

@@ -0,0 +1,41 @@
import { Tag } from '@/ui/display/tag/components/Tag';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
} from '@/ui/input/components/Checkbox';
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
import {
StyledMenuItemBase,
StyledMenuItemLeftContent,
} from '../internals/components/StyledMenuItemBase';
type MenuItemMultiSelectTagProps = {
selected: boolean;
className?: string;
onClick?: () => void;
color: ThemeColor;
text: string;
};
export const MenuItemMultiSelectTag = ({
color,
selected,
className,
onClick,
text,
}: MenuItemMultiSelectTagProps) => {
return (
<StyledMenuItemBase onClick={onClick} className={className}>
<Checkbox
size={CheckboxSize.Small}
shape={CheckboxShape.Squared}
checked={selected}
/>
<StyledMenuItemLeftContent>
<Tag color={color} text={text} />
</StyledMenuItemLeftContent>
</StyledMenuItemBase>
);
};

View File

@@ -4,4 +4,5 @@ export type FeatureFlagKey =
| 'IS_QUICK_ACTIONS_ENABLED' | 'IS_QUICK_ACTIONS_ENABLED'
| 'IS_EVENT_OBJECT_ENABLED' | 'IS_EVENT_OBJECT_ENABLED'
| 'IS_AIRTABLE_INTEGRATION_ENABLED' | 'IS_AIRTABLE_INTEGRATION_ENABLED'
| 'IS_POSTGRESQL_INTEGRATION_ENABLED'; | 'IS_POSTGRESQL_INTEGRATION_ENABLED'
| 'IS_MULTI_SELECT_ENABLED';

View File

@@ -42,7 +42,8 @@ const canPersistFieldMetadataItemUpdate = (
) => { ) => {
return ( return (
fieldMetadataItem.isCustom || fieldMetadataItem.isCustom ||
fieldMetadataItem.type === FieldMetadataType.Select fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
); );
}; };
@@ -87,6 +88,7 @@ export const SettingsObjectFieldEdit = () => {
hasFormChanged, hasFormChanged,
hasRelationFormChanged, hasRelationFormChanged,
hasSelectFormChanged, hasSelectFormChanged,
hasMultiSelectFormChanged,
initForm, initForm,
isInitialized, isInitialized,
isValid, isValid,
@@ -114,6 +116,14 @@ export const SettingsObjectFieldEdit = () => {
(optionA, optionB) => optionA.position - optionB.position, (optionA, optionB) => optionA.position - optionB.position,
); );
const multiSelectOptions = activeMetadataField.options?.map((option) => ({
...option,
isDefault: defaultValue?.includes(`'${option.value}'`) || false,
}));
multiSelectOptions?.sort(
(optionA, optionB) => optionA.position - optionB.position,
);
const fieldType = activeMetadataField.type; const fieldType = activeMetadataField.type;
const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType); const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType);
@@ -135,6 +145,9 @@ export const SettingsObjectFieldEdit = () => {
}, },
defaultValue: activeMetadataField.defaultValue, defaultValue: activeMetadataField.defaultValue,
...(selectOptions?.length ? { select: selectOptions } : {}), ...(selectOptions?.length ? { select: selectOptions } : {}),
...(multiSelectOptions?.length
? { multiSelect: multiSelectOptions }
: {}),
}); });
}, [ }, [
activeMetadataField, activeMetadataField,
@@ -170,11 +183,13 @@ export const SettingsObjectFieldEdit = () => {
icon: validatedFormValues.relation.field.icon, icon: validatedFormValues.relation.field.icon,
id: relationFieldMetadataItem?.id, id: relationFieldMetadataItem?.id,
label: validatedFormValues.relation.field.label, label: validatedFormValues.relation.field.label,
type: validatedFormValues.type,
}); });
} }
if ( if (
hasFieldFormChanged || hasFieldFormChanged ||
hasSelectFormChanged || hasSelectFormChanged ||
hasMultiSelectFormChanged ||
hasDefaultValueChanged hasDefaultValueChanged
) { ) {
await editMetadataField({ await editMetadataField({
@@ -183,10 +198,13 @@ export const SettingsObjectFieldEdit = () => {
id: activeMetadataField.id, id: activeMetadataField.id,
label: validatedFormValues.label, label: validatedFormValues.label,
defaultValue: validatedFormValues.defaultValue, defaultValue: validatedFormValues.defaultValue,
type: validatedFormValues.type,
options: options:
validatedFormValues.type === FieldMetadataType.Select validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select ? validatedFormValues.select
: undefined, : validatedFormValues.type === FieldMetadataType.MultiSelect
? validatedFormValues.multiSelect
: undefined,
}); });
} }
@@ -261,6 +279,7 @@ export const SettingsObjectFieldEdit = () => {
currency: formValues.currency, currency: formValues.currency,
relation: formValues.relation, relation: formValues.relation,
select: formValues.select, select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue, defaultValue: formValues.defaultValue,
}} }}
/> />

View File

@@ -28,6 +28,7 @@ import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled.ts';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -41,6 +42,7 @@ export const SettingsObjectNewFieldStep2 = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { objectSlug = '' } = useParams(); const { objectSlug = '' } = useParams();
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const isMultiSelectEnabled = useIsFeatureEnabled('IS_MULTI_SELECT_ENABLED');
const { const {
findActiveObjectMetadataItemBySlug, findActiveObjectMetadataItemBySlug,
@@ -132,12 +134,14 @@ export const SettingsObjectNewFieldStep2 = () => {
description: validatedFormValues.description, description: validatedFormValues.description,
icon: validatedFormValues.icon, icon: validatedFormValues.icon,
label: validatedFormValues.label, label: validatedFormValues.label,
type: validatedFormValues.type,
}, },
objectMetadataId: activeObjectMetadataItem.id, objectMetadataId: activeObjectMetadataItem.id,
connect: { connect: {
field: { field: {
icon: validatedFormValues.relation.field.icon, icon: validatedFormValues.relation.field.icon,
label: validatedFormValues.relation.field.label, label: validatedFormValues.relation.field.label,
type: validatedFormValues.relation.field.type,
}, },
objectMetadataId: validatedFormValues.relation.objectMetadataId, objectMetadataId: validatedFormValues.relation.objectMetadataId,
}, },
@@ -147,7 +151,7 @@ export const SettingsObjectNewFieldStep2 = () => {
validatedFormValues.relation.objectMetadataId, validatedFormValues.relation.objectMetadataId,
); );
objectViews.forEach(async (view) => { objectViews.map(async (view) => {
const viewFieldToCreate = { const viewFieldToCreate = {
viewId: view.id, viewId: view.id,
fieldMetadataId: fieldMetadataId:
@@ -180,7 +184,7 @@ export const SettingsObjectNewFieldStep2 = () => {
recordId: view.id, recordId: view.id,
}); });
relationObjectViews.forEach(async (view) => { relationObjectViews.map(async (view) => {
const viewFieldToCreate = { const viewFieldToCreate = {
viewId: view.id, viewId: view.id,
fieldMetadataId: fieldMetadataId:
@@ -230,10 +234,12 @@ export const SettingsObjectNewFieldStep2 = () => {
options: options:
validatedFormValues.type === FieldMetadataType.Select validatedFormValues.type === FieldMetadataType.Select
? validatedFormValues.select ? validatedFormValues.select
: undefined, : validatedFormValues.type === FieldMetadataType.MultiSelect
? validatedFormValues.multiSelect
: undefined,
}); });
objectViews.forEach(async (view) => { objectViews.map(async (view) => {
const viewFieldToCreate = { const viewFieldToCreate = {
viewId: view.id, viewId: view.id,
fieldMetadataId: createdMetadataField.data?.createOneField.id, fieldMetadataId: createdMetadataField.data?.createOneField.id,
@@ -278,13 +284,16 @@ export const SettingsObjectNewFieldStep2 = () => {
FieldMetadataType.Email, FieldMetadataType.Email,
FieldMetadataType.FullName, FieldMetadataType.FullName,
FieldMetadataType.Link, FieldMetadataType.Link,
FieldMetadataType.MultiSelect,
FieldMetadataType.Numeric, FieldMetadataType.Numeric,
FieldMetadataType.Phone, FieldMetadataType.Phone,
FieldMetadataType.Probability, FieldMetadataType.Probability,
FieldMetadataType.Uuid, FieldMetadataType.Uuid,
]; ];
if (!isMultiSelectEnabled) {
excludedFieldTypes.push(FieldMetadataType.MultiSelect);
}
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer> <SettingsPageContainer>
@@ -335,6 +344,7 @@ export const SettingsObjectNewFieldStep2 = () => {
currency: formValues.currency, currency: formValues.currency,
relation: formValues.relation, relation: formValues.relation,
select: formValues.select, select: formValues.select,
multiSelect: formValues.multiSelect,
defaultValue: formValues.defaultValue, defaultValue: formValues.defaultValue,
}} }}
/> />

View File

@@ -40,11 +40,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
return this.mainDataSource; return this.mainDataSource;
} }
/**
* Connects to a data source using metadata. Returns a cached connection if it exists.
* @param dataSource DataSourceEntity
* @returns Promise<DataSource | undefined>
*/
public async connectToDataSource( public async connectToDataSource(
dataSource: DataSourceEntity, dataSource: DataSourceEntity,
): Promise<DataSource | undefined> { ): Promise<DataSource | undefined> {
@@ -96,12 +91,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
return workspaceDataSource; return workspaceDataSource;
} }
/**
* Disconnects from a workspace data source.
* @param dataSourceId
* @returns Promise<void>
*
*/
public async disconnectFromDataSource(dataSourceId: string) { public async disconnectFromDataSource(dataSourceId: string) {
if (!this.dataSources.has(dataSourceId)) { if (!this.dataSources.has(dataSourceId)) {
return; return;
@@ -114,11 +103,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
this.dataSources.delete(dataSourceId); this.dataSources.delete(dataSourceId);
} }
/**
* Creates a new schema
* @param workspaceId
* @returns Promise<void>
*/
public async createSchema(schemaName: string): Promise<string> { public async createSchema(schemaName: string): Promise<string> {
const queryRunner = this.mainDataSource.createQueryRunner(); const queryRunner = this.mainDataSource.createQueryRunner();

View File

@@ -306,9 +306,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
...updatableFieldInput, ...updatableFieldInput,
defaultValue: defaultValue:
// Todo: we handle default value for all field types. // Todo: we handle default value for all field types.
![FieldMetadataType.SELECT, FieldMetadataType.BOOLEAN].includes( ![
existingFieldMetadata.type, FieldMetadataType.SELECT,
) FieldMetadataType.MULTI_SELECT,
FieldMetadataType.BOOLEAN,
].includes(existingFieldMetadata.type)
? existingFieldMetadata.defaultValue ? existingFieldMetadata.defaultValue
: updatableFieldInput.defaultValue !== null : updatableFieldInput.defaultValue !== null
? updatableFieldInput.defaultValue ? updatableFieldInput.defaultValue

View File

@@ -15,8 +15,8 @@ export enum WorkspaceMigrationColumnActionType {
DROP = 'DROP', DROP = 'DROP',
CREATE_COMMENT = 'CREATE_COMMENT', CREATE_COMMENT = 'CREATE_COMMENT',
} }
export type WorkspaceMigrationRenamedEnum = { from: string; to: string };
export type WorkspaceMigrationEnum = string | { from: string; to: string }; export type WorkspaceMigrationEnum = string | WorkspaceMigrationRenamedEnum;
export interface WorkspaceMigrationColumnDefinition { export interface WorkspaceMigrationColumnDefinition {
columnName: string; columnName: string;

View File

@@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { QueryRunner } from 'typeorm'; import { QueryRunner, TableColumn } from 'typeorm';
import { v4 } from 'uuid';
import { WorkspaceMigrationColumnAlter } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import {
WorkspaceMigrationColumnAlter,
WorkspaceMigrationRenamedEnum,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value'; import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value';
@Injectable() @Injectable()
@@ -28,8 +32,9 @@ export class WorkspaceMigrationEnumService {
} }
const columnDefinition = migrationColumn.alteredColumnDefinition; const columnDefinition = migrationColumn.alteredColumnDefinition;
const oldEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum`; const oldEnumTypeName =
const newEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum_new`; `${tableName}_${migrationColumn.currentColumnDefinition.columnName}_enum`.toLowerCase();
const tempEnumTypeName = `${oldEnumTypeName}_temp`;
const enumValues = const enumValues =
columnDefinition.enum?.map((enumValue) => { columnDefinition.enum?.map((enumValue) => {
if (typeof enumValue === 'string') { if (typeof enumValue === 'string') {
@@ -38,62 +43,61 @@ export class WorkspaceMigrationEnumService {
return enumValue.to; return enumValue.to;
}) ?? []; }) ?? [];
const renamedEnumValues = columnDefinition.enum?.filter(
(enumValue): enumValue is WorkspaceMigrationRenamedEnum =>
typeof enumValue !== 'string',
);
if (!columnDefinition.isNullable && !columnDefinition.defaultValue) { if (!columnDefinition.isNullable && !columnDefinition.defaultValue) {
columnDefinition.defaultValue = serializeDefaultValue(enumValues[0]); columnDefinition.defaultValue = serializeDefaultValue(enumValues[0]);
} }
// Create new enum type with new values const oldColumnName = `${columnDefinition.columnName}_old_${v4()}`;
await this.createNewEnumType(
newEnumTypeName,
queryRunner,
schemaName,
enumValues,
);
// Temporarily change column type to text // Rename old column
await queryRunner.query(` await this.renameColumn(
ALTER TABLE "${schemaName}"."${tableName}"
ALTER COLUMN "${columnDefinition.columnName}" TYPE TEXT
`);
// Migrate existing values to new values
await this.migrateEnumValues(
queryRunner,
schemaName,
tableName,
migrationColumn,
);
// Update existing rows to handle missing values
await this.handleMissingEnumValues(
queryRunner,
schemaName,
tableName,
migrationColumn,
enumValues,
);
// Alter column type to new enum
await this.updateColumnToNewEnum(
queryRunner, queryRunner,
schemaName, schemaName,
tableName, tableName,
columnDefinition.columnName, columnDefinition.columnName,
newEnumTypeName, oldColumnName,
columnDefinition.defaultValue,
); );
// Drop old enum type
await this.dropOldEnumType(queryRunner, schemaName, oldEnumTypeName);
// Rename new enum type to old enum type name
await this.renameEnumType( await this.renameEnumType(
queryRunner, queryRunner,
schemaName, schemaName,
oldEnumTypeName, oldEnumTypeName,
newEnumTypeName, tempEnumTypeName,
); );
await queryRunner.addColumn(
`${schemaName}.${tableName}`,
new TableColumn({
name: columnDefinition.columnName,
type: columnDefinition.columnType,
default: columnDefinition.defaultValue,
enum: enumValues,
isArray: columnDefinition.isArray,
isNullable: columnDefinition.isNullable,
}),
);
await this.migrateEnumValues(
queryRunner,
schemaName,
migrationColumn,
tableName,
oldColumnName,
enumValues,
renamedEnumValues,
);
// Drop old column
await queryRunner.query(`
ALTER TABLE "${schemaName}"."${tableName}"
DROP COLUMN "${oldColumnName}"
`);
// Drop temp enum type
await this.dropOldEnumType(queryRunner, schemaName, tempEnumTypeName);
} }
private async renameColumn( private async renameColumn(
@@ -109,90 +113,52 @@ export class WorkspaceMigrationEnumService {
`); `);
} }
private async createNewEnumType( private migrateEnumValue(
name: string, value: string,
queryRunner: QueryRunner, renamedEnumValues?: WorkspaceMigrationRenamedEnum[],
schemaName: string,
newValues: string[],
) { ) {
const enumValues = newValues return (
.map((value) => `'${value.replace(/'/g, "''")}'`) renamedEnumValues?.find((enumVal) => enumVal?.from === value)?.to || value
.join(', ');
await queryRunner.query(
`CREATE TYPE "${schemaName}"."${name}" AS ENUM (${enumValues})`,
); );
} }
private async migrateEnumValues( private async migrateEnumValues(
queryRunner: QueryRunner, queryRunner: QueryRunner,
schemaName: string, schemaName: string,
tableName: string,
migrationColumn: WorkspaceMigrationColumnAlter, migrationColumn: WorkspaceMigrationColumnAlter,
tableName: string,
oldColumnName: string,
enumValues: string[],
renamedEnumValues?: WorkspaceMigrationRenamedEnum[],
) { ) {
const columnDefinition = migrationColumn.alteredColumnDefinition; const columnDefinition = migrationColumn.alteredColumnDefinition;
if (!columnDefinition.enum) { const values = await queryRunner.query(
return; `SELECT id, "${oldColumnName}" FROM "${schemaName}"."${tableName}"`,
} );
for (const enumValue of columnDefinition.enum) { values.map(async (value) => {
// Skip string values let val = value[oldColumnName];
if (typeof enumValue === 'string') {
continue; if (/^\{.*\}$/.test(val)) {
val = serializeDefaultValue(
val
.slice(1, -1)
.split(',')
.map((v: string) => v.trim())
.map((v: string) => this.migrateEnumValue(v, renamedEnumValues))
.filter((v: string) => enumValues.includes(v)),
);
} else if (typeof val === 'string') {
val = `'${this.migrateEnumValue(val, renamedEnumValues)}'`;
} }
await queryRunner.query(` await queryRunner.query(`
UPDATE "${schemaName}"."${tableName}" UPDATE "${schemaName}"."${tableName}"
SET "${columnDefinition.columnName}" = '${enumValue.to}' SET "${columnDefinition.columnName}" = ${val}
WHERE "${columnDefinition.columnName}" = '${enumValue.from}' WHERE id='${value.id}'
`); `);
} });
}
private async handleMissingEnumValues(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
migrationColumn: WorkspaceMigrationColumnAlter,
enumValues: string[],
) {
const columnDefinition = migrationColumn.alteredColumnDefinition;
// Set missing values to null or default value
let defaultValue = 'NULL';
if (columnDefinition.defaultValue) {
if (Array.isArray(columnDefinition.defaultValue)) {
defaultValue = `ARRAY[${columnDefinition.defaultValue
.map((e) => `'${e}'`)
.join(', ')}]`;
} else {
defaultValue = columnDefinition.defaultValue;
}
}
await queryRunner.query(`
UPDATE "${schemaName}"."${tableName}"
SET "${columnDefinition.columnName}" = ${defaultValue}
WHERE "${columnDefinition.columnName}" NOT IN (${enumValues
.map((e) => `'${e}'`)
.join(', ')})
`);
}
private async updateColumnToNewEnum(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnName: string,
newEnumTypeName: string,
newDefaultValue: string,
) {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT,
ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}"),
ALTER COLUMN "${columnName}" SET DEFAULT ${newDefaultValue}`,
);
} }
private async dropOldEnumType( private async dropOldEnumType(
@@ -212,8 +178,8 @@ export class WorkspaceMigrationEnumService {
newEnumTypeName: string, newEnumTypeName: string,
) { ) {
await queryRunner.query(` await queryRunner.query(`
ALTER TYPE "${schemaName}"."${newEnumTypeName}" ALTER TYPE "${schemaName}"."${oldEnumTypeName}"
RENAME TO "${oldEnumTypeName}" RENAME TO "${newEnumTypeName}"
`); `);
} }
} }

View File

@@ -124,6 +124,7 @@ export {
IconSortDescending, IconSortDescending,
IconTable, IconTable,
IconTag, IconTag,
IconTags,
IconTarget, IconTarget,
IconTargetArrow, IconTargetArrow,
IconTextSize, IconTextSize,