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:
martmull
2026-06-12 23:10:51 +02:00
committed by GitHub
parent 926cf2d0cf
commit e293c33311
6 changed files with 205 additions and 0 deletions

View File

@@ -86,6 +86,17 @@ export default defineObject({
**Base fields are added automatically.** When you define a custom object, Twenty creates standard fields like `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and `deletedAt` for you. You don't need to declare them in your `fields` array — only your custom fields. You can override a default field by declaring one with the same name, but this is rarely a good idea.
</Note>
## Default values
Literal string defaults must be wrapped in single quotes **inside** the string — `defaultValue: "'Draft'"`, not `defaultValue: "Draft"`. That's why the `status` field above uses `` `'${PostCardStatus.DRAFT}'` ``.
Unquoted strings are reserved for computed defaults, evaluated when a record is created:
- `'uuid'` — generates a UUID (for `UUID` fields)
- `'now'` — the current timestamp (for `DATE_TIME` fields)
The same convention applies to string sub-fields of composite defaults (e.g. `{ source: "'MANUAL'" }` on an `ACTOR` field) and to `SELECT`/`MULTI_SELECT` values. A literal string default left unquoted raises a warning when your app is built.
## What's next
- **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern.

View File

@@ -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([]);
});
});

View File

@@ -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,
});
};

View File

@@ -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}".`,
];
});
};

View File

@@ -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,
});
};

View File

@@ -18,6 +18,15 @@ export type RegularFieldManifest<
label: string;
description?: string;
icon?: string;
/**
* Default value in the canonical metadata format.
*
* Literal string defaults must be wrapped in single quotes inside the
* string (e.g. `"'Draft'"`), including string sub-fields of composite
* defaults (e.g. `{ source: "'MANUAL'" }`) and SELECT/MULTI_SELECT values.
* Unquoted strings are reserved for computed defaults such as `'uuid'`
* and `'now'`; any other unquoted string raises a validation warning.
*/
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
universalSettings?: FieldMetadataUniversalSettings<T>;