mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 12:02:10 +03:00
Refactor default value for select (#5343)
In this PR, we are refactoring two things: - leverage field.defaultValue for Select and MultiSelect settings form (instead of option.isDefault) - use quoted string (ex: "'USD'") for string default values to embrace backend format --------- Co-authored-by: Thaïs Guigon <guigon.thais@gmail.com>
This commit is contained in:
parent
7728c09dba
commit
8590bd7227
@ -1,8 +0,0 @@
|
|||||||
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
|
|
||||||
|
|
||||||
export type FieldMetadataOption = {
|
|
||||||
color?: ThemeColor;
|
|
||||||
id?: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
label: string;
|
|
||||||
};
|
|
@ -1,59 +1,33 @@
|
|||||||
|
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
import {
|
import { formatFieldMetadataItemInput } from '../formatFieldMetadataItemInput';
|
||||||
formatFieldMetadataItemInput,
|
|
||||||
getOptionValueFromLabel,
|
|
||||||
} from '../formatFieldMetadataItemInput';
|
|
||||||
|
|
||||||
describe('getOptionValueFromLabel', () => {
|
|
||||||
it('should return the option value from the label', () => {
|
|
||||||
const label = 'Example Label';
|
|
||||||
const expected = 'EXAMPLE_LABEL';
|
|
||||||
|
|
||||||
const result = getOptionValueFromLabel(label);
|
|
||||||
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle labels with accents', () => {
|
|
||||||
const label = 'Éxàmplè Làbèl';
|
|
||||||
const expected = 'EXAMPLE_LABEL';
|
|
||||||
|
|
||||||
const result = getOptionValueFromLabel(label);
|
|
||||||
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle labels with special characters', () => {
|
|
||||||
const label = 'Example!@#$%^&*() Label';
|
|
||||||
const expected = 'EXAMPLE_LABEL';
|
|
||||||
|
|
||||||
const result = getOptionValueFromLabel(label);
|
|
||||||
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle labels with emojis', () => {
|
|
||||||
const label = '📱 Example Label';
|
|
||||||
const expected = 'EXAMPLE_LABEL';
|
|
||||||
|
|
||||||
const result = getOptionValueFromLabel(label);
|
|
||||||
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatFieldMetadataItemInput', () => {
|
describe('formatFieldMetadataItemInput', () => {
|
||||||
it('should format the field metadata item input correctly', () => {
|
it('should format the field metadata item input correctly', () => {
|
||||||
|
const options: FieldMetadataItemOption[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Option 1',
|
||||||
|
color: 'red' as const,
|
||||||
|
position: 0,
|
||||||
|
value: 'OPTION_1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'Option 2',
|
||||||
|
color: 'blue' as const,
|
||||||
|
position: 1,
|
||||||
|
value: 'OPTION_2',
|
||||||
|
},
|
||||||
|
];
|
||||||
const input = {
|
const input = {
|
||||||
|
defaultValue: "'OPTION_1'",
|
||||||
label: 'Example Label',
|
label: 'Example Label',
|
||||||
icon: 'example-icon',
|
icon: 'example-icon',
|
||||||
type: FieldMetadataType.Select,
|
type: FieldMetadataType.Select,
|
||||||
description: 'Example description',
|
description: 'Example description',
|
||||||
options: [
|
options,
|
||||||
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
|
|
||||||
{ id: '2', label: 'Option 2', color: 'blue' as const },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
@ -61,22 +35,7 @@ describe('formatFieldMetadataItemInput', () => {
|
|||||||
icon: 'example-icon',
|
icon: 'example-icon',
|
||||||
label: 'Example Label',
|
label: 'Example Label',
|
||||||
name: 'exampleLabel',
|
name: 'exampleLabel',
|
||||||
options: [
|
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'",
|
defaultValue: "'OPTION_1'",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,15 +67,29 @@ describe('formatFieldMetadataItemInput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should format the field metadata item multi select input correctly', () => {
|
it('should format the field metadata item multi select input correctly', () => {
|
||||||
|
const options: FieldMetadataItemOption[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Option 1',
|
||||||
|
color: 'red' as const,
|
||||||
|
position: 0,
|
||||||
|
value: 'OPTION_1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'Option 2',
|
||||||
|
color: 'blue' as const,
|
||||||
|
position: 1,
|
||||||
|
value: 'OPTION_2',
|
||||||
|
},
|
||||||
|
];
|
||||||
const input = {
|
const input = {
|
||||||
|
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
|
||||||
label: 'Example Label',
|
label: 'Example Label',
|
||||||
icon: 'example-icon',
|
icon: 'example-icon',
|
||||||
type: FieldMetadataType.MultiSelect,
|
type: FieldMetadataType.MultiSelect,
|
||||||
description: 'Example description',
|
description: 'Example description',
|
||||||
options: [
|
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 = {
|
const expected = {
|
||||||
@ -124,22 +97,7 @@ describe('formatFieldMetadataItemInput', () => {
|
|||||||
icon: 'example-icon',
|
icon: 'example-icon',
|
||||||
label: 'Example Label',
|
label: 'Example Label',
|
||||||
name: 'exampleLabel',
|
name: 'exampleLabel',
|
||||||
options: [
|
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'"],
|
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,82 +1,22 @@
|
|||||||
import toSnakeCase from 'lodash.snakecase';
|
|
||||||
|
|
||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend';
|
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
|
||||||
import { formatMetadataLabelToMetadataNameOrThrows } from '~/pages/settings/data-model/utils/format-metadata-label-to-name.util';
|
import { formatMetadataLabelToMetadataNameOrThrows } from '~/pages/settings/data-model/utils/format-metadata-label-to-name.util';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
|
||||||
|
|
||||||
import { FieldMetadataOption } from '../types/FieldMetadataOption';
|
|
||||||
|
|
||||||
export const getOptionValueFromLabel = (label: string) => {
|
|
||||||
// Remove accents
|
|
||||||
const unaccentedLabel = label
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '');
|
|
||||||
// Remove special characters
|
|
||||||
const noSpecialCharactersLabel = unaccentedLabel.replace(
|
|
||||||
/[^a-zA-Z0-9 ]/g,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
|
|
||||||
return toSnakeCase(noSpecialCharactersLabel).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatFieldMetadataItemInput = (
|
export const formatFieldMetadataItemInput = (
|
||||||
input: Partial<
|
input: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
FieldMetadataItem,
|
FieldMetadataItem,
|
||||||
'type' | 'label' | 'defaultValue' | 'icon' | 'description'
|
'type' | 'label' | 'defaultValue' | 'icon' | 'description' | 'options'
|
||||||
>
|
>
|
||||||
> & { options?: FieldMetadataOption[] },
|
>,
|
||||||
) => {
|
) => {
|
||||||
const options = input.options as FieldMetadataOption[] | undefined;
|
|
||||||
let defaultValue = input.defaultValue;
|
|
||||||
if (input.type === FieldMetadataType.MultiSelect) {
|
|
||||||
defaultValue = options
|
|
||||||
?.filter((option) => option.isDefault)
|
|
||||||
?.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 (options !== undefined) {
|
|
||||||
// Compute the values based on the label
|
|
||||||
const values = options.map((option) =>
|
|
||||||
getOptionValueFromLabel(option.label),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (new Set(values).size !== options.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Options must have unique values, but contains the following duplicates ${values.join(
|
|
||||||
', ',
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = input.label?.trim();
|
const label = input.label?.trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultValue:
|
defaultValue: input.defaultValue,
|
||||||
isDefined(defaultValue) && input.type
|
|
||||||
? getDefaultValueForBackend(defaultValue, input.type)
|
|
||||||
: undefined,
|
|
||||||
description: input.description?.trim() ?? null,
|
description: input.description?.trim() ?? null,
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
label,
|
label,
|
||||||
name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined,
|
name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined,
|
||||||
options: options?.map((option, index) => ({
|
options: input.options,
|
||||||
color: option.color,
|
|
||||||
id: option.id,
|
|
||||||
label: option.label.trim(),
|
|
||||||
position: index,
|
|
||||||
value: getOptionValueFromLabel(option.label),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
|
|
||||||
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
|
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
|
||||||
|
|
||||||
export const getDefaultValueForBackend = (
|
|
||||||
defaultValue: any,
|
|
||||||
fieldMetadataType: FieldMetadataType,
|
|
||||||
) => {
|
|
||||||
if (fieldMetadataType === FieldMetadataType.Currency) {
|
|
||||||
const currencyDefaultValue = defaultValue as FieldCurrencyValue;
|
|
||||||
return {
|
|
||||||
amountMicros: currencyDefaultValue.amountMicros,
|
|
||||||
currencyCode: `'${currencyDefaultValue.currencyCode}'` as CurrencyCode,
|
|
||||||
} satisfies FieldCurrencyValue;
|
|
||||||
} else if (fieldMetadataType === FieldMetadataType.Select) {
|
|
||||||
return defaultValue ? `'${defaultValue}'` : null;
|
|
||||||
} else if (fieldMetadataType === FieldMetadataType.MultiSelect) {
|
|
||||||
return defaultValue.map((value: string) => `'${value}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultValue;
|
|
||||||
};
|
|
@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
|
||||||
|
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
|
||||||
|
|
||||||
|
const selectOptionSchema = z
|
||||||
|
.object({
|
||||||
|
color: themeColorSchema,
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string().trim().min(1),
|
||||||
|
position: z.number(),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.refine((option) => option.value === getOptionValueFromLabel(option.label), {
|
||||||
|
message: 'Value does not match label',
|
||||||
|
}) satisfies z.ZodType<FieldMetadataItemOption>;
|
||||||
|
|
||||||
|
export const selectOptionsSchema = z
|
||||||
|
.array(selectOptionSchema)
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(options) => {
|
||||||
|
const optionIds = options.map(({ id }) => id);
|
||||||
|
return new Set(optionIds).size === options.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Options must have unique ids',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(options) => {
|
||||||
|
const optionValues = options.map(({ value }) => value);
|
||||||
|
return new Set(optionValues).size === options.length;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Options must have unique values',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(options) =>
|
||||||
|
[...options].sort().every((option, index) => option.position === index),
|
||||||
|
{
|
||||||
|
message: 'Options positions must be sequential',
|
||||||
|
},
|
||||||
|
);
|
@ -0,0 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
|
||||||
|
|
||||||
|
export const currencyCodeSchema = z.nativeEnum(CurrencyCode);
|
@ -2,47 +2,54 @@ import { Controller, useFormContext } from 'react-hook-form';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { DropResult } from '@hello-pangea/dnd';
|
import { DropResult } from '@hello-pangea/dnd';
|
||||||
import { IconPlus } from 'twenty-ui';
|
import { IconPlus } from 'twenty-ui';
|
||||||
import { v4 } from 'uuid';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import {
|
||||||
import { SettingsObjectFieldSelectFormOption } from '@/settings/data-model/types/SettingsObjectFieldSelectFormOption';
|
FieldMetadataItem,
|
||||||
|
FieldMetadataItemOption,
|
||||||
|
} from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { selectOptionsSchema } from '@/object-metadata/validation-schemas/selectOptionsSchema';
|
||||||
|
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/hooks/useSelectSettingsFormInitialValues';
|
||||||
|
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/utils/generateNewSelectOption';
|
||||||
|
import { isSelectOptionDefaultValue } from '@/settings/data-model/utils/isSelectOptionDefaultValue';
|
||||||
import { LightButton } from '@/ui/input/button/components/LightButton';
|
import { LightButton } from '@/ui/input/button/components/LightButton';
|
||||||
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
||||||
import { CardFooter } from '@/ui/layout/card/components/CardFooter';
|
import { CardFooter } from '@/ui/layout/card/components/CardFooter';
|
||||||
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
||||||
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
|
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
|
||||||
import {
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
MAIN_COLOR_NAMES,
|
|
||||||
ThemeColor,
|
|
||||||
} from '@/ui/theme/constants/MainColorNames';
|
|
||||||
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
|
|
||||||
import { moveArrayItem } from '~/utils/array/moveArrayItem';
|
import { moveArrayItem } from '~/utils/array/moveArrayItem';
|
||||||
|
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
|
||||||
|
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
|
||||||
|
|
||||||
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
|
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
|
||||||
|
|
||||||
// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components
|
// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components
|
||||||
|
|
||||||
export const settingsDataModelFieldSelectFormSchema = z.object({
|
export const settingsDataModelFieldSelectFormSchema = z.object({
|
||||||
options: z
|
defaultValue: simpleQuotesStringSchema.nullable(),
|
||||||
.array(
|
options: selectOptionsSchema,
|
||||||
z.object({
|
|
||||||
color: themeColorSchema,
|
|
||||||
value: z.string(),
|
|
||||||
isDefault: z.boolean().optional(),
|
|
||||||
label: z.string().min(1),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const settingsDataModelFieldMultiSelectFormSchema = z.object({
|
||||||
|
defaultValue: z.array(simpleQuotesStringSchema).nullable(),
|
||||||
|
options: selectOptionsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectOrMultiSelectFormSchema = z.union([
|
||||||
|
settingsDataModelFieldSelectFormSchema,
|
||||||
|
settingsDataModelFieldMultiSelectFormSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export type SettingsDataModelFieldSelectFormValues = z.infer<
|
export type SettingsDataModelFieldSelectFormValues = z.infer<
|
||||||
typeof settingsDataModelFieldSelectFormSchema
|
typeof selectOrMultiSelectFormSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type SettingsDataModelFieldSelectFormProps = {
|
type SettingsDataModelFieldSelectFormProps = {
|
||||||
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
|
fieldMetadataItem: Pick<
|
||||||
isMultiSelect?: boolean;
|
FieldMetadataItem,
|
||||||
|
'defaultValue' | 'options' | 'type'
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledContainer = styled(CardContent)`
|
const StyledContainer = styled(CardContent)`
|
||||||
@ -68,155 +75,178 @@ const StyledButton = styled(LightButton)`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getNextColor = (currentColor: ThemeColor) => {
|
|
||||||
const currentColorIndex = MAIN_COLOR_NAMES.findIndex(
|
|
||||||
(color) => color === currentColor,
|
|
||||||
);
|
|
||||||
const nextColorIndex = (currentColorIndex + 1) % MAIN_COLOR_NAMES.length;
|
|
||||||
return MAIN_COLOR_NAMES[nextColorIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultValueOptionIndexes = (
|
|
||||||
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>,
|
|
||||||
) =>
|
|
||||||
fieldMetadataItem?.options?.reduce<number[]>((result, option, index) => {
|
|
||||||
if (
|
|
||||||
Array.isArray(fieldMetadataItem?.defaultValue) &&
|
|
||||||
fieldMetadataItem?.defaultValue.includes(`'${option.value}'`)
|
|
||||||
) {
|
|
||||||
return [...result, index];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure default value is unique for simple Select field
|
|
||||||
if (
|
|
||||||
!result.length &&
|
|
||||||
fieldMetadataItem?.defaultValue === `'${option.value}'`
|
|
||||||
) {
|
|
||||||
return [index];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const DEFAULT_OPTION: SettingsObjectFieldSelectFormOption = {
|
|
||||||
color: 'green',
|
|
||||||
label: 'Option 1',
|
|
||||||
value: v4(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsDataModelFieldSelectForm = ({
|
export const SettingsDataModelFieldSelectForm = ({
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
isMultiSelect = false,
|
|
||||||
}: SettingsDataModelFieldSelectFormProps) => {
|
}: SettingsDataModelFieldSelectFormProps) => {
|
||||||
const { control } = useFormContext<SettingsDataModelFieldSelectFormValues>();
|
const { initialDefaultValue, initialOptions } =
|
||||||
|
useSelectSettingsFormInitialValues({ fieldMetadataItem });
|
||||||
|
|
||||||
const initialDefaultValueOptionIndexes =
|
const {
|
||||||
getDefaultValueOptionIndexes(fieldMetadataItem);
|
control,
|
||||||
|
setValue: setFormValue,
|
||||||
const initialValue = fieldMetadataItem?.options
|
watch: watchFormValue,
|
||||||
?.map((option, index) => ({
|
getValues,
|
||||||
...option,
|
} = useFormContext<SettingsDataModelFieldSelectFormValues>();
|
||||||
isDefault: initialDefaultValueOptionIndexes?.includes(index),
|
|
||||||
}))
|
|
||||||
.sort((optionA, optionB) => optionA.position - optionB.position);
|
|
||||||
|
|
||||||
const handleDragEnd = (
|
const handleDragEnd = (
|
||||||
values: SettingsObjectFieldSelectFormOption[],
|
values: FieldMetadataItemOption[],
|
||||||
result: DropResult,
|
result: DropResult,
|
||||||
onChange: (options: SettingsObjectFieldSelectFormOption[]) => void,
|
onChange: (options: FieldMetadataItemOption[]) => void,
|
||||||
) => {
|
) => {
|
||||||
if (!result.destination) return;
|
if (!result.destination) return;
|
||||||
|
|
||||||
const nextOptions = moveArrayItem(values, {
|
const nextOptions = moveArrayItem(values, {
|
||||||
fromIndex: result.source.index,
|
fromIndex: result.source.index,
|
||||||
toIndex: result.destination.index,
|
toIndex: result.destination.index,
|
||||||
});
|
}).map((option, index) => ({ ...option, position: index }));
|
||||||
|
|
||||||
onChange(nextOptions);
|
onChange(nextOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => {
|
const isOptionDefaultValue = (
|
||||||
let optionIndex = values.length + 1;
|
optionValue: FieldMetadataItemOption['value'],
|
||||||
while (optionIndex < 100) {
|
) =>
|
||||||
const newLabel = `Option ${optionIndex}`;
|
isSelectOptionDefaultValue(optionValue, {
|
||||||
if (!values.map((value) => value.label).includes(newLabel)) {
|
type: fieldMetadataItem.type,
|
||||||
return newLabel;
|
defaultValue: watchFormValue('defaultValue'),
|
||||||
}
|
});
|
||||||
optionIndex += 1;
|
|
||||||
|
const handleSetOptionAsDefault = (
|
||||||
|
optionValue: FieldMetadataItemOption['value'],
|
||||||
|
) => {
|
||||||
|
if (isOptionDefaultValue(optionValue)) return;
|
||||||
|
|
||||||
|
if (fieldMetadataItem.type === FieldMetadataType.Select) {
|
||||||
|
setFormValue('defaultValue', applySimpleQuotesToString(optionValue), {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousDefaultValue = getValues('defaultValue');
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
|
||||||
|
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
|
||||||
|
) {
|
||||||
|
setFormValue(
|
||||||
|
'defaultValue',
|
||||||
|
[
|
||||||
|
...(previousDefaultValue ?? []),
|
||||||
|
applySimpleQuotesToString(optionValue),
|
||||||
|
],
|
||||||
|
{ shouldDirty: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveOptionAsDefault = (
|
||||||
|
optionValue: FieldMetadataItemOption['value'],
|
||||||
|
) => {
|
||||||
|
if (!isOptionDefaultValue(optionValue)) return;
|
||||||
|
|
||||||
|
if (fieldMetadataItem.type === FieldMetadataType.Select) {
|
||||||
|
setFormValue('defaultValue', null, { shouldDirty: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousDefaultValue = getValues('defaultValue');
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
|
||||||
|
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
|
||||||
|
) {
|
||||||
|
const nextDefaultValue = previousDefaultValue?.filter(
|
||||||
|
(value) => value !== applySimpleQuotesToString(optionValue),
|
||||||
|
);
|
||||||
|
setFormValue(
|
||||||
|
'defaultValue',
|
||||||
|
nextDefaultValue?.length ? nextDefaultValue : null,
|
||||||
|
{ shouldDirty: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return `Option 100`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<>
|
||||||
name="options"
|
<Controller
|
||||||
control={control}
|
name="defaultValue"
|
||||||
defaultValue={initialValue?.length ? initialValue : [DEFAULT_OPTION]}
|
control={control}
|
||||||
render={({ field: { onChange, value: options } }) => (
|
defaultValue={initialDefaultValue}
|
||||||
<>
|
render={() => <></>}
|
||||||
<StyledContainer>
|
/>
|
||||||
<StyledLabel>Options</StyledLabel>
|
<Controller
|
||||||
<DraggableList
|
name="options"
|
||||||
onDragEnd={(result) => handleDragEnd(options, result, onChange)}
|
control={control}
|
||||||
draggableItems={
|
defaultValue={initialOptions}
|
||||||
<>
|
render={({ field: { onChange, value: options } }) => (
|
||||||
{options.map((option, index) => (
|
<>
|
||||||
<DraggableItem
|
<StyledContainer>
|
||||||
key={option.value}
|
<StyledLabel>Options</StyledLabel>
|
||||||
draggableId={option.value}
|
<DraggableList
|
||||||
index={index}
|
onDragEnd={(result) => handleDragEnd(options, result, onChange)}
|
||||||
isDragDisabled={options.length === 1}
|
draggableItems={
|
||||||
itemComponent={
|
<>
|
||||||
<SettingsObjectFieldSelectFormOptionRow
|
{options.map((option, index) => (
|
||||||
key={option.value}
|
<DraggableItem
|
||||||
isDefault={option.isDefault}
|
key={option.id}
|
||||||
onChange={(nextOption) => {
|
draggableId={option.id}
|
||||||
const nextOptions =
|
index={index}
|
||||||
isMultiSelect || !nextOption.isDefault
|
isDragDisabled={options.length === 1}
|
||||||
? [...options]
|
itemComponent={
|
||||||
: // Reset simple Select default option before setting the new one
|
<SettingsObjectFieldSelectFormOptionRow
|
||||||
options.map<SettingsObjectFieldSelectFormOption>(
|
key={option.id}
|
||||||
(value) => ({ ...value, isDefault: false }),
|
option={option}
|
||||||
);
|
onChange={(nextOption) => {
|
||||||
nextOptions.splice(index, 1, nextOption);
|
const nextOptions = [...options];
|
||||||
onChange(nextOptions);
|
nextOptions.splice(index, 1, nextOption);
|
||||||
}}
|
onChange(nextOptions);
|
||||||
onRemove={
|
|
||||||
options.length > 1
|
// Update option value in defaultValue if value has changed
|
||||||
? () => {
|
if (
|
||||||
const nextOptions = [...options];
|
nextOption.value !== option.value &&
|
||||||
nextOptions.splice(index, 1);
|
isOptionDefaultValue(option.value)
|
||||||
onChange(nextOptions);
|
) {
|
||||||
}
|
handleRemoveOptionAsDefault(option.value);
|
||||||
: undefined
|
handleSetOptionAsDefault(nextOption.value);
|
||||||
}
|
}
|
||||||
option={option}
|
}}
|
||||||
/>
|
onRemove={
|
||||||
}
|
options.length > 1
|
||||||
/>
|
? () => {
|
||||||
))}
|
const nextOptions = [...options];
|
||||||
</>
|
nextOptions.splice(index, 1);
|
||||||
}
|
onChange(nextOptions);
|
||||||
/>
|
}
|
||||||
</StyledContainer>
|
: undefined
|
||||||
<StyledFooter>
|
}
|
||||||
<StyledButton
|
isDefault={isOptionDefaultValue(option.value)}
|
||||||
title="Add option"
|
onSetAsDefault={() =>
|
||||||
Icon={IconPlus}
|
handleSetOptionAsDefault(option.value)
|
||||||
onClick={() =>
|
}
|
||||||
onChange([
|
onRemoveAsDefault={() =>
|
||||||
...options,
|
handleRemoveOptionAsDefault(option.value)
|
||||||
{
|
}
|
||||||
color: getNextColor(options[options.length - 1].color),
|
/>
|
||||||
label: findNewLabel(options),
|
}
|
||||||
value: v4(),
|
/>
|
||||||
},
|
))}
|
||||||
])
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledFooter>
|
</StyledContainer>
|
||||||
</>
|
<StyledFooter>
|
||||||
)}
|
<StyledButton
|
||||||
/>
|
title="Add option"
|
||||||
|
Icon={IconPlus}
|
||||||
|
onClick={() =>
|
||||||
|
onChange([...options, generateNewSelectOption(options)])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -10,6 +10,8 @@ import {
|
|||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
|
||||||
import { ColorSample } from '@/ui/display/color/components/ColorSample';
|
import { ColorSample } from '@/ui/display/color/components/ColorSample';
|
||||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
@ -21,14 +23,14 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
|||||||
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
|
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
|
||||||
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
|
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
|
||||||
|
|
||||||
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
|
|
||||||
|
|
||||||
type SettingsObjectFieldSelectFormOptionRowProps = {
|
type SettingsObjectFieldSelectFormOptionRowProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
onChange: (value: SettingsObjectFieldSelectFormOption) => void;
|
onChange: (value: FieldMetadataItemOption) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
option: SettingsObjectFieldSelectFormOption;
|
onSetAsDefault?: () => void;
|
||||||
|
onRemoveAsDefault?: () => void;
|
||||||
|
option: FieldMetadataItemOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledRow = styled.div`
|
const StyledRow = styled.div`
|
||||||
@ -58,6 +60,8 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
|
|||||||
isDefault,
|
isDefault,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onSetAsDefault,
|
||||||
|
onRemoveAsDefault,
|
||||||
option,
|
option,
|
||||||
}: SettingsObjectFieldSelectFormOptionRowProps) => {
|
}: SettingsObjectFieldSelectFormOptionRowProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@ -106,7 +110,9 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
|
|||||||
/>
|
/>
|
||||||
<StyledOptionInput
|
<StyledOptionInput
|
||||||
value={option.label}
|
value={option.label}
|
||||||
onChange={(label) => onChange({ ...option, label })}
|
onChange={(label) =>
|
||||||
|
onChange({ ...option, label, value: getOptionValueFromLabel(label) })
|
||||||
|
}
|
||||||
RightIcon={isDefault ? IconCheck : undefined}
|
RightIcon={isDefault ? IconCheck : undefined}
|
||||||
/>
|
/>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@ -124,7 +130,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
|
|||||||
LeftIcon={IconX}
|
LeftIcon={IconX}
|
||||||
text="Remove as default"
|
text="Remove as default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange({ ...option, isDefault: false });
|
onRemoveAsDefault?.();
|
||||||
closeActionsDropdown();
|
closeActionsDropdown();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -133,7 +139,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
|
|||||||
LeftIcon={IconCheck}
|
LeftIcon={IconCheck}
|
||||||
text="Set as default"
|
text="Set as default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange({ ...option, isDefault: true });
|
onSetAsDefault?.();
|
||||||
closeActionsDropdown();
|
closeActionsDropdown();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -3,15 +3,22 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
|
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
|
||||||
|
import { currencyCodeSchema } from '@/object-record/record-field/validation-schemas/currencyCodeSchema';
|
||||||
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
|
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
|
||||||
import { Select } from '@/ui/input/components/Select';
|
import { Select } from '@/ui/input/components/Select';
|
||||||
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
||||||
|
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
|
||||||
// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components
|
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
||||||
|
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
|
||||||
|
|
||||||
export const settingsDataModelFieldCurrencyFormSchema = z.object({
|
export const settingsDataModelFieldCurrencyFormSchema = z.object({
|
||||||
defaultValue: z.object({
|
defaultValue: z.object({
|
||||||
currencyCode: z.nativeEnum(CurrencyCode),
|
currencyCode: simpleQuotesStringSchema.refine(
|
||||||
|
(value) =>
|
||||||
|
currencyCodeSchema.safeParse(stripSimpleQuotesFromString(value))
|
||||||
|
.success,
|
||||||
|
{ message: 'String is not a valid currencyCode' },
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -27,7 +34,7 @@ type SettingsDataModelFieldCurrencyFormProps = {
|
|||||||
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
|
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
|
||||||
([value, { label, Icon }]) => ({
|
([value, { label, Icon }]) => ({
|
||||||
label,
|
label,
|
||||||
value: value as CurrencyCode,
|
value: applySimpleQuotesToString(value),
|
||||||
Icon,
|
Icon,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -48,7 +55,7 @@ export const SettingsDataModelFieldCurrencyForm = ({
|
|||||||
<Controller
|
<Controller
|
||||||
name="defaultValue.currencyCode"
|
name="defaultValue.currencyCode"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={initialValue}
|
defaultValue={applySimpleQuotesToString(initialValue)}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<Select
|
<Select
|
||||||
fullWidth
|
fullWidth
|
@ -0,0 +1,70 @@
|
|||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||||
|
import {
|
||||||
|
settingsDataModelFieldMultiSelectFormSchema,
|
||||||
|
SettingsDataModelFieldSelectForm,
|
||||||
|
settingsDataModelFieldSelectFormSchema,
|
||||||
|
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
||||||
|
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/hooks/useSelectSettingsFormInitialValues';
|
||||||
|
import {
|
||||||
|
SettingsDataModelFieldPreviewCard,
|
||||||
|
SettingsDataModelFieldPreviewCardProps,
|
||||||
|
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
|
||||||
|
|
||||||
|
const selectOrMultiSelectFormSchema = z.union([
|
||||||
|
settingsDataModelFieldSelectFormSchema,
|
||||||
|
settingsDataModelFieldMultiSelectFormSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type SettingsDataModelFieldSettingsFormValues = z.infer<
|
||||||
|
typeof selectOrMultiSelectFormSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SettingsDataModelFieldSelectSettingsFormCardProps = {
|
||||||
|
fieldMetadataItem: Pick<
|
||||||
|
FieldMetadataItem,
|
||||||
|
'icon' | 'label' | 'type' | 'defaultValue' | 'options'
|
||||||
|
>;
|
||||||
|
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
|
||||||
|
|
||||||
|
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsDataModelFieldSelectSettingsFormCard = ({
|
||||||
|
fieldMetadataItem,
|
||||||
|
objectMetadataItem,
|
||||||
|
}: SettingsDataModelFieldSelectSettingsFormCardProps) => {
|
||||||
|
const { initialOptions, initialDefaultValue } =
|
||||||
|
useSelectSettingsFormInitialValues({
|
||||||
|
fieldMetadataItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch: watchFormValue } =
|
||||||
|
useFormContext<SettingsDataModelFieldSettingsFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsDataModelPreviewFormCard
|
||||||
|
preview={
|
||||||
|
<StyledFieldPreviewCard
|
||||||
|
fieldMetadataItem={{
|
||||||
|
...fieldMetadataItem,
|
||||||
|
defaultValue: watchFormValue('defaultValue', initialDefaultValue),
|
||||||
|
options: watchFormValue('options', initialOptions),
|
||||||
|
}}
|
||||||
|
objectMetadataItem={objectMetadataItem}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
form={
|
||||||
|
<SettingsDataModelFieldSelectForm
|
||||||
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,3 @@
|
|||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import omit from 'lodash.omit';
|
import omit from 'lodash.omit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -9,17 +8,18 @@ import {
|
|||||||
settingsDataModelFieldBooleanFormSchema,
|
settingsDataModelFieldBooleanFormSchema,
|
||||||
} from '@/settings/data-model/components/SettingsDataModelDefaultValue';
|
} from '@/settings/data-model/components/SettingsDataModelDefaultValue';
|
||||||
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||||
import {
|
|
||||||
SettingsDataModelFieldCurrencyForm,
|
|
||||||
settingsDataModelFieldCurrencyFormSchema,
|
|
||||||
} from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
|
|
||||||
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
|
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
|
||||||
import {
|
import {
|
||||||
SettingsDataModelFieldSelectForm,
|
settingsDataModelFieldMultiSelectFormSchema,
|
||||||
settingsDataModelFieldSelectFormSchema,
|
settingsDataModelFieldSelectFormSchema,
|
||||||
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
||||||
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
|
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
|
||||||
|
import {
|
||||||
|
SettingsDataModelFieldCurrencyForm,
|
||||||
|
settingsDataModelFieldCurrencyFormSchema,
|
||||||
|
} from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldCurrencyForm';
|
||||||
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldRelationSettingsFormCard';
|
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldRelationSettingsFormCard';
|
||||||
|
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSelectSettingsFormCard';
|
||||||
import {
|
import {
|
||||||
SettingsDataModelFieldPreviewCard,
|
SettingsDataModelFieldPreviewCard,
|
||||||
SettingsDataModelFieldPreviewCardProps,
|
SettingsDataModelFieldPreviewCardProps,
|
||||||
@ -39,11 +39,13 @@ const relationFieldFormSchema = z
|
|||||||
.merge(settingsDataModelFieldRelationFormSchema);
|
.merge(settingsDataModelFieldRelationFormSchema);
|
||||||
|
|
||||||
const selectFieldFormSchema = z
|
const selectFieldFormSchema = z
|
||||||
.object({
|
.object({ type: z.literal(FieldMetadataType.Select) })
|
||||||
type: z.enum([FieldMetadataType.Select, FieldMetadataType.MultiSelect]),
|
|
||||||
})
|
|
||||||
.merge(settingsDataModelFieldSelectFormSchema);
|
.merge(settingsDataModelFieldSelectFormSchema);
|
||||||
|
|
||||||
|
const multiSelectFieldFormSchema = z
|
||||||
|
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
|
||||||
|
.merge(settingsDataModelFieldMultiSelectFormSchema);
|
||||||
|
|
||||||
const otherFieldsFormSchema = z.object({
|
const otherFieldsFormSchema = z.object({
|
||||||
type: z.enum(
|
type: z.enum(
|
||||||
Object.keys(
|
Object.keys(
|
||||||
@ -65,14 +67,11 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
|
|||||||
currencyFieldFormSchema,
|
currencyFieldFormSchema,
|
||||||
relationFieldFormSchema,
|
relationFieldFormSchema,
|
||||||
selectFieldFormSchema,
|
selectFieldFormSchema,
|
||||||
|
multiSelectFieldFormSchema,
|
||||||
otherFieldsFormSchema,
|
otherFieldsFormSchema,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
type SettingsDataModelFieldSettingsFormValues = z.infer<
|
|
||||||
typeof settingsDataModelFieldSettingsFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
type SettingsDataModelFieldSettingsFormCardProps = {
|
type SettingsDataModelFieldSettingsFormCardProps = {
|
||||||
disableCurrencyForm?: boolean;
|
disableCurrencyForm?: boolean;
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
|
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
|
||||||
@ -84,11 +83,6 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledPreviewContent = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const previewableTypes = [
|
const previewableTypes = [
|
||||||
FieldMetadataType.Boolean,
|
FieldMetadataType.Boolean,
|
||||||
FieldMetadataType.Currency,
|
FieldMetadataType.Currency,
|
||||||
@ -112,9 +106,6 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
|||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
}: SettingsDataModelFieldSettingsFormCardProps) => {
|
}: SettingsDataModelFieldSettingsFormCardProps) => {
|
||||||
const { watch: watchFormValue } =
|
|
||||||
useFormContext<SettingsDataModelFieldSettingsFormValues>();
|
|
||||||
|
|
||||||
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
|
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
|
||||||
|
|
||||||
if (fieldMetadataItem.type === FieldMetadataType.Relation) {
|
if (fieldMetadataItem.type === FieldMetadataType.Relation) {
|
||||||
@ -126,16 +117,25 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMetadataItem.type === FieldMetadataType.Select ||
|
||||||
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SettingsDataModelFieldSelectSettingsFormCard
|
||||||
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
|
objectMetadataItem={objectMetadataItem}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsDataModelPreviewFormCard
|
<SettingsDataModelPreviewFormCard
|
||||||
preview={
|
preview={
|
||||||
<StyledPreviewContent>
|
<StyledFieldPreviewCard
|
||||||
<StyledFieldPreviewCard
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
fieldMetadataItem={fieldMetadataItem}
|
objectMetadataItem={objectMetadataItem}
|
||||||
objectMetadataItem={objectMetadataItem}
|
/>
|
||||||
selectOptions={watchFormValue('options')}
|
|
||||||
/>
|
|
||||||
</StyledPreviewContent>
|
|
||||||
}
|
}
|
||||||
form={
|
form={
|
||||||
fieldMetadataItem.type === FieldMetadataType.Boolean ? (
|
fieldMetadataItem.type === FieldMetadataType.Boolean ? (
|
||||||
@ -147,14 +147,6 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
|||||||
disabled={disableCurrencyForm}
|
disabled={disableCurrencyForm}
|
||||||
fieldMetadataItem={fieldMetadataItem}
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
/>
|
/>
|
||||||
) : fieldMetadataItem.type === FieldMetadataType.Select ||
|
|
||||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
|
|
||||||
<SettingsDataModelFieldSelectForm
|
|
||||||
fieldMetadataItem={fieldMetadataItem}
|
|
||||||
isMultiSelect={
|
|
||||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FieldMetadataItem,
|
||||||
|
FieldMetadataItemOption,
|
||||||
|
} from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
|
||||||
|
|
||||||
|
const DEFAULT_OPTION: FieldMetadataItemOption = {
|
||||||
|
color: 'green',
|
||||||
|
id: v4(),
|
||||||
|
label: 'Option 1',
|
||||||
|
position: 0,
|
||||||
|
value: getOptionValueFromLabel('Option 1'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSelectSettingsFormInitialValues = ({
|
||||||
|
fieldMetadataItem,
|
||||||
|
}: {
|
||||||
|
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
|
||||||
|
}) => {
|
||||||
|
const initialDefaultValue = fieldMetadataItem.defaultValue ?? null;
|
||||||
|
const initialOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
fieldMetadataItem.options?.length
|
||||||
|
? [...fieldMetadataItem.options].sort(
|
||||||
|
(optionA, optionB) => optionA.position - optionB.position,
|
||||||
|
)
|
||||||
|
: [DEFAULT_OPTION],
|
||||||
|
[fieldMetadataItem.options],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialDefaultValue,
|
||||||
|
initialOptions,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,34 @@
|
|||||||
|
import { generateNewSelectOptionLabel } from '@/settings/data-model/fields/forms/utils/generateNewSelectOptionLabel';
|
||||||
|
|
||||||
|
describe('generateNewSelectOptionLabel', () => {
|
||||||
|
it('generates a new select option label', () => {
|
||||||
|
// Given
|
||||||
|
const options = [
|
||||||
|
{ label: 'Option 1' },
|
||||||
|
{ label: 'Option 2' },
|
||||||
|
{ label: 'Lorem ipsum' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// When
|
||||||
|
const newLabel = generateNewSelectOptionLabel(options);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(newLabel).toBe('Option 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('iterates until it finds an unique label', () => {
|
||||||
|
// Given
|
||||||
|
const options = [
|
||||||
|
{ label: 'Option 1' },
|
||||||
|
{ label: 'Option 2' },
|
||||||
|
{ label: 'Option 4' },
|
||||||
|
{ label: 'Option 5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// When
|
||||||
|
const newLabel = generateNewSelectOptionLabel(options);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(newLabel).toBe('Option 6');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,39 @@
|
|||||||
|
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
|
||||||
|
|
||||||
|
describe('getOptionValueFromLabel', () => {
|
||||||
|
it('should return the option value from the label', () => {
|
||||||
|
const label = 'Example Label';
|
||||||
|
const expected = 'EXAMPLE_LABEL';
|
||||||
|
|
||||||
|
const result = getOptionValueFromLabel(label);
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle labels with accents', () => {
|
||||||
|
const label = 'Éxàmplè Làbèl';
|
||||||
|
const expected = 'EXAMPLE_LABEL';
|
||||||
|
|
||||||
|
const result = getOptionValueFromLabel(label);
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle labels with special characters', () => {
|
||||||
|
const label = 'Example!@#$%^&*() Label';
|
||||||
|
const expected = 'EXAMPLE_LABEL';
|
||||||
|
|
||||||
|
const result = getOptionValueFromLabel(label);
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle labels with emojis', () => {
|
||||||
|
const label = '📱 Example Label';
|
||||||
|
const expected = 'EXAMPLE_LABEL';
|
||||||
|
|
||||||
|
const result = getOptionValueFromLabel(label);
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,20 @@
|
|||||||
|
import { getNextThemeColor } from 'twenty-ui';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { generateNewSelectOptionLabel } from '@/settings/data-model/fields/forms/utils/generateNewSelectOptionLabel';
|
||||||
|
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
|
||||||
|
|
||||||
|
export const generateNewSelectOption = (
|
||||||
|
options: FieldMetadataItemOption[],
|
||||||
|
): FieldMetadataItemOption => {
|
||||||
|
const newOptionLabel = generateNewSelectOptionLabel(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: getNextThemeColor(options[options.length - 1].color),
|
||||||
|
id: v4(),
|
||||||
|
label: newOptionLabel,
|
||||||
|
position: options.length,
|
||||||
|
value: getOptionValueFromLabel(newOptionLabel),
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,13 @@
|
|||||||
|
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
|
||||||
|
export const generateNewSelectOptionLabel = (
|
||||||
|
values: Pick<FieldMetadataItemOption, 'label'>[],
|
||||||
|
iteration = 1,
|
||||||
|
): string => {
|
||||||
|
const newOptionLabel = `Option ${values.length + iteration}`;
|
||||||
|
const labelExists = values.some((value) => value.label === newOptionLabel);
|
||||||
|
|
||||||
|
return labelExists
|
||||||
|
? generateNewSelectOptionLabel(values, iteration + 1)
|
||||||
|
: newOptionLabel;
|
||||||
|
};
|
@ -0,0 +1,15 @@
|
|||||||
|
import snakeCase from 'lodash.snakecase';
|
||||||
|
|
||||||
|
export const getOptionValueFromLabel = (label: string) => {
|
||||||
|
// Remove accents
|
||||||
|
const unaccentedLabel = label
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
// Remove special characters
|
||||||
|
const noSpecialCharactersLabel = unaccentedLabel.replace(
|
||||||
|
/[^a-zA-Z0-9 ]/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
return snakeCase(noSpecialCharactersLabel).toUpperCase();
|
||||||
|
};
|
@ -8,7 +8,6 @@ import { FieldDisplay } from '@/object-record/record-field/components/FieldDispl
|
|||||||
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||||
import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput';
|
import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput';
|
||||||
import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
|
import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
|
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
|
||||||
import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
|
import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
|
||||||
import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview';
|
import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview';
|
||||||
@ -17,14 +16,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
|
|||||||
export type SettingsDataModelFieldPreviewProps = {
|
export type SettingsDataModelFieldPreviewProps = {
|
||||||
fieldMetadataItem: Pick<
|
fieldMetadataItem: Pick<
|
||||||
FieldMetadataItem,
|
FieldMetadataItem,
|
||||||
'icon' | 'label' | 'type' | 'defaultValue'
|
'icon' | 'label' | 'type' | 'defaultValue' | 'options'
|
||||||
> & {
|
> & {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
objectMetadataItem: ObjectMetadataItem;
|
objectMetadataItem: ObjectMetadataItem;
|
||||||
relationObjectMetadataItem?: ObjectMetadataItem;
|
relationObjectMetadataItem?: ObjectMetadataItem;
|
||||||
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
|
|
||||||
shrink?: boolean;
|
shrink?: boolean;
|
||||||
withFieldLabel?: boolean;
|
withFieldLabel?: boolean;
|
||||||
};
|
};
|
||||||
@ -55,7 +53,6 @@ export const SettingsDataModelFieldPreview = ({
|
|||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
shrink,
|
shrink,
|
||||||
withFieldLabel = true,
|
withFieldLabel = true,
|
||||||
}: SettingsDataModelFieldPreviewProps) => {
|
}: SettingsDataModelFieldPreviewProps) => {
|
||||||
@ -69,7 +66,6 @@ export const SettingsDataModelFieldPreview = ({
|
|||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -107,7 +103,7 @@ export const SettingsDataModelFieldPreview = ({
|
|||||||
objectMetadataNameSingular: objectMetadataItem.nameSingular,
|
objectMetadataNameSingular: objectMetadataItem.nameSingular,
|
||||||
relationObjectMetadataNameSingular:
|
relationObjectMetadataNameSingular:
|
||||||
relationObjectMetadataItem?.nameSingular,
|
relationObjectMetadataItem?.nameSingular,
|
||||||
options: selectOptions,
|
options: fieldMetadataItem.options,
|
||||||
},
|
},
|
||||||
defaultValue: fieldMetadataItem.defaultValue,
|
defaultValue: fieldMetadataItem.defaultValue,
|
||||||
},
|
},
|
||||||
|
@ -28,7 +28,6 @@ export const SettingsDataModelFieldPreviewCard = ({
|
|||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
shrink,
|
shrink,
|
||||||
withFieldLabel = true,
|
withFieldLabel = true,
|
||||||
}: SettingsDataModelFieldPreviewCardProps) => (
|
}: SettingsDataModelFieldPreviewCardProps) => (
|
||||||
@ -39,7 +38,6 @@ export const SettingsDataModelFieldPreviewCard = ({
|
|||||||
objectMetadataItem={objectMetadataItem}
|
objectMetadataItem={objectMetadataItem}
|
||||||
fieldMetadataItem={fieldMetadataItem}
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
relationObjectMetadataItem={relationObjectMetadataItem}
|
relationObjectMetadataItem={relationObjectMetadataItem}
|
||||||
selectOptions={selectOptions}
|
|
||||||
shrink={shrink}
|
shrink={shrink}
|
||||||
withFieldLabel={withFieldLabel}
|
withFieldLabel={withFieldLabel}
|
||||||
/>
|
/>
|
||||||
|
@ -4,26 +4,28 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
|||||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||||
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue';
|
import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue';
|
||||||
import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord';
|
import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
type UseFieldPreviewParams = {
|
type UseFieldPreviewParams = {
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'type'> & {
|
fieldMetadataItem: Pick<
|
||||||
|
FieldMetadataItem,
|
||||||
|
'icon' | 'type' | 'options' | 'defaultValue'
|
||||||
|
> & {
|
||||||
|
// id and name are undefined in create mode (field does not exist yet)
|
||||||
|
// and are defined in edit mode.
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
objectMetadataItem: ObjectMetadataItem;
|
objectMetadataItem: ObjectMetadataItem;
|
||||||
relationObjectMetadataItem?: ObjectMetadataItem;
|
relationObjectMetadataItem?: ObjectMetadataItem;
|
||||||
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFieldPreview = ({
|
export const useFieldPreview = ({
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
}: UseFieldPreviewParams) => {
|
}: UseFieldPreviewParams) => {
|
||||||
const isLabelIdentifier =
|
const isLabelIdentifier =
|
||||||
!!fieldMetadataItem.id &&
|
!!fieldMetadataItem.id &&
|
||||||
@ -40,6 +42,9 @@ export const useFieldPreview = ({
|
|||||||
objectNameSingular: objectMetadataItem.nameSingular,
|
objectNameSingular: objectMetadataItem.nameSingular,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
skip: !fieldMetadataItem.name,
|
skip: !fieldMetadataItem.name,
|
||||||
|
orderBy: {
|
||||||
|
[fieldMetadataItem.name ?? '']: 'AscNullsLast',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const [firstRecord] = records;
|
const [firstRecord] = records;
|
||||||
|
|
||||||
@ -51,17 +56,17 @@ export const useFieldPreview = ({
|
|||||||
name: fieldMetadataItem.name,
|
name: fieldMetadataItem.name,
|
||||||
type: fieldMetadataItem.type,
|
type: fieldMetadataItem.type,
|
||||||
},
|
},
|
||||||
selectOptions,
|
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const selectOptionValues = selectOptions?.map((option) => option.value);
|
|
||||||
const isValueFromFirstRecord =
|
const isValueFromFirstRecord =
|
||||||
firstRecord &&
|
firstRecord &&
|
||||||
!isFieldValueEmpty({
|
!isFieldValueEmpty({
|
||||||
fieldDefinition: { type: fieldMetadataItem.type },
|
fieldDefinition: { type: fieldMetadataItem.type },
|
||||||
fieldValue: fieldPreviewValueFromFirstRecord,
|
fieldValue: fieldPreviewValueFromFirstRecord,
|
||||||
selectOptionValues,
|
selectOptionValues: fieldMetadataItem.options?.map(
|
||||||
|
(option) => option.value,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { records: relationRecords } = useFindManyRecords({
|
const { records: relationRecords } = useFindManyRecords({
|
||||||
@ -83,7 +88,6 @@ export const useFieldPreview = ({
|
|||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fieldName =
|
const fieldName =
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
|
|
||||||
|
|
||||||
export type SettingsObjectFieldSelectFormOption = {
|
|
||||||
color: ThemeColor;
|
|
||||||
isDefault?: boolean;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
@ -1,5 +1,4 @@
|
|||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import {
|
import {
|
||||||
mockedCompanyObjectMetadataItem,
|
mockedCompanyObjectMetadataItem,
|
||||||
mockedOpportunityObjectMetadataItem,
|
mockedOpportunityObjectMetadataItem,
|
||||||
@ -10,35 +9,21 @@ import { getFieldDefaultPreviewValue } from '../getFieldDefaultPreviewValue';
|
|||||||
|
|
||||||
describe('getFieldDefaultPreviewValue', () => {
|
describe('getFieldDefaultPreviewValue', () => {
|
||||||
describe('SELECT field', () => {
|
describe('SELECT field', () => {
|
||||||
it('returns the default select option', () => {
|
it('returns the default select option value', () => {
|
||||||
// Given
|
// Given
|
||||||
const objectMetadataItem = mockedOpportunityObjectMetadataItem;
|
const objectMetadataItem = mockedOpportunityObjectMetadataItem;
|
||||||
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
||||||
({ name }) => name === 'stage',
|
({ name }) => name === 'stage',
|
||||||
)!;
|
)!;
|
||||||
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
|
|
||||||
{
|
|
||||||
color: 'purple',
|
|
||||||
label: '🏭 Industry',
|
|
||||||
value: 'INDUSTRY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'pink',
|
|
||||||
isDefault: true,
|
|
||||||
label: '💊 Health',
|
|
||||||
value: 'HEALTH',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = getFieldDefaultPreviewValue({
|
const result = getFieldDefaultPreviewValue({
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
fieldMetadataItem,
|
fieldMetadataItem: { ...fieldMetadataItem, defaultValue: "'MEETING'" },
|
||||||
selectOptions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual(selectOptions[1].value);
|
expect(result).toEqual('MEETING');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the first select option if no default option was found', () => {
|
it('returns the first select option if no default option was found', () => {
|
||||||
@ -47,28 +32,15 @@ describe('getFieldDefaultPreviewValue', () => {
|
|||||||
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
||||||
({ name }) => name === 'stage',
|
({ name }) => name === 'stage',
|
||||||
)!;
|
)!;
|
||||||
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
|
|
||||||
{
|
|
||||||
color: 'purple' as const,
|
|
||||||
label: '🏭 Industry',
|
|
||||||
value: 'INDUSTRY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'pink' as const,
|
|
||||||
label: '💊 Health',
|
|
||||||
value: 'HEALTH',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = getFieldDefaultPreviewValue({
|
const result = getFieldDefaultPreviewValue({
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
fieldMetadataItem,
|
fieldMetadataItem: { ...fieldMetadataItem, defaultValue: null },
|
||||||
selectOptions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual(selectOptions[0].value);
|
expect(result).toEqual(fieldMetadataItem.options![0].value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,106 +1,12 @@
|
|||||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import {
|
import {
|
||||||
mockedCompanyObjectMetadataItem,
|
mockedCompanyObjectMetadataItem,
|
||||||
mockedOpportunityObjectMetadataItem,
|
|
||||||
mockedPersonObjectMetadataItem,
|
mockedPersonObjectMetadataItem,
|
||||||
} from '~/testing/mock-data/metadata';
|
} from '~/testing/mock-data/metadata';
|
||||||
|
|
||||||
import { getFieldPreviewValueFromRecord } from '../getFieldPreviewValueFromRecord';
|
import { getFieldPreviewValueFromRecord } from '../getFieldPreviewValueFromRecord';
|
||||||
|
|
||||||
describe('getFieldPreviewValueFromRecord', () => {
|
describe('getFieldPreviewValueFromRecord', () => {
|
||||||
describe('SELECT field', () => {
|
|
||||||
it('returns the select option corresponding to the record field value', () => {
|
|
||||||
// Given
|
|
||||||
const record: ObjectRecord = {
|
|
||||||
id: '',
|
|
||||||
stage: 'MEETING',
|
|
||||||
__typename: 'Opportunity',
|
|
||||||
};
|
|
||||||
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
|
||||||
({ name }) => name === 'stage',
|
|
||||||
)!;
|
|
||||||
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
|
|
||||||
{
|
|
||||||
color: 'red',
|
|
||||||
label: 'New',
|
|
||||||
value: 'NEW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'purple',
|
|
||||||
label: 'Screening',
|
|
||||||
value: 'SCREENING',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'sky',
|
|
||||||
label: 'Meeting',
|
|
||||||
value: 'MEETING',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'turquoise',
|
|
||||||
label: 'Proposal',
|
|
||||||
value: 'PROPOSAL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'yellow',
|
|
||||||
label: 'Customer',
|
|
||||||
value: 'CUSTOMER',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// When
|
|
||||||
const result = getFieldPreviewValueFromRecord({
|
|
||||||
record,
|
|
||||||
fieldMetadataItem,
|
|
||||||
selectOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then
|
|
||||||
expect(result).toEqual(selectOptions[2].value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if the select option was not found', () => {
|
|
||||||
// Given
|
|
||||||
const record: ObjectRecord = {
|
|
||||||
id: '',
|
|
||||||
industry: 'DOES_NOT_EXIST',
|
|
||||||
__typename: 'Opportunity',
|
|
||||||
};
|
|
||||||
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
|
|
||||||
({ name }) => name === 'stage',
|
|
||||||
)!;
|
|
||||||
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
|
|
||||||
{
|
|
||||||
color: 'purple',
|
|
||||||
label: '🏭 Industry',
|
|
||||||
value: 'INDUSTRY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'pink',
|
|
||||||
isDefault: true,
|
|
||||||
label: '💊 Health',
|
|
||||||
value: 'HEALTH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'turquoise',
|
|
||||||
label: '🌿 Green tech',
|
|
||||||
value: 'GREEN_TECH',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// When
|
|
||||||
const result = getFieldPreviewValueFromRecord({
|
|
||||||
record,
|
|
||||||
fieldMetadataItem,
|
|
||||||
selectOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RELATION field', () => {
|
describe('RELATION field', () => {
|
||||||
it('returns the first relation record from a list of edges ("to many" relation)', () => {
|
it('returns the first relation record from a list of edges ("to many" relation)', () => {
|
||||||
// Given
|
// Given
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
|
|
||||||
|
import { isSelectOptionDefaultValue } from '../isSelectOptionDefaultValue';
|
||||||
|
|
||||||
|
describe('isSelectOptionDefaultValue', () => {
|
||||||
|
describe('SELECT field', () => {
|
||||||
|
it('returns true if the option value matches the default value', () => {
|
||||||
|
// Given
|
||||||
|
const optionValue = 'OPTION_1';
|
||||||
|
const fieldMetadataItem = {
|
||||||
|
defaultValue: `'${optionValue}'`,
|
||||||
|
type: FieldMetadataType.Select,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the option value does not match the default value', () => {
|
||||||
|
// Given
|
||||||
|
const optionValue = 'OPTION_1';
|
||||||
|
const fieldMetadataItem = {
|
||||||
|
defaultValue: "'OPTION_2'",
|
||||||
|
type: FieldMetadataType.Select,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MULTI_SELECT field', () => {
|
||||||
|
it('returns true if the option value is included in the default value array', () => {
|
||||||
|
// Given
|
||||||
|
const optionValue = 'OPTION_1';
|
||||||
|
const fieldMetadataItem = {
|
||||||
|
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
|
||||||
|
type: FieldMetadataType.MultiSelect,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the option value is not included in the default value array', () => {
|
||||||
|
// Given
|
||||||
|
const optionValue = 'OPTION_1';
|
||||||
|
const fieldMetadataItem = {
|
||||||
|
defaultValue: ["'OPTION_2'", "'OPTION_3'"],
|
||||||
|
type: FieldMetadataType.MultiSelect,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -2,39 +2,41 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
|||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
|
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
||||||
|
|
||||||
export const getFieldDefaultPreviewValue = ({
|
export const getFieldDefaultPreviewValue = ({
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
relationObjectMetadataItem,
|
relationObjectMetadataItem,
|
||||||
selectOptions,
|
|
||||||
}: {
|
}: {
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'type'> & {
|
fieldMetadataItem: Pick<
|
||||||
|
FieldMetadataItem,
|
||||||
|
'type' | 'defaultValue' | 'options'
|
||||||
|
> & {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
objectMetadataItem: ObjectMetadataItem;
|
objectMetadataItem: ObjectMetadataItem;
|
||||||
relationObjectMetadataItem?: ObjectMetadataItem;
|
relationObjectMetadataItem?: ObjectMetadataItem;
|
||||||
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
|
|
||||||
}) => {
|
}) => {
|
||||||
if (
|
if (fieldMetadataItem.type === FieldMetadataType.Select) {
|
||||||
fieldMetadataItem.type === FieldMetadataType.Select &&
|
const defaultValue = fieldMetadataItem.defaultValue
|
||||||
isDefined(selectOptions)
|
? stripSimpleQuotesFromString(fieldMetadataItem.defaultValue)
|
||||||
) {
|
: null;
|
||||||
const defaultSelectOption =
|
return defaultValue ?? fieldMetadataItem.options?.[0]?.value ?? null;
|
||||||
selectOptions.find(({ isDefault }) => isDefault) || selectOptions[0];
|
|
||||||
return defaultSelectOption.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (fieldMetadataItem.type === FieldMetadataType.MultiSelect) {
|
||||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
|
const defaultValues = fieldMetadataItem.defaultValue?.map(
|
||||||
isDefined(selectOptions)
|
(defaultValue: `'${string}'`) =>
|
||||||
) {
|
stripSimpleQuotesFromString(defaultValue),
|
||||||
return selectOptions.map((selectOption) => selectOption.value);
|
);
|
||||||
|
return defaultValues?.length
|
||||||
|
? defaultValues
|
||||||
|
: fieldMetadataItem.options?.map(({ value }) => value) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -1,26 +1,16 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||||
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
|
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
export const getFieldPreviewValueFromRecord = ({
|
export const getFieldPreviewValueFromRecord = ({
|
||||||
record,
|
record,
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
selectOptions,
|
|
||||||
}: {
|
}: {
|
||||||
record: ObjectRecord;
|
record: ObjectRecord;
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
|
fieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
|
||||||
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
|
|
||||||
}) => {
|
}) => {
|
||||||
const recordFieldValue = record[fieldMetadataItem.name];
|
const recordFieldValue = record[fieldMetadataItem.name];
|
||||||
|
|
||||||
// Select field
|
|
||||||
if (fieldMetadataItem.type === FieldMetadataType.Select) {
|
|
||||||
return selectOptions?.find(
|
|
||||||
(selectOption) => selectOption.value === recordFieldValue,
|
|
||||||
)?.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relation fields (to many)
|
// Relation fields (to many)
|
||||||
if (
|
if (
|
||||||
fieldMetadataItem.type === FieldMetadataType.Relation &&
|
fieldMetadataItem.type === FieldMetadataType.Relation &&
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
|
||||||
|
|
||||||
|
export const isSelectOptionDefaultValue = (
|
||||||
|
optionValue: string,
|
||||||
|
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'type'>,
|
||||||
|
): boolean => {
|
||||||
|
if (fieldMetadataItem.type === FieldMetadataType.Select) {
|
||||||
|
return (
|
||||||
|
applySimpleQuotesToString(optionValue) === fieldMetadataItem.defaultValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
|
||||||
|
Array.isArray(fieldMetadataItem.defaultValue)
|
||||||
|
) {
|
||||||
|
return fieldMetadataItem.defaultValue.includes(
|
||||||
|
applySimpleQuotesToString(optionValue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
@ -6,7 +6,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import omit from 'lodash.omit';
|
import omit from 'lodash.omit';
|
||||||
import pick from 'lodash.pick';
|
import pick from 'lodash.pick';
|
||||||
import { IconArchive, IconSettings } from 'twenty-ui';
|
import { IconArchive, IconSettings } from 'twenty-ui';
|
||||||
import { v4 } from 'uuid';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||||
@ -125,14 +124,9 @@ export const SettingsObjectFieldEdit = () => {
|
|||||||
Object.keys(otherDirtyFields),
|
Object.keys(otherDirtyFields),
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = formattedInput.options?.map((option) => ({
|
|
||||||
...option,
|
|
||||||
id: option.id ?? v4(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await updateOneFieldMetadataItem({
|
await updateOneFieldMetadataItem({
|
||||||
fieldMetadataIdToUpdate: activeMetadataField.id,
|
fieldMetadataIdToUpdate: activeMetadataField.id,
|
||||||
updatePayload: { ...formattedInput, options },
|
updatePayload: formattedInput,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { applySimpleQuotesToString } from '../applySimpleQuotesToString';
|
||||||
|
|
||||||
|
describe('applySimpleQuotesToString', () => {
|
||||||
|
it('wraps the input string with single quotes', () => {
|
||||||
|
const input = 'Hello, World!';
|
||||||
|
|
||||||
|
const result = applySimpleQuotesToString(input);
|
||||||
|
|
||||||
|
expect(result).toBe(`'${input}'`);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,32 @@
|
|||||||
|
import { stripSimpleQuotesFromString } from '../stripSimpleQuotesFromString';
|
||||||
|
|
||||||
|
describe('stripSimpleQuotesFromString', () => {
|
||||||
|
it('removes surrounding single quotes from a string', () => {
|
||||||
|
// Given
|
||||||
|
const input = "'Hello, World!'";
|
||||||
|
|
||||||
|
// When
|
||||||
|
const output = stripSimpleQuotesFromString(input);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(output).toBe('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// Given
|
||||||
|
['no simple quotes'],
|
||||||
|
["'only at start"],
|
||||||
|
["only at end'"],
|
||||||
|
["mid'dle"],
|
||||||
|
[''],
|
||||||
|
])(
|
||||||
|
'returns the input without changes if the string does not start and end with single quotes (%s)',
|
||||||
|
(input) => {
|
||||||
|
// When
|
||||||
|
const output = stripSimpleQuotesFromString(input);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(output).toBe(input);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,3 @@
|
|||||||
|
export const applySimpleQuotesToString = <T extends string>(
|
||||||
|
value: T,
|
||||||
|
): `'${T}'` => `'${value}'`;
|
@ -0,0 +1,8 @@
|
|||||||
|
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
|
||||||
|
|
||||||
|
export const stripSimpleQuotesFromString = <Input extends string>(
|
||||||
|
value: Input,
|
||||||
|
) =>
|
||||||
|
(simpleQuotesStringSchema.safeParse(value).success
|
||||||
|
? value.slice(1, -1)
|
||||||
|
: value) as Input extends `'${infer Output}'` ? Output : Input;
|
@ -0,0 +1,38 @@
|
|||||||
|
import { SafeParseError } from 'zod';
|
||||||
|
|
||||||
|
import { simpleQuotesStringSchema } from '../simpleQuotesStringSchema';
|
||||||
|
|
||||||
|
describe('simpleQuotesStringSchema', () => {
|
||||||
|
it('validates a string with simple quotes', () => {
|
||||||
|
// Given
|
||||||
|
const input = "'with simple quotes'";
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = simpleQuotesStringSchema.parse(input);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// Given
|
||||||
|
['no simple quotes'],
|
||||||
|
["'only at start"],
|
||||||
|
["only at end'"],
|
||||||
|
["mid'dle"],
|
||||||
|
[''],
|
||||||
|
])('fails for strings not wrapped in simple quotes (%s)', (input) => {
|
||||||
|
// When
|
||||||
|
const result = simpleQuotesStringSchema.safeParse(input);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result as SafeParseError<string>).error.errors).toEqual([
|
||||||
|
{
|
||||||
|
code: 'custom',
|
||||||
|
message: 'String should be wrapped in simple quotes',
|
||||||
|
path: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const simpleQuotesStringSchema: z.ZodType<
|
||||||
|
`'${string}'`,
|
||||||
|
z.ZodTypeDef,
|
||||||
|
string
|
||||||
|
> = z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(value: string): value is `'${string}'` =>
|
||||||
|
value.startsWith("'") && value.endsWith("'"),
|
||||||
|
{
|
||||||
|
message: 'String should be wrapped in simple quotes',
|
||||||
|
},
|
||||||
|
);
|
1
packages/twenty-ui/__mocks__/imageMock.js
Normal file
1
packages/twenty-ui/__mocks__/imageMock.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default 'test-file-stub';
|
@ -1,4 +1,9 @@
|
|||||||
export default {
|
import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const tsConfig = require('./tsconfig.json');
|
||||||
|
|
||||||
|
const jestConfig: JestConfigWithTsJest = {
|
||||||
displayName: 'twenty-ui',
|
displayName: 'twenty-ui',
|
||||||
preset: '../../jest.preset.js',
|
preset: '../../jest.preset.js',
|
||||||
setupFilesAfterEnv: ['./setupTests.ts'],
|
setupFilesAfterEnv: ['./setupTests.ts'],
|
||||||
@ -15,7 +20,14 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$':
|
||||||
|
'<rootDir>/__mocks__/imageMock.js',
|
||||||
|
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths),
|
||||||
|
},
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||||
coverageDirectory: './coverage',
|
coverageDirectory: './coverage',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default jestConfig;
|
||||||
|
@ -32,3 +32,4 @@ export * from './constants/ThemeDark';
|
|||||||
export * from './constants/ThemeLight';
|
export * from './constants/ThemeLight';
|
||||||
export * from './provider/ThemeProvider';
|
export * from './provider/ThemeProvider';
|
||||||
export * from './types/ThemeType';
|
export * from './types/ThemeType';
|
||||||
|
export * from './utils/getNextThemeColor';
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
MAIN_COLOR_NAMES,
|
||||||
|
ThemeColor,
|
||||||
|
} from '@ui/theme/constants/MainColorNames';
|
||||||
|
|
||||||
|
import { getNextThemeColor } from '../getNextThemeColor';
|
||||||
|
|
||||||
|
describe('getNextThemeColor', () => {
|
||||||
|
it('returns the next theme color', () => {
|
||||||
|
const currentColor: ThemeColor = MAIN_COLOR_NAMES[0];
|
||||||
|
const nextColor: ThemeColor = MAIN_COLOR_NAMES[1];
|
||||||
|
|
||||||
|
expect(getNextThemeColor(currentColor)).toBe(nextColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the first color when reaching the end', () => {
|
||||||
|
const currentColor: ThemeColor =
|
||||||
|
MAIN_COLOR_NAMES[MAIN_COLOR_NAMES.length - 1];
|
||||||
|
const nextColor: ThemeColor = MAIN_COLOR_NAMES[0];
|
||||||
|
|
||||||
|
expect(getNextThemeColor(currentColor)).toBe(nextColor);
|
||||||
|
});
|
||||||
|
});
|
9
packages/twenty-ui/src/theme/utils/getNextThemeColor.ts
Normal file
9
packages/twenty-ui/src/theme/utils/getNextThemeColor.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { MAIN_COLOR_NAMES, ThemeColor } from '@ui/theme';
|
||||||
|
|
||||||
|
export const getNextThemeColor = (currentColor: ThemeColor): ThemeColor => {
|
||||||
|
const currentColorIndex = MAIN_COLOR_NAMES.findIndex(
|
||||||
|
(color) => color === currentColor,
|
||||||
|
);
|
||||||
|
const nextColorIndex = (currentColorIndex + 1) % MAIN_COLOR_NAMES.length;
|
||||||
|
return MAIN_COLOR_NAMES[nextColorIndex];
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user