Normalize defaultValue properly (#21511)
<img width="1506" height="338" alt="image" src="https://github.com/user-attachments/assets/3ee0d5c9-af32-4ef3-83c9-2f4671219126" />
This commit is contained in:
+97
@@ -0,0 +1,97 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type ObjectFieldManifest } from 'twenty-shared/application';
|
||||
|
||||
import { getFieldDefaultValueWarnings } from '@/sdk/define/fields/get-field-default-value-warnings';
|
||||
|
||||
const buildField = (
|
||||
overrides: Partial<ObjectFieldManifest>,
|
||||
): ObjectFieldManifest =>
|
||||
({
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
...overrides,
|
||||
}) as ObjectFieldManifest;
|
||||
|
||||
describe('getFieldDefaultValueWarnings', () => {
|
||||
it('returns no warning when fields are undefined', () => {
|
||||
expect(getFieldDefaultValueWarnings(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no warning when defaultValue is not set', () => {
|
||||
expect(getFieldDefaultValueWarnings([buildField({})])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no warning for quoted string default values', () => {
|
||||
expect(
|
||||
getFieldDefaultValueWarnings([buildField({ defaultValue: "'todo'" })]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no warning for computed default values', () => {
|
||||
expect(
|
||||
getFieldDefaultValueWarnings([
|
||||
buildField({ type: FieldMetadataType.UUID, defaultValue: 'uuid' }),
|
||||
buildField({ type: FieldMetadataType.DATE_TIME, defaultValue: 'now' }),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns for unquoted string default values', () => {
|
||||
const warnings = getFieldDefaultValueWarnings([
|
||||
buildField({ defaultValue: 'todo' }),
|
||||
]);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('Field "Title"');
|
||||
expect(warnings[0]).toContain('"todo"');
|
||||
expect(warnings[0]).toContain('"\'todo\'"');
|
||||
});
|
||||
|
||||
it('warns for unquoted items in multi-select default values', () => {
|
||||
const warnings = getFieldDefaultValueWarnings([
|
||||
buildField({
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
defaultValue: ['OPTION_1', "'OPTION_2'"],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('"OPTION_1"');
|
||||
expect(warnings[0]).not.toContain('OPTION_2');
|
||||
});
|
||||
|
||||
it('warns for unquoted string properties of composite default values', () => {
|
||||
const warnings = getFieldDefaultValueWarnings([
|
||||
buildField({
|
||||
type: FieldMetadataType.ACTOR,
|
||||
defaultValue: { source: 'MANUAL', name: "'No Source'" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('"MANUAL"');
|
||||
});
|
||||
|
||||
it('returns no warning for non-composite object default values', () => {
|
||||
expect(
|
||||
getFieldDefaultValueWarnings([
|
||||
buildField({
|
||||
type: FieldMetadataType.RAW_JSON,
|
||||
defaultValue: { foo: 'bar' },
|
||||
}),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no warning for non-string default values', () => {
|
||||
expect(
|
||||
getFieldDefaultValueWarnings([
|
||||
buildField({ type: FieldMetadataType.NUMBER, defaultValue: 42 }),
|
||||
buildField({ type: FieldMetadataType.BOOLEAN, defaultValue: true }),
|
||||
buildField({ defaultValue: null }),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FieldManifest } from 'twenty-shared/application';
|
||||
import { getFieldDefaultValueWarnings } from '@/sdk/define/fields/get-field-default-value-warnings';
|
||||
import { validateFields } from '@/sdk/define/fields/validate-fields';
|
||||
|
||||
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||
@@ -15,8 +16,11 @@ export const defineField: DefineEntity<FieldManifest> = (config) => {
|
||||
|
||||
errors.push(...fieldErrors);
|
||||
|
||||
const warnings = getFieldDefaultValueWarnings([config]);
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
warnings,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type ObjectFieldManifest } from 'twenty-shared/application';
|
||||
import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from 'twenty-shared/constants';
|
||||
import {
|
||||
fieldMetadataDefaultValueFunctionName,
|
||||
type FieldMetadataDefaultValueFunctionNames,
|
||||
FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
import { isString } from '@sniptt/guards';
|
||||
|
||||
const isQuotedString = (value: string): boolean =>
|
||||
value.length >= 2 && value.startsWith("'") && value.endsWith("'");
|
||||
|
||||
const isComputedDefaultValue = (value: string): boolean =>
|
||||
Object.values(fieldMetadataDefaultValueFunctionName).includes(
|
||||
value as FieldMetadataDefaultValueFunctionNames,
|
||||
);
|
||||
|
||||
const collectUnquotedStrings = (field: ObjectFieldManifest): string[] => {
|
||||
const { defaultValue, type } = field;
|
||||
|
||||
if (!isDefined(defaultValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isString(defaultValue)) {
|
||||
return isQuotedString(defaultValue) || isComputedDefaultValue(defaultValue)
|
||||
? []
|
||||
: [defaultValue];
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(defaultValue) &&
|
||||
(type === FieldMetadataType.MULTI_SELECT ||
|
||||
type === FieldMetadataType.ARRAY)
|
||||
) {
|
||||
return defaultValue.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && !isQuotedString(item),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
type in COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES &&
|
||||
isPlainObject(defaultValue)
|
||||
) {
|
||||
return Object.values(defaultValue).filter(
|
||||
(value): value is string =>
|
||||
typeof value === 'string' && !isQuotedString(value),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getFieldDefaultValueWarnings = (
|
||||
fields: ObjectFieldManifest[] | undefined,
|
||||
): string[] => {
|
||||
if (!isDefined(fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fields.flatMap((field) => {
|
||||
const unquotedStrings = collectUnquotedStrings(field);
|
||||
|
||||
if (unquotedStrings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const quotedExample = unquotedStrings[0];
|
||||
|
||||
return [
|
||||
`Field "${field.label}" has string defaultValue(s) without surrounding single quotes: ${unquotedStrings
|
||||
.map((value) => `"${value}"`)
|
||||
.join(
|
||||
', ',
|
||||
)}. Unquoted strings are reserved for computed defaults ('uuid', 'now'). To define a literal string default, wrap it in single quotes: "'${quotedExample}'" instead of "${quotedExample}".`,
|
||||
];
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
|
||||
import { getFieldDefaultValueWarnings } from '@/sdk/define/fields/get-field-default-value-warnings';
|
||||
import { validateFields } from '@/sdk/define/fields/validate-fields';
|
||||
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -44,8 +45,11 @@ export const defineObject: DefineEntity<ObjectConfig> = (config) => {
|
||||
);
|
||||
}
|
||||
|
||||
const warnings = getFieldDefaultValueWarnings(config.fields);
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
warnings,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user