mirror of
https://github.com/lingble/twenty.git
synced 2025-11-25 10:25:03 +00:00
4778 multi select field front implement multi select type (#4887)
This commit is contained in:
@@ -18,12 +18,6 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
const logger = loggerLink(() => 'Twenty-Refresh');
|
||||
|
||||
/**
|
||||
* Renew token mutation with custom apollo client
|
||||
* @param uri string | UriFunction | undefined
|
||||
* @param refreshToken string
|
||||
* @returns RenewTokenMutation
|
||||
*/
|
||||
const renewTokenMutation = async (
|
||||
uri: string | UriFunction | undefined,
|
||||
refreshToken: string,
|
||||
@@ -54,11 +48,6 @@ const renewTokenMutation = async (
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renew token and update cookie storage
|
||||
* @param uri string | UriFunction | undefined
|
||||
* @returns TokenPair
|
||||
*/
|
||||
export const renewToken = async (
|
||||
uri: string | UriFunction | undefined,
|
||||
tokenPair: AuthTokenPair | undefined | null,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption.ts';
|
||||
import { Field } from '~/generated/graphql';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
import { FieldMetadataItem } from '../types/FieldMetadataItem';
|
||||
import { FieldMetadataOption } from '../types/FieldMetadataOption';
|
||||
import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput';
|
||||
|
||||
import { useCreateOneFieldMetadataItem } from './useCreateOneFieldMetadataItem';
|
||||
@@ -17,22 +16,22 @@ export const useFieldMetadataItem = () => {
|
||||
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
|
||||
|
||||
const createMetadataField = (
|
||||
input: Pick<Field, 'label' | 'icon' | 'description' | 'defaultValue'> & {
|
||||
defaultValue?: unknown;
|
||||
input: Pick<
|
||||
Field,
|
||||
'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options'
|
||||
> & {
|
||||
objectMetadataId: string;
|
||||
options?: Omit<FieldMetadataOption, 'id'>[];
|
||||
type: FieldMetadataType;
|
||||
},
|
||||
) => {
|
||||
const formatedInput = formatFieldMetadataItemInput(input);
|
||||
const formattedInput = formatFieldMetadataItemInput(input);
|
||||
const defaultValue = input.defaultValue
|
||||
? typeof input.defaultValue == 'string'
|
||||
? `'${input.defaultValue}'`
|
||||
: input.defaultValue
|
||||
: formatedInput.defaultValue ?? undefined;
|
||||
: formattedInput.defaultValue ?? undefined;
|
||||
|
||||
return createOneFieldMetadataItem({
|
||||
...formatedInput,
|
||||
...formattedInput,
|
||||
defaultValue,
|
||||
objectMetadataId: input.objectMetadataId,
|
||||
type: input.type,
|
||||
@@ -42,17 +41,21 @@ export const useFieldMetadataItem = () => {
|
||||
const editMetadataField = (
|
||||
input: Pick<
|
||||
Field,
|
||||
'id' | 'label' | 'icon' | 'description' | 'defaultValue'
|
||||
> & {
|
||||
options?: FieldMetadataOption[];
|
||||
},
|
||||
| 'id'
|
||||
| 'label'
|
||||
| 'icon'
|
||||
| 'description'
|
||||
| 'defaultValue'
|
||||
| 'type'
|
||||
| 'options'
|
||||
>,
|
||||
) => {
|
||||
const formatedInput = formatFieldMetadataItemInput(input);
|
||||
const formattedInput = formatFieldMetadataItemInput(input);
|
||||
const defaultValue = input.defaultValue
|
||||
? typeof input.defaultValue == 'string'
|
||||
? `'${input.defaultValue}'`
|
||||
: input.defaultValue
|
||||
: formatedInput.defaultValue ?? undefined;
|
||||
: formattedInput.defaultValue ?? undefined;
|
||||
|
||||
return updateOneFieldMetadataItem({
|
||||
fieldMetadataIdToUpdate: input.id,
|
||||
@@ -61,7 +64,7 @@ export const useFieldMetadataItem = () => {
|
||||
defaultValue,
|
||||
// In Edit mode, all options need an id,
|
||||
// so we generate an id for newly created options.
|
||||
options: input.options?.map((option) =>
|
||||
options: input.options?.map((option: FieldMetadataOption) =>
|
||||
option.id ? option : { ...option, id: v4() },
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql.ts';
|
||||
|
||||
import {
|
||||
formatFieldMetadataItemInput,
|
||||
getOptionValueFromLabel,
|
||||
@@ -46,6 +48,7 @@ describe('formatFieldMetadataItemInput', () => {
|
||||
const input = {
|
||||
label: 'Example Label',
|
||||
icon: 'example-icon',
|
||||
type: FieldMetadataType.Select,
|
||||
description: 'Example description',
|
||||
options: [
|
||||
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
|
||||
@@ -86,6 +89,70 @@ describe('formatFieldMetadataItemInput', () => {
|
||||
const input = {
|
||||
label: 'Example Label',
|
||||
icon: 'example-icon',
|
||||
type: FieldMetadataType.Select,
|
||||
description: 'Example description',
|
||||
};
|
||||
|
||||
const expected = {
|
||||
description: 'Example description',
|
||||
icon: 'example-icon',
|
||||
label: 'Example Label',
|
||||
name: 'exampleLabel',
|
||||
options: undefined,
|
||||
defaultValue: undefined,
|
||||
};
|
||||
|
||||
const result = formatFieldMetadataItemInput(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should format the field metadata item multi select input correctly', () => {
|
||||
const input = {
|
||||
label: 'Example Label',
|
||||
icon: 'example-icon',
|
||||
type: FieldMetadataType.MultiSelect,
|
||||
description: 'Example description',
|
||||
options: [
|
||||
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
|
||||
{ id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true },
|
||||
],
|
||||
};
|
||||
|
||||
const expected = {
|
||||
description: 'Example description',
|
||||
icon: 'example-icon',
|
||||
label: 'Example Label',
|
||||
name: 'exampleLabel',
|
||||
options: [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Option 1',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
value: 'OPTION_1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Option 2',
|
||||
color: 'blue',
|
||||
position: 1,
|
||||
value: 'OPTION_2',
|
||||
},
|
||||
],
|
||||
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
|
||||
};
|
||||
|
||||
const result = formatFieldMetadataItemInput(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multi select input without options', () => {
|
||||
const input = {
|
||||
label: 'Example Label',
|
||||
icon: 'example-icon',
|
||||
type: FieldMetadataType.MultiSelect,
|
||||
description: 'Example description',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import toCamelCase from 'lodash.camelcase';
|
||||
import toSnakeCase from 'lodash.snakecase';
|
||||
|
||||
import { Field } from '~/generated-metadata/graphql';
|
||||
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { isDefined } from '~/utils/isDefined.ts';
|
||||
|
||||
import { FieldMetadataOption } from '../types/FieldMetadataOption';
|
||||
|
||||
@@ -20,20 +21,36 @@ export const getOptionValueFromLabel = (label: string) => {
|
||||
};
|
||||
|
||||
export const formatFieldMetadataItemInput = (
|
||||
input: Pick<Field, 'label' | 'icon' | 'description' | 'defaultValue'> & {
|
||||
options?: FieldMetadataOption[];
|
||||
},
|
||||
input: Pick<
|
||||
Field,
|
||||
'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options'
|
||||
>,
|
||||
) => {
|
||||
const defaultOption = input.options?.find((option) => option.isDefault);
|
||||
const options = input.options as FieldMetadataOption[];
|
||||
let defaultValue = input.defaultValue;
|
||||
if (input.type === FieldMetadataType.MultiSelect) {
|
||||
const defaultOptions = options?.filter((option) => option.isDefault);
|
||||
if (isDefined(defaultOptions)) {
|
||||
defaultValue = defaultOptions.map(
|
||||
(defaultOption) => `'${getOptionValueFromLabel(defaultOption.label)}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.type === FieldMetadataType.Select) {
|
||||
const defaultOption = options?.find((option) => option.isDefault);
|
||||
defaultValue = isDefined(defaultOption)
|
||||
? `'${getOptionValueFromLabel(defaultOption.label)}'`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Check if options has unique values
|
||||
if (input.options !== undefined) {
|
||||
if (options !== undefined) {
|
||||
// Compute the values based on the label
|
||||
const values = input.options.map((option) =>
|
||||
const values = options.map((option) =>
|
||||
getOptionValueFromLabel(option.label),
|
||||
);
|
||||
|
||||
if (new Set(values).size !== input.options.length) {
|
||||
if (new Set(values).size !== options.length) {
|
||||
throw new Error(
|
||||
`Options must have unique values, but contains the following duplicates ${values.join(
|
||||
', ',
|
||||
@@ -43,14 +60,12 @@ export const formatFieldMetadataItemInput = (
|
||||
}
|
||||
|
||||
return {
|
||||
defaultValue: defaultOption
|
||||
? `'${getOptionValueFromLabel(defaultOption.label)}'`
|
||||
: input.defaultValue,
|
||||
defaultValue,
|
||||
description: input.description?.trim() ?? null,
|
||||
icon: input.icon,
|
||||
label: input.label.trim(),
|
||||
name: toCamelCase(input.label.trim()),
|
||||
options: input.options?.map((option, index) => ({
|
||||
options: options?.map((option, index) => ({
|
||||
color: option.color,
|
||||
id: option.id,
|
||||
label: option.label.trim(),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
|
||||
FieldMetadataType.Address,
|
||||
FieldMetadataType.Relation,
|
||||
FieldMetadataType.Select,
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Currency,
|
||||
].includes(field.type)
|
||||
) {
|
||||
@@ -76,6 +77,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
|
||||
return 'RELATION';
|
||||
case FieldMetadataType.Select:
|
||||
return 'SELECT';
|
||||
case FieldMetadataType.MultiSelect:
|
||||
return 'MULTI_SELECT';
|
||||
case FieldMetadataType.Address:
|
||||
return 'ADDRESS';
|
||||
default:
|
||||
|
||||
@@ -9,10 +9,10 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput';
|
||||
|
||||
export type FormatRelationMetadataInputParams = {
|
||||
relationType: RelationType;
|
||||
field: Pick<Field, 'label' | 'icon' | 'description'>;
|
||||
field: Pick<Field, 'label' | 'icon' | 'description' | 'type'>;
|
||||
objectMetadataId: string;
|
||||
connect: {
|
||||
field: Pick<Field, 'label' | 'icon'>;
|
||||
field: Pick<Field, 'label' | 'icon' | 'type'>;
|
||||
objectMetadataId: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
|
||||
'BOOLEAN',
|
||||
'RATING',
|
||||
'SELECT',
|
||||
'MULTI_SELECT',
|
||||
'POSITION',
|
||||
'RAW_JSON',
|
||||
] as FieldMetadataType[]
|
||||
|
||||
@@ -15,18 +15,20 @@ export const getRecordFromRecordNode = <T extends ObjectRecord>({
|
||||
return [fieldName, value];
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && isDefined(value.edges)) {
|
||||
return [
|
||||
if (Array.isArray(value)) {
|
||||
return [fieldName, value];
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return [fieldName, value];
|
||||
}
|
||||
|
||||
return isDefined(value.edges)
|
||||
? [
|
||||
fieldName,
|
||||
getRecordsFromRecordConnection({ recordConnection: value }),
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !isDefined(value.edges)) {
|
||||
return [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
|
||||
}
|
||||
|
||||
return [fieldName, value];
|
||||
]
|
||||
: [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
|
||||
}),
|
||||
),
|
||||
id: recordNode.id,
|
||||
|
||||
@@ -6,7 +6,10 @@ import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
|
||||
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
|
||||
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RelationDefinitionType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize';
|
||||
|
||||
@@ -65,12 +68,16 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItem) => objectMetadataItem.namePlural === fieldName,
|
||||
if (
|
||||
field.type === FieldMetadataType.Relation &&
|
||||
field.relationDefinition?.direction ===
|
||||
RelationDefinitionType.OneToMany
|
||||
) {
|
||||
const oneToManyObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.namePlural === fieldName,
|
||||
);
|
||||
|
||||
if (!objectMetadataItem) {
|
||||
if (!oneToManyObjectMetadataItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -78,7 +85,7 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
|
||||
fieldName,
|
||||
getRecordConnectionFromRecords({
|
||||
objectMetadataItems,
|
||||
objectMetadataItem: objectMetadataItem,
|
||||
objectMetadataItem: oneToManyObjectMetadataItem,
|
||||
records: value as ObjectRecord[],
|
||||
queryFields:
|
||||
queryFields?.[fieldName] === true ||
|
||||
|
||||
@@ -9,4 +9,5 @@ export type FilterType =
|
||||
| 'LINK'
|
||||
| 'RELATION'
|
||||
| 'ADDRESS'
|
||||
| 'SELECT';
|
||||
| 'SELECT'
|
||||
| 'MULTI_SELECT';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
|
||||
import { FieldContext } from '../contexts/FieldContext';
|
||||
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
|
||||
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
|
||||
@@ -10,7 +7,9 @@ import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyF
|
||||
import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay';
|
||||
import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay';
|
||||
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
|
||||
import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay';
|
||||
import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay';
|
||||
import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx';
|
||||
import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay';
|
||||
import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay';
|
||||
import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay';
|
||||
@@ -23,8 +22,10 @@ import { isFieldDateTime } from '../types/guards/isFieldDateTime';
|
||||
import { isFieldEmail } from '../types/guards/isFieldEmail';
|
||||
import { isFieldFullName } from '../types/guards/isFieldFullName';
|
||||
import { isFieldLink } from '../types/guards/isFieldLink';
|
||||
import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect.ts';
|
||||
import { isFieldNumber } from '../types/guards/isFieldNumber';
|
||||
import { isFieldPhone } from '../types/guards/isFieldPhone';
|
||||
import { isFieldRawJson } from '../types/guards/isFieldRawJson';
|
||||
import { isFieldRelation } from '../types/guards/isFieldRelation';
|
||||
import { isFieldSelect } from '../types/guards/isFieldSelect';
|
||||
import { isFieldText } from '../types/guards/isFieldText';
|
||||
@@ -60,6 +61,8 @@ export const FieldDisplay = () => {
|
||||
<PhoneFieldDisplay />
|
||||
) : isFieldSelect(fieldDefinition) ? (
|
||||
<SelectFieldDisplay />
|
||||
) : isFieldMultiSelect(fieldDefinition) ? (
|
||||
<MultiSelectFieldDisplay />
|
||||
) : isFieldAddress(fieldDefinition) ? (
|
||||
<AddressFieldDisplay />
|
||||
) : isFieldRawJson(fieldDefinition) ? (
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useContext } from 'react';
|
||||
|
||||
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
|
||||
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
|
||||
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
|
||||
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
|
||||
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
|
||||
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
|
||||
@@ -131,6 +133,8 @@ export const FieldInput = ({
|
||||
<RatingFieldInput onSubmit={onSubmit} />
|
||||
) : isFieldSelect(fieldDefinition) ? (
|
||||
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
|
||||
) : isFieldMultiSelect(fieldDefinition) ? (
|
||||
<MultiSelectFieldInput onCancel={onCancel} />
|
||||
) : isFieldAddress(fieldDefinition) ? (
|
||||
<AddressFieldInput
|
||||
onEnter={onEnter}
|
||||
|
||||
@@ -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 { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
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 { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
|
||||
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||
@@ -86,6 +88,10 @@ export const usePersistField = () => {
|
||||
const fieldIsSelect =
|
||||
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
|
||||
|
||||
const fieldIsMultiSelect =
|
||||
isFieldMultiSelect(fieldDefinition) &&
|
||||
isFieldMultiSelectValue(valueToPersist);
|
||||
|
||||
const fieldIsAddress =
|
||||
isFieldAddress(fieldDefinition) &&
|
||||
isFieldAddressValue(valueToPersist);
|
||||
@@ -94,7 +100,7 @@ export const usePersistField = () => {
|
||||
isFieldRawJson(fieldDefinition) &&
|
||||
isFieldRawJsonValue(valueToPersist);
|
||||
|
||||
if (
|
||||
const isValuePersistable =
|
||||
fieldIsRelation ||
|
||||
fieldIsText ||
|
||||
fieldIsBoolean ||
|
||||
@@ -107,9 +113,11 @@ export const usePersistField = () => {
|
||||
fieldIsCurrency ||
|
||||
fieldIsFullName ||
|
||||
fieldIsSelect ||
|
||||
fieldIsMultiSelect ||
|
||||
fieldIsAddress ||
|
||||
fieldIsRawJson
|
||||
) {
|
||||
fieldIsRawJson;
|
||||
|
||||
if (isValuePersistable === true) {
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
set(
|
||||
recordStoreFamilySelector({ recordId: entityId, fieldName }),
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FieldEmailValue,
|
||||
FieldFullNameValue,
|
||||
FieldLinkValue,
|
||||
FieldMultiSelectValue,
|
||||
FieldNumberValue,
|
||||
FieldPhoneValue,
|
||||
FieldRatingValue,
|
||||
@@ -22,6 +23,7 @@ export type FieldDateTimeDraftValue = string;
|
||||
export type FieldPhoneDraftValue = string;
|
||||
export type FieldEmailDraftValue = string;
|
||||
export type FieldSelectDraftValue = string;
|
||||
export type FieldMultiSelectDraftValue = string[];
|
||||
export type FieldRelationDraftValue = string;
|
||||
export type FieldLinkDraftValue = { url: string; label: string };
|
||||
export type FieldCurrencyDraftValue = {
|
||||
@@ -64,6 +66,8 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
|
||||
? FieldRatingValue
|
||||
: FieldValue extends FieldSelectValue
|
||||
? FieldSelectDraftValue
|
||||
: FieldValue extends FieldMultiSelectValue
|
||||
? FieldMultiSelectDraftValue
|
||||
: FieldValue extends FieldRelationValue
|
||||
? FieldRelationDraftValue
|
||||
: FieldValue extends FieldAddressValue
|
||||
|
||||
@@ -103,6 +103,12 @@ export type FieldSelectMetadata = {
|
||||
options: { label: string; color: ThemeColor; value: string }[];
|
||||
};
|
||||
|
||||
export type FieldMultiSelectMetadata = {
|
||||
objectMetadataNameSingular?: string;
|
||||
fieldName: string;
|
||||
options: { label: string; color: ThemeColor; value: string }[];
|
||||
};
|
||||
|
||||
export type FieldMetadata =
|
||||
| FieldBooleanMetadata
|
||||
| FieldCurrencyMetadata
|
||||
@@ -115,6 +121,7 @@ export type FieldMetadata =
|
||||
| FieldRatingMetadata
|
||||
| FieldRelationMetadata
|
||||
| FieldSelectMetadata
|
||||
| FieldMultiSelectMetadata
|
||||
| FieldTextMetadata
|
||||
| FieldUuidMetadata
|
||||
| FieldAddressMetadata;
|
||||
@@ -145,6 +152,7 @@ export type FieldAddressValue = {
|
||||
};
|
||||
export type FieldRatingValue = (typeof RATING_VALUES)[number];
|
||||
export type FieldSelectValue = string | null;
|
||||
export type FieldMultiSelectValue = string[] | null;
|
||||
|
||||
export type FieldRelationValue = EntityForSelect | null;
|
||||
export type FieldJsonValue = string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FieldFullNameMetadata,
|
||||
FieldLinkMetadata,
|
||||
FieldMetadata,
|
||||
FieldMultiSelectMetadata,
|
||||
FieldNumberMetadata,
|
||||
FieldPhoneMetadata,
|
||||
FieldRatingMetadata,
|
||||
@@ -34,6 +35,8 @@ type AssertFieldMetadataFunction = <
|
||||
? FieldEmailMetadata
|
||||
: E extends 'SELECT'
|
||||
? FieldSelectMetadata
|
||||
: E extends 'MULTI_SELECT'
|
||||
? FieldMultiSelectMetadata
|
||||
: E extends 'RATING'
|
||||
? FieldRatingMetadata
|
||||
: E extends 'LINK'
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
|
||||
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
||||
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
|
||||
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
|
||||
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
|
||||
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
@@ -54,6 +56,13 @@ export const isFieldValueEmpty = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isFieldMultiSelect(fieldDefinition)) {
|
||||
return (
|
||||
!isFieldMultiSelectValue(fieldValue, selectOptionValues) ||
|
||||
!isDefined(fieldValue)
|
||||
);
|
||||
}
|
||||
|
||||
if (isFieldCurrency(fieldDefinition)) {
|
||||
return (
|
||||
!isFieldCurrencyValue(fieldValue) ||
|
||||
|
||||
@@ -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();
|
||||
@@ -143,6 +143,7 @@ export const isRecordMatchingFilter = ({
|
||||
case FieldMetadataType.Email:
|
||||
case FieldMetadataType.Phone:
|
||||
case FieldMetadataType.Select:
|
||||
case FieldMetadataType.MultiSelect:
|
||||
case FieldMetadataType.Text: {
|
||||
return isMatchingStringFilter({
|
||||
stringFilter: filterValue as StringFilter,
|
||||
|
||||
@@ -73,7 +73,7 @@ export const generateEmptyFieldValue = (
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.MultiSelect: {
|
||||
throw new Error('Not implemented yet');
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.RawJson: {
|
||||
return null;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type SettingsObjectFieldSelectFormValues =
|
||||
type SettingsObjectFieldSelectFormProps = {
|
||||
onChange: (values: SettingsObjectFieldSelectFormValues) => void;
|
||||
values: SettingsObjectFieldSelectFormValues;
|
||||
isMultiSelect?: boolean;
|
||||
};
|
||||
|
||||
const StyledContainer = styled(CardContent)`
|
||||
@@ -60,6 +61,7 @@ const getNextColor = (currentColor: ThemeColor) => {
|
||||
export const SettingsObjectFieldSelectForm = ({
|
||||
onChange,
|
||||
values,
|
||||
isMultiSelect = false,
|
||||
}: SettingsObjectFieldSelectFormProps) => {
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
@@ -72,6 +74,38 @@ export const SettingsObjectFieldSelectForm = ({
|
||||
onChange(nextOptions);
|
||||
};
|
||||
|
||||
const handleDefaultValueChange = (
|
||||
index: number,
|
||||
option: SettingsObjectFieldSelectFormOption,
|
||||
nextOption: SettingsObjectFieldSelectFormOption,
|
||||
forceUniqueDefaultValue: boolean,
|
||||
) => {
|
||||
const computeUniqueDefaultValue =
|
||||
forceUniqueDefaultValue && option.isDefault !== nextOption.isDefault;
|
||||
|
||||
const nextOptions = computeUniqueDefaultValue
|
||||
? values.map((value) => ({
|
||||
...value,
|
||||
isDefault: false,
|
||||
}))
|
||||
: [...values];
|
||||
|
||||
nextOptions.splice(index, 1, nextOption);
|
||||
onChange(nextOptions);
|
||||
};
|
||||
|
||||
const findNewLabel = () => {
|
||||
let optionIndex = values.length + 1;
|
||||
while (optionIndex < 100) {
|
||||
const newLabel = `Option ${optionIndex}`;
|
||||
if (!values.map((value) => value.label).includes(newLabel)) {
|
||||
return newLabel;
|
||||
}
|
||||
optionIndex += 1;
|
||||
}
|
||||
return `Option 100`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContainer>
|
||||
@@ -91,18 +125,12 @@ export const SettingsObjectFieldSelectForm = ({
|
||||
key={option.value}
|
||||
isDefault={option.isDefault}
|
||||
onChange={(nextOption) => {
|
||||
const hasDefaultOptionChanged =
|
||||
!option.isDefault && nextOption.isDefault;
|
||||
const nextOptions = hasDefaultOptionChanged
|
||||
? values.map((value) => ({
|
||||
...value,
|
||||
isDefault: false,
|
||||
}))
|
||||
: [...values];
|
||||
|
||||
nextOptions.splice(index, 1, nextOption);
|
||||
|
||||
onChange(nextOptions);
|
||||
handleDefaultValueChange(
|
||||
index,
|
||||
option,
|
||||
nextOption,
|
||||
!isMultiSelect,
|
||||
);
|
||||
}}
|
||||
onRemove={
|
||||
values.length > 1
|
||||
@@ -131,7 +159,7 @@ export const SettingsObjectFieldSelectForm = ({
|
||||
...values,
|
||||
{
|
||||
color: getNextColor(values[values.length - 1].color),
|
||||
label: `Option ${values.length + 1}`,
|
||||
label: findNewLabel(),
|
||||
value: v4(),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconPhone,
|
||||
IconRelationManyToMany,
|
||||
IconTag,
|
||||
IconTags,
|
||||
IconTextSize,
|
||||
IconUser,
|
||||
} from 'twenty-ui';
|
||||
@@ -75,8 +76,8 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
|
||||
Icon: IconTag,
|
||||
},
|
||||
[FieldMetadataType.MultiSelect]: {
|
||||
label: 'MultiSelect',
|
||||
Icon: IconTag,
|
||||
label: 'Multi-select',
|
||||
Icon: IconTags,
|
||||
},
|
||||
[FieldMetadataType.Currency]: {
|
||||
label: 'Currency',
|
||||
|
||||
@@ -27,6 +27,7 @@ export type SettingsDataModelFieldSettingsFormValues = {
|
||||
currency: SettingsObjectFieldCurrencyFormValues;
|
||||
relation: SettingsObjectFieldRelationFormValues;
|
||||
select: SettingsObjectFieldSelectFormValues;
|
||||
multiSelect: SettingsObjectFieldSelectFormValues;
|
||||
defaultValue: any;
|
||||
};
|
||||
|
||||
@@ -63,6 +64,7 @@ const previewableTypes = [
|
||||
FieldMetadataType.Currency,
|
||||
FieldMetadataType.DateTime,
|
||||
FieldMetadataType.Select,
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.Number,
|
||||
FieldMetadataType.Rating,
|
||||
@@ -98,7 +100,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
||||
shrink={fieldMetadataItem.type === FieldMetadataType.Relation}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
relationObjectMetadataItem={relationObjectMetadataItem}
|
||||
selectOptions={values.select}
|
||||
selectOptions={
|
||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
||||
? values.multiSelect
|
||||
: values.select
|
||||
}
|
||||
/>
|
||||
{fieldMetadataItem.type === FieldMetadataType.Relation &&
|
||||
!!relationObjectMetadataItem && (
|
||||
@@ -155,6 +161,14 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
||||
onChange({ select: nextSelectValues })
|
||||
}
|
||||
/>
|
||||
) : fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
|
||||
<SettingsObjectFieldSelectForm
|
||||
values={values.multiSelect}
|
||||
onChange={(nextMultiSelectValues) =>
|
||||
onChange({ multiSelect: nextMultiSelectValues })
|
||||
}
|
||||
isMultiSelect={true}
|
||||
/>
|
||||
) : fieldMetadataItem.type === FieldMetadataType.Boolean ? (
|
||||
<SettingsDataModelDefaultValueForm
|
||||
value={values.defaultValue}
|
||||
|
||||
@@ -26,6 +26,7 @@ const defaultValues = {
|
||||
currency: fieldMetadataFormDefaultValues.currency,
|
||||
relation: fieldMetadataFormDefaultValues.relation,
|
||||
select: fieldMetadataFormDefaultValues.select,
|
||||
multiSelect: fieldMetadataFormDefaultValues.multiSelect,
|
||||
defaultValue: fieldMetadataFormDefaultValues.defaultValue,
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ describe('useFieldMetadataForm', () => {
|
||||
select: [
|
||||
{ color: 'green', label: 'Option 1', value: expect.any(String) },
|
||||
],
|
||||
multiSelect: [
|
||||
{ color: 'green', label: 'Option 1', value: expect.any(String) },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,13 +34,19 @@ export const fieldMetadataFormDefaultValues: FormValues = {
|
||||
},
|
||||
defaultValue: null,
|
||||
select: [{ color: 'green', label: 'Option 1', value: v4() }],
|
||||
multiSelect: [{ color: 'green', label: 'Option 1', value: v4() }],
|
||||
};
|
||||
|
||||
const fieldSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
icon: z.string().startsWith('Icon'),
|
||||
label: z.string().min(1),
|
||||
defaultValue: z.any(),
|
||||
type: z.enum(
|
||||
Object.values(FieldMetadataType) as [
|
||||
FieldMetadataType,
|
||||
...FieldMetadataType[],
|
||||
],
|
||||
),
|
||||
});
|
||||
|
||||
const currencySchema = fieldSchema.merge(
|
||||
@@ -83,10 +89,27 @@ const selectSchema = fieldSchema.merge(
|
||||
}),
|
||||
);
|
||||
|
||||
const multiSelectSchema = fieldSchema.merge(
|
||||
z.object({
|
||||
type: z.literal(FieldMetadataType.MultiSelect),
|
||||
multiSelect: z
|
||||
.array(
|
||||
z.object({
|
||||
color: themeColorSchema,
|
||||
id: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
label: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.nonempty(),
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
Currency: _Currency,
|
||||
Relation: _Relation,
|
||||
Select: _Select,
|
||||
MultiSelect: _MultiSelect,
|
||||
...otherFieldTypes
|
||||
} = FieldMetadataType;
|
||||
|
||||
@@ -95,6 +118,7 @@ type OtherFieldType = Exclude<
|
||||
| FieldMetadataType.Currency
|
||||
| FieldMetadataType.Relation
|
||||
| FieldMetadataType.Select
|
||||
| FieldMetadataType.MultiSelect
|
||||
>;
|
||||
|
||||
const otherFieldTypesSchema = fieldSchema.merge(
|
||||
@@ -109,6 +133,7 @@ const schema = z.discriminatedUnion('type', [
|
||||
currencySchema,
|
||||
relationSchema,
|
||||
selectSchema,
|
||||
multiSelectSchema,
|
||||
otherFieldTypesSchema,
|
||||
]);
|
||||
|
||||
@@ -127,6 +152,8 @@ export const useFieldMetadataForm = () => {
|
||||
const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false);
|
||||
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
|
||||
const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false);
|
||||
const [hasMultiSelectFormChanged, setHasMultiSelectFormChanged] =
|
||||
useState(false);
|
||||
const [hasDefaultValueChanged, setHasDefaultValueFormChanged] =
|
||||
useState(false);
|
||||
const [validationResult, setValidationResult] = useState(
|
||||
@@ -174,13 +201,15 @@ export const useFieldMetadataForm = () => {
|
||||
currency: initialCurrencyFormValues,
|
||||
relation: initialRelationFormValues,
|
||||
select: initialSelectFormValues,
|
||||
defaultValue: initalDefaultValue,
|
||||
multiSelect: initialMultiSelectFormValues,
|
||||
defaultValue: initialDefaultValue,
|
||||
...initialFieldFormValues
|
||||
} = initialFormValues;
|
||||
const {
|
||||
currency: nextCurrencyFormValues,
|
||||
relation: nextRelationFormValues,
|
||||
select: nextSelectFormValues,
|
||||
multiSelect: nextMultiSelectFormValues,
|
||||
defaultValue: nextDefaultValue,
|
||||
...nextFieldFormValues
|
||||
} = nextFormValues;
|
||||
@@ -200,9 +229,13 @@ export const useFieldMetadataForm = () => {
|
||||
nextFieldFormValues.type === FieldMetadataType.Select &&
|
||||
!isDeeplyEqual(initialSelectFormValues, nextSelectFormValues),
|
||||
);
|
||||
setHasMultiSelectFormChanged(
|
||||
nextFieldFormValues.type === FieldMetadataType.MultiSelect &&
|
||||
!isDeeplyEqual(initialMultiSelectFormValues, nextMultiSelectFormValues),
|
||||
);
|
||||
setHasDefaultValueFormChanged(
|
||||
nextFieldFormValues.type === FieldMetadataType.Boolean &&
|
||||
!isDeeplyEqual(initalDefaultValue, nextDefaultValue),
|
||||
!isDeeplyEqual(initialDefaultValue, nextDefaultValue),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -215,9 +248,11 @@ export const useFieldMetadataForm = () => {
|
||||
hasCurrencyFormChanged ||
|
||||
hasRelationFormChanged ||
|
||||
hasSelectFormChanged ||
|
||||
hasMultiSelectFormChanged ||
|
||||
hasDefaultValueChanged,
|
||||
hasRelationFormChanged,
|
||||
hasSelectFormChanged,
|
||||
hasMultiSelectFormChanged,
|
||||
hasDefaultValueChanged,
|
||||
initForm,
|
||||
isInitialized,
|
||||
|
||||
@@ -21,7 +21,6 @@ export const getFieldDefaultPreviewValue = ({
|
||||
relationObjectMetadataItem?: ObjectMetadataItem;
|
||||
selectOptions?: SettingsObjectFieldSelectFormValues;
|
||||
}) => {
|
||||
// Select field
|
||||
if (
|
||||
fieldMetadataItem.type === FieldMetadataType.Select &&
|
||||
isDefined(selectOptions)
|
||||
@@ -31,7 +30,13 @@ export const getFieldDefaultPreviewValue = ({
|
||||
return defaultSelectOption.value;
|
||||
}
|
||||
|
||||
// Relation field
|
||||
if (
|
||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
|
||||
isDefined(selectOptions)
|
||||
) {
|
||||
return selectOptions.map((selectOption) => selectOption.value);
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMetadataItem.type === FieldMetadataType.Relation &&
|
||||
isDefined(relationObjectMetadataItem)
|
||||
@@ -60,7 +65,6 @@ export const getFieldDefaultPreviewValue = ({
|
||||
return defaultRelationRecord;
|
||||
}
|
||||
|
||||
// Other fields
|
||||
const isLabelIdentifier =
|
||||
!!fieldMetadataItem.id &&
|
||||
!!fieldMetadataItem.name &&
|
||||
|
||||
@@ -20,6 +20,7 @@ const StyledInnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: ${() => (useIsMobile() ? `100%` : '348px')};
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
|
||||
const StyledIntermediateContainer = styled.div`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -4,4 +4,5 @@ export type FeatureFlagKey =
|
||||
| 'IS_QUICK_ACTIONS_ENABLED'
|
||||
| 'IS_EVENT_OBJECT_ENABLED'
|
||||
| 'IS_AIRTABLE_INTEGRATION_ENABLED'
|
||||
| 'IS_POSTGRESQL_INTEGRATION_ENABLED';
|
||||
| 'IS_POSTGRESQL_INTEGRATION_ENABLED'
|
||||
| 'IS_MULTI_SELECT_ENABLED';
|
||||
|
||||
@@ -42,7 +42,8 @@ const canPersistFieldMetadataItemUpdate = (
|
||||
) => {
|
||||
return (
|
||||
fieldMetadataItem.isCustom ||
|
||||
fieldMetadataItem.type === FieldMetadataType.Select
|
||||
fieldMetadataItem.type === FieldMetadataType.Select ||
|
||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,6 +88,7 @@ export const SettingsObjectFieldEdit = () => {
|
||||
hasFormChanged,
|
||||
hasRelationFormChanged,
|
||||
hasSelectFormChanged,
|
||||
hasMultiSelectFormChanged,
|
||||
initForm,
|
||||
isInitialized,
|
||||
isValid,
|
||||
@@ -114,6 +116,14 @@ export const SettingsObjectFieldEdit = () => {
|
||||
(optionA, optionB) => optionA.position - optionB.position,
|
||||
);
|
||||
|
||||
const multiSelectOptions = activeMetadataField.options?.map((option) => ({
|
||||
...option,
|
||||
isDefault: defaultValue?.includes(`'${option.value}'`) || false,
|
||||
}));
|
||||
multiSelectOptions?.sort(
|
||||
(optionA, optionB) => optionA.position - optionB.position,
|
||||
);
|
||||
|
||||
const fieldType = activeMetadataField.type;
|
||||
const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType);
|
||||
|
||||
@@ -135,6 +145,9 @@ export const SettingsObjectFieldEdit = () => {
|
||||
},
|
||||
defaultValue: activeMetadataField.defaultValue,
|
||||
...(selectOptions?.length ? { select: selectOptions } : {}),
|
||||
...(multiSelectOptions?.length
|
||||
? { multiSelect: multiSelectOptions }
|
||||
: {}),
|
||||
});
|
||||
}, [
|
||||
activeMetadataField,
|
||||
@@ -170,11 +183,13 @@ export const SettingsObjectFieldEdit = () => {
|
||||
icon: validatedFormValues.relation.field.icon,
|
||||
id: relationFieldMetadataItem?.id,
|
||||
label: validatedFormValues.relation.field.label,
|
||||
type: validatedFormValues.type,
|
||||
});
|
||||
}
|
||||
if (
|
||||
hasFieldFormChanged ||
|
||||
hasSelectFormChanged ||
|
||||
hasMultiSelectFormChanged ||
|
||||
hasDefaultValueChanged
|
||||
) {
|
||||
await editMetadataField({
|
||||
@@ -183,9 +198,12 @@ export const SettingsObjectFieldEdit = () => {
|
||||
id: activeMetadataField.id,
|
||||
label: validatedFormValues.label,
|
||||
defaultValue: validatedFormValues.defaultValue,
|
||||
type: validatedFormValues.type,
|
||||
options:
|
||||
validatedFormValues.type === FieldMetadataType.Select
|
||||
? validatedFormValues.select
|
||||
: validatedFormValues.type === FieldMetadataType.MultiSelect
|
||||
? validatedFormValues.multiSelect
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -261,6 +279,7 @@ export const SettingsObjectFieldEdit = () => {
|
||||
currency: formValues.currency,
|
||||
relation: formValues.relation,
|
||||
select: formValues.select,
|
||||
multiSelect: formValues.multiSelect,
|
||||
defaultValue: formValues.defaultValue,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { View } from '@/views/types/View';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled.ts';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
@@ -41,6 +42,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
const navigate = useNavigate();
|
||||
const { objectSlug = '' } = useParams();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const isMultiSelectEnabled = useIsFeatureEnabled('IS_MULTI_SELECT_ENABLED');
|
||||
|
||||
const {
|
||||
findActiveObjectMetadataItemBySlug,
|
||||
@@ -132,12 +134,14 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
description: validatedFormValues.description,
|
||||
icon: validatedFormValues.icon,
|
||||
label: validatedFormValues.label,
|
||||
type: validatedFormValues.type,
|
||||
},
|
||||
objectMetadataId: activeObjectMetadataItem.id,
|
||||
connect: {
|
||||
field: {
|
||||
icon: validatedFormValues.relation.field.icon,
|
||||
label: validatedFormValues.relation.field.label,
|
||||
type: validatedFormValues.relation.field.type,
|
||||
},
|
||||
objectMetadataId: validatedFormValues.relation.objectMetadataId,
|
||||
},
|
||||
@@ -147,7 +151,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
validatedFormValues.relation.objectMetadataId,
|
||||
);
|
||||
|
||||
objectViews.forEach(async (view) => {
|
||||
objectViews.map(async (view) => {
|
||||
const viewFieldToCreate = {
|
||||
viewId: view.id,
|
||||
fieldMetadataId:
|
||||
@@ -180,7 +184,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
recordId: view.id,
|
||||
});
|
||||
|
||||
relationObjectViews.forEach(async (view) => {
|
||||
relationObjectViews.map(async (view) => {
|
||||
const viewFieldToCreate = {
|
||||
viewId: view.id,
|
||||
fieldMetadataId:
|
||||
@@ -230,10 +234,12 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
options:
|
||||
validatedFormValues.type === FieldMetadataType.Select
|
||||
? validatedFormValues.select
|
||||
: validatedFormValues.type === FieldMetadataType.MultiSelect
|
||||
? validatedFormValues.multiSelect
|
||||
: undefined,
|
||||
});
|
||||
|
||||
objectViews.forEach(async (view) => {
|
||||
objectViews.map(async (view) => {
|
||||
const viewFieldToCreate = {
|
||||
viewId: view.id,
|
||||
fieldMetadataId: createdMetadataField.data?.createOneField.id,
|
||||
@@ -278,13 +284,16 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
FieldMetadataType.Email,
|
||||
FieldMetadataType.FullName,
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Numeric,
|
||||
FieldMetadataType.Phone,
|
||||
FieldMetadataType.Probability,
|
||||
FieldMetadataType.Uuid,
|
||||
];
|
||||
|
||||
if (!isMultiSelectEnabled) {
|
||||
excludedFieldTypes.push(FieldMetadataType.MultiSelect);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<SettingsPageContainer>
|
||||
@@ -335,6 +344,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
currency: formValues.currency,
|
||||
relation: formValues.relation,
|
||||
select: formValues.select,
|
||||
multiSelect: formValues.multiSelect,
|
||||
defaultValue: formValues.defaultValue,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -40,11 +40,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.mainDataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a data source using metadata. Returns a cached connection if it exists.
|
||||
* @param dataSource DataSourceEntity
|
||||
* @returns Promise<DataSource | undefined>
|
||||
*/
|
||||
public async connectToDataSource(
|
||||
dataSource: DataSourceEntity,
|
||||
): Promise<DataSource | undefined> {
|
||||
@@ -96,12 +91,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
||||
return workspaceDataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from a workspace data source.
|
||||
* @param dataSourceId
|
||||
* @returns Promise<void>
|
||||
*
|
||||
*/
|
||||
public async disconnectFromDataSource(dataSourceId: string) {
|
||||
if (!this.dataSources.has(dataSourceId)) {
|
||||
return;
|
||||
@@ -114,11 +103,6 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
||||
this.dataSources.delete(dataSourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new schema
|
||||
* @param workspaceId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
public async createSchema(schemaName: string): Promise<string> {
|
||||
const queryRunner = this.mainDataSource.createQueryRunner();
|
||||
|
||||
|
||||
@@ -306,9 +306,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
...updatableFieldInput,
|
||||
defaultValue:
|
||||
// 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
|
||||
: updatableFieldInput.defaultValue !== null
|
||||
? updatableFieldInput.defaultValue
|
||||
|
||||
@@ -15,8 +15,8 @@ export enum WorkspaceMigrationColumnActionType {
|
||||
DROP = 'DROP',
|
||||
CREATE_COMMENT = 'CREATE_COMMENT',
|
||||
}
|
||||
|
||||
export type WorkspaceMigrationEnum = string | { from: string; to: string };
|
||||
export type WorkspaceMigrationRenamedEnum = { from: string; to: string };
|
||||
export type WorkspaceMigrationEnum = string | WorkspaceMigrationRenamedEnum;
|
||||
|
||||
export interface WorkspaceMigrationColumnDefinition {
|
||||
columnName: string;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@@ -28,8 +32,9 @@ export class WorkspaceMigrationEnumService {
|
||||
}
|
||||
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
const oldEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum`;
|
||||
const newEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum_new`;
|
||||
const oldEnumTypeName =
|
||||
`${tableName}_${migrationColumn.currentColumnDefinition.columnName}_enum`.toLowerCase();
|
||||
const tempEnumTypeName = `${oldEnumTypeName}_temp`;
|
||||
const enumValues =
|
||||
columnDefinition.enum?.map((enumValue) => {
|
||||
if (typeof enumValue === 'string') {
|
||||
@@ -38,62 +43,61 @@ export class WorkspaceMigrationEnumService {
|
||||
|
||||
return enumValue.to;
|
||||
}) ?? [];
|
||||
const renamedEnumValues = columnDefinition.enum?.filter(
|
||||
(enumValue): enumValue is WorkspaceMigrationRenamedEnum =>
|
||||
typeof enumValue !== 'string',
|
||||
);
|
||||
|
||||
if (!columnDefinition.isNullable && !columnDefinition.defaultValue) {
|
||||
columnDefinition.defaultValue = serializeDefaultValue(enumValues[0]);
|
||||
}
|
||||
|
||||
// Create new enum type with new values
|
||||
await this.createNewEnumType(
|
||||
newEnumTypeName,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumValues,
|
||||
);
|
||||
const oldColumnName = `${columnDefinition.columnName}_old_${v4()}`;
|
||||
|
||||
// Temporarily change column type to text
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "${schemaName}"."${tableName}"
|
||||
ALTER COLUMN "${columnDefinition.columnName}" TYPE TEXT
|
||||
`);
|
||||
|
||||
// Migrate existing values to new values
|
||||
await this.migrateEnumValues(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
);
|
||||
|
||||
// Update existing rows to handle missing values
|
||||
await this.handleMissingEnumValues(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
enumValues,
|
||||
);
|
||||
|
||||
// Alter column type to new enum
|
||||
await this.updateColumnToNewEnum(
|
||||
// Rename old column
|
||||
await this.renameColumn(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition.columnName,
|
||||
newEnumTypeName,
|
||||
columnDefinition.defaultValue,
|
||||
oldColumnName,
|
||||
);
|
||||
|
||||
// Drop old enum type
|
||||
await this.dropOldEnumType(queryRunner, schemaName, oldEnumTypeName);
|
||||
|
||||
// Rename new enum type to old enum type name
|
||||
await this.renameEnumType(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldEnumTypeName,
|
||||
newEnumTypeName,
|
||||
tempEnumTypeName,
|
||||
);
|
||||
|
||||
await queryRunner.addColumn(
|
||||
`${schemaName}.${tableName}`,
|
||||
new TableColumn({
|
||||
name: columnDefinition.columnName,
|
||||
type: columnDefinition.columnType,
|
||||
default: columnDefinition.defaultValue,
|
||||
enum: enumValues,
|
||||
isArray: columnDefinition.isArray,
|
||||
isNullable: columnDefinition.isNullable,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.migrateEnumValues(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
migrationColumn,
|
||||
tableName,
|
||||
oldColumnName,
|
||||
enumValues,
|
||||
renamedEnumValues,
|
||||
);
|
||||
|
||||
// Drop old column
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "${schemaName}"."${tableName}"
|
||||
DROP COLUMN "${oldColumnName}"
|
||||
`);
|
||||
// Drop temp enum type
|
||||
await this.dropOldEnumType(queryRunner, schemaName, tempEnumTypeName);
|
||||
}
|
||||
|
||||
private async renameColumn(
|
||||
@@ -109,90 +113,52 @@ export class WorkspaceMigrationEnumService {
|
||||
`);
|
||||
}
|
||||
|
||||
private async createNewEnumType(
|
||||
name: string,
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
newValues: string[],
|
||||
private migrateEnumValue(
|
||||
value: string,
|
||||
renamedEnumValues?: WorkspaceMigrationRenamedEnum[],
|
||||
) {
|
||||
const enumValues = newValues
|
||||
.map((value) => `'${value.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "${schemaName}"."${name}" AS ENUM (${enumValues})`,
|
||||
return (
|
||||
renamedEnumValues?.find((enumVal) => enumVal?.from === value)?.to || value
|
||||
);
|
||||
}
|
||||
|
||||
private async migrateEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
|
||||
if (!columnDefinition.enum) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const enumValue of columnDefinition.enum) {
|
||||
// Skip string values
|
||||
if (typeof enumValue === 'string') {
|
||||
continue;
|
||||
}
|
||||
await queryRunner.query(`
|
||||
UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${columnDefinition.columnName}" = '${enumValue.to}'
|
||||
WHERE "${columnDefinition.columnName}" = '${enumValue.from}'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
oldColumnName: string,
|
||||
enumValues: string[],
|
||||
renamedEnumValues?: WorkspaceMigrationRenamedEnum[],
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
|
||||
// Set missing values to null or default value
|
||||
let defaultValue = 'NULL';
|
||||
const values = await queryRunner.query(
|
||||
`SELECT id, "${oldColumnName}" FROM "${schemaName}"."${tableName}"`,
|
||||
);
|
||||
|
||||
if (columnDefinition.defaultValue) {
|
||||
if (Array.isArray(columnDefinition.defaultValue)) {
|
||||
defaultValue = `ARRAY[${columnDefinition.defaultValue
|
||||
.map((e) => `'${e}'`)
|
||||
.join(', ')}]`;
|
||||
} else {
|
||||
defaultValue = columnDefinition.defaultValue;
|
||||
}
|
||||
values.map(async (value) => {
|
||||
let val = value[oldColumnName];
|
||||
|
||||
if (/^\{.*\}$/.test(val)) {
|
||||
val = serializeDefaultValue(
|
||||
val
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((v: string) => v.trim())
|
||||
.map((v: string) => this.migrateEnumValue(v, renamedEnumValues))
|
||||
.filter((v: string) => enumValues.includes(v)),
|
||||
);
|
||||
} else if (typeof val === 'string') {
|
||||
val = `'${this.migrateEnumValue(val, renamedEnumValues)}'`;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${columnDefinition.columnName}" = ${defaultValue}
|
||||
WHERE "${columnDefinition.columnName}" NOT IN (${enumValues
|
||||
.map((e) => `'${e}'`)
|
||||
.join(', ')})
|
||||
SET "${columnDefinition.columnName}" = ${val}
|
||||
WHERE id='${value.id}'
|
||||
`);
|
||||
}
|
||||
|
||||
private async updateColumnToNewEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
newEnumTypeName: string,
|
||||
newDefaultValue: string,
|
||||
) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT,
|
||||
ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}"),
|
||||
ALTER COLUMN "${columnName}" SET DEFAULT ${newDefaultValue}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async dropOldEnumType(
|
||||
@@ -212,8 +178,8 @@ export class WorkspaceMigrationEnumService {
|
||||
newEnumTypeName: string,
|
||||
) {
|
||||
await queryRunner.query(`
|
||||
ALTER TYPE "${schemaName}"."${newEnumTypeName}"
|
||||
RENAME TO "${oldEnumTypeName}"
|
||||
ALTER TYPE "${schemaName}"."${oldEnumTypeName}"
|
||||
RENAME TO "${newEnumTypeName}"
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export {
|
||||
IconSortDescending,
|
||||
IconTable,
|
||||
IconTag,
|
||||
IconTags,
|
||||
IconTarget,
|
||||
IconTargetArrow,
|
||||
IconTextSize,
|
||||
|
||||
Reference in New Issue
Block a user