mirror of
https://github.com/lingble/twenty.git
synced 2025-11-25 18:34:56 +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');
|
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,
|
||||||
|
|||||||
@@ -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() },
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export type FilterType =
|
|||||||
| 'LINK'
|
| 'LINK'
|
||||||
| 'RELATION'
|
| 'RELATION'
|
||||||
| 'ADDRESS'
|
| 'ADDRESS'
|
||||||
| 'SELECT';
|
| 'SELECT'
|
||||||
|
| 'MULTI_SELECT';
|
||||||
|
|||||||
@@ -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) ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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,
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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 { 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) ||
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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_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';
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}"
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export {
|
|||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconTable,
|
IconTable,
|
||||||
IconTag,
|
IconTag,
|
||||||
|
IconTags,
|
||||||
IconTarget,
|
IconTarget,
|
||||||
IconTargetArrow,
|
IconTargetArrow,
|
||||||
IconTextSize,
|
IconTextSize,
|
||||||
|
|||||||
Reference in New Issue
Block a user