mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-12 18:08:58 -04:00
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.
|
||||
</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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user