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:
@@ -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.
|
**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>
|
</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
|
## What's next
|
||||||
|
|
||||||
- **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern.
|
- **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern.
|
||||||
|
|||||||
+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 { 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 { validateFields } from '@/sdk/define/fields/validate-fields';
|
||||||
|
|
||||||
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||||
@@ -15,8 +16,11 @@ export const defineField: DefineEntity<FieldManifest> = (config) => {
|
|||||||
|
|
||||||
errors.push(...fieldErrors);
|
errors.push(...fieldErrors);
|
||||||
|
|
||||||
|
const warnings = getFieldDefaultValueWarnings([config]);
|
||||||
|
|
||||||
return createValidationResult({
|
return createValidationResult({
|
||||||
config,
|
config,
|
||||||
errors,
|
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 { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
|
||||||
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
|
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 { validateFields } from '@/sdk/define/fields/validate-fields';
|
||||||
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
|
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
@@ -44,8 +45,11 @@ export const defineObject: DefineEntity<ObjectConfig> = (config) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnings = getFieldDefaultValueWarnings(config.fields);
|
||||||
|
|
||||||
return createValidationResult({
|
return createValidationResult({
|
||||||
config,
|
config,
|
||||||
errors,
|
errors,
|
||||||
|
warnings,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export type RegularFieldManifest<
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: 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>;
|
defaultValue?: FieldMetadataDefaultValue<T>;
|
||||||
options?: FieldMetadataOptions<T>;
|
options?: FieldMetadataOptions<T>;
|
||||||
universalSettings?: FieldMetadataUniversalSettings<T>;
|
universalSettings?: FieldMetadataUniversalSettings<T>;
|
||||||
|
|||||||
Reference in New Issue
Block a user