From e293c3331162cd4f60e8bb55f2bfa8a27e9fdb8a Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 12 Jun 2026 23:10:51 +0200 Subject: [PATCH] Normalize defaultValue properly (#21511) image --- .../developers/extend/apps/data/objects.mdx | 11 +++ .../get-field-default-value-warnings.spec.ts | 97 +++++++++++++++++++ .../src/sdk/define/fields/define-field.ts | 4 + .../get-field-default-value-warnings.ts | 80 +++++++++++++++ .../src/sdk/define/objects/define-object.ts | 4 + .../src/application/fieldManifestType.ts | 9 ++ 6 files changed, 205 insertions(+) create mode 100644 packages/twenty-sdk/src/sdk/define/fields/__tests__/get-field-default-value-warnings.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/define/fields/get-field-default-value-warnings.ts diff --git a/packages/twenty-docs/developers/extend/apps/data/objects.mdx b/packages/twenty-docs/developers/extend/apps/data/objects.mdx index b6f3f83ee66..6f466443d6c 100644 --- a/packages/twenty-docs/developers/extend/apps/data/objects.mdx +++ b/packages/twenty-docs/developers/extend/apps/data/objects.mdx @@ -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. +## 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. diff --git a/packages/twenty-sdk/src/sdk/define/fields/__tests__/get-field-default-value-warnings.spec.ts b/packages/twenty-sdk/src/sdk/define/fields/__tests__/get-field-default-value-warnings.spec.ts new file mode 100644 index 00000000000..c8a1ec6308f --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/fields/__tests__/get-field-default-value-warnings.spec.ts @@ -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 => + ({ + 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([]); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/fields/define-field.ts b/packages/twenty-sdk/src/sdk/define/fields/define-field.ts index 01834be74ef..a9ceee29eba 100644 --- a/packages/twenty-sdk/src/sdk/define/fields/define-field.ts +++ b/packages/twenty-sdk/src/sdk/define/fields/define-field.ts @@ -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 = (config) => { errors.push(...fieldErrors); + const warnings = getFieldDefaultValueWarnings([config]); + return createValidationResult({ config, errors, + warnings, }); }; diff --git a/packages/twenty-sdk/src/sdk/define/fields/get-field-default-value-warnings.ts b/packages/twenty-sdk/src/sdk/define/fields/get-field-default-value-warnings.ts new file mode 100644 index 00000000000..2f94f0e5cc5 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/fields/get-field-default-value-warnings.ts @@ -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}".`, + ]; + }); +}; diff --git a/packages/twenty-sdk/src/sdk/define/objects/define-object.ts b/packages/twenty-sdk/src/sdk/define/objects/define-object.ts index 432b7bc79f8..c5f6d765c36 100644 --- a/packages/twenty-sdk/src/sdk/define/objects/define-object.ts +++ b/packages/twenty-sdk/src/sdk/define/objects/define-object.ts @@ -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 = (config) => { ); } + const warnings = getFieldDefaultValueWarnings(config.fields); + return createValidationResult({ config, errors, + warnings, }); }; diff --git a/packages/twenty-shared/src/application/fieldManifestType.ts b/packages/twenty-shared/src/application/fieldManifestType.ts index 4ec1c90a3f3..48755ade547 100644 --- a/packages/twenty-shared/src/application/fieldManifestType.ts +++ b/packages/twenty-shared/src/application/fieldManifestType.ts @@ -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; options?: FieldMetadataOptions; universalSettings?: FieldMetadataUniversalSettings;