Compare commits

...

5 Commits

Author SHA1 Message Date
Félix Malfait 58cd7f7c05 Lint 2026-03-13 13:58:50 +01:00
Félix Malfait 6ada4eb88c fix: type canProvidePlaceholderForFieldType param as FieldMetadataType
Made-with: Cursor
2026-03-13 13:52:20 +01:00
Félix Malfait c69f34213a fix: use valid phone placeholder and local date for required fields
- PHONES: Use valid libphonenumber-js placeholder (5551234567, US, +1)
  instead of 'Untitled' which caused INVALID_PHONE_NUMBER on backend
- DATE: Use format(new Date(), 'yyyy-MM-dd') for local date instead of
  toISOString().split('T')[0] which was off by one day for non-UTC users

Made-with: Cursor
2026-03-13 13:52:04 +01:00
Félix Malfait 1308e11232 Address PR review: compute join column at runtime, fix DATE/EMAILS/SELECT
- Compute relation join column at runtime: use getJoinColumnName(settings)
  ?? `${field.name}Id` instead of only settings.joinColumnName
- Use date-only (YYYY-MM-DD) for FieldMetadataType.DATE; keep ISO string
  for DATE_TIME
- Use syntactically valid email 'untitled@example.com' for required EMAILS
- Add missingRequiredFieldsUnfillable for required SELECT with no options
  and other unfillable types; throw clear error instead of GraphQL failure
- Update tests: remove hard-coded settings.joinColumnName from relation mock
- Add comment explaining difference from prefillRecord/generateEmptyFieldValue
- Fix ApolloCache<object> type argument

Made-with: Cursor
2026-03-13 13:39:50 +01:00
Félix Malfait a2830d8817 fix: add placeholders for required fields when creating records via quick-create
- Add getRecordInputPlaceholdersForRequiredFields utility to fill missing
  required non-relation fields (TEXT, FULL_NAME, UUID, etc.) with safe
  defaults before create mutation
- For label identifier: use 'Untitled {objectLabelSingular}'; for other
  TEXT fields: use 'Untitled'
- Required MANY_TO_ONE relations cannot be auto-filled: throw clear error
  with field labels instead of opaque GraphQL error
- Placeholders are merged so filters/RLS/explicit input override them
- Fix setLoading(false) in catch block when mutation fails
- Add unit tests for placeholder logic and error message formatting

Fixes 'Field "title" of required type "String!" was not provided'
when using Add New with minimal input (e.g. Playbook with only position)

Made-with: Cursor
2026-03-13 13:27:55 +01:00
3 changed files with 533 additions and 3 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { v4 } from 'uuid';
import { type ApolloCache, type FetchResult } from '@apollo/client';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@@ -28,6 +29,13 @@ import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/ut
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import {
getRecordInputPlaceholdersForRequiredFields,
getRequiredFieldsUnfillableErrorMessage,
getRequiredRelationFieldsMissingErrorMessage,
REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE,
REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE,
} from '@/object-record/utils/getRecordInputPlaceholdersForRequiredFields';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CustomError, isDefined } from 'twenty-shared/utils';
@@ -86,10 +94,44 @@ export const useCreateOneRecord = <
const idForCreation = recordInput.id ?? v4();
const {
placeholders,
missingRequiredRelationFields,
missingRequiredFieldsUnfillable,
} = getRecordInputPlaceholdersForRequiredFields(
objectMetadataItem,
recordInput,
);
if (missingRequiredRelationFields.length > 0) {
setLoading(false);
throw new CustomError(
getRequiredRelationFieldsMissingErrorMessage(
missingRequiredRelationFields,
),
REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE,
);
}
if (missingRequiredFieldsUnfillable.length > 0) {
setLoading(false);
throw new CustomError(
getRequiredFieldsUnfillableErrorMessage(
missingRequiredFieldsUnfillable,
),
REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE,
);
}
const recordInputWithPlaceholder = {
...placeholders,
...recordInput,
};
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput,
recordInput: recordInputWithPlaceholder,
}),
id: idForCreation,
};
@@ -101,7 +143,7 @@ export const useCreateOneRecord = <
objectMetadataItems,
recordInput: {
...computeOptimisticCreateRecordBaseRecordInput(objectMetadataItem),
...recordInput,
...recordInputWithPlaceholder,
id: idForCreation,
},
objectPermissionsByObjectMetadataId,
@@ -143,7 +185,8 @@ export const useCreateOneRecord = <
variables: {
input: sanitizedInput,
},
update: (cache, { data }) => {
update: (cache: ApolloCache<object>, result: FetchResult) => {
const { data } = result;
const record = data?.[mutationResponseField];
if (skipPostOptimisticEffect === false && isDefined(record)) {
triggerCreateRecordsOptimisticEffect({
@@ -162,6 +205,8 @@ export const useCreateOneRecord = <
},
})
.catch((error: Error) => {
setLoading(false);
if (!isDefined(recordCreatedInCache)) {
throw error;
}
@@ -0,0 +1,218 @@
import {
getRecordInputPlaceholdersForRequiredFields,
getRequiredFieldsUnfillableErrorMessage,
getRequiredRelationFieldsMissingErrorMessage,
REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE,
REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE,
} from '@/object-record/utils/getRecordInputPlaceholdersForRequiredFields';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> & {
fields: ObjectMetadataItem['fields'];
},
): ObjectMetadataItem =>
({
nameSingular: 'playbook',
labelSingular: 'Playbook',
labelPlural: 'Playbooks',
fields: [],
...overrides,
}) as ObjectMetadataItem;
const createMockField = (
overrides: Partial<FieldMetadataItem>,
): FieldMetadataItem =>
({
id: 'field-id',
name: 'title',
label: 'Title',
type: FieldMetadataType.TEXT,
isNullable: false,
...overrides,
}) as FieldMetadataItem;
describe('getRecordInputPlaceholdersForRequiredFields', () => {
it('should add placeholder for missing required TEXT label identifier', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelIdentifierFieldMetadataId: 'title-field-id',
fields: [
createMockField({
id: 'title-field-id',
name: 'title',
type: FieldMetadataType.TEXT,
isNullable: false,
}),
],
});
const { placeholders, missingRequiredRelationFields } =
getRecordInputPlaceholdersForRequiredFields(objectMetadataItem, {});
expect(placeholders.title).toBeDefined();
expect(typeof placeholders.title).toBe('string');
expect((placeholders.title as string).length).toBeGreaterThan(0);
expect(missingRequiredRelationFields).toHaveLength(0);
});
it('should skip placeholder when value already provided', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelIdentifierFieldMetadataId: 'title-field-id',
fields: [
createMockField({
id: 'title-field-id',
name: 'title',
type: FieldMetadataType.TEXT,
isNullable: false,
}),
],
});
const { placeholders } = getRecordInputPlaceholdersForRequiredFields(
objectMetadataItem,
{ title: 'My Playbook' },
);
expect(placeholders.title).toBeUndefined();
});
it('should add missing required relation to missingRequiredRelationFields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelIdentifierFieldMetadataId: 'title-field-id',
fields: [
createMockField({
id: 'title-field-id',
name: 'title',
type: FieldMetadataType.TEXT,
isNullable: false,
}),
createMockField({
id: 'workspace-field-id',
name: 'workspace',
label: 'Workspace',
type: FieldMetadataType.RELATION,
isNullable: false,
relation: { type: RelationType.MANY_TO_ONE },
}),
],
});
const { placeholders, missingRequiredRelationFields } =
getRecordInputPlaceholdersForRequiredFields(objectMetadataItem, {});
expect(placeholders.title).toBeDefined();
expect(missingRequiredRelationFields).toHaveLength(1);
expect(missingRequiredRelationFields[0].name).toBe('workspace');
});
it('should treat relation as provided when {fieldName}Id is set', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelIdentifierFieldMetadataId: 'title-field-id',
fields: [
createMockField({
id: 'title-field-id',
name: 'title',
type: FieldMetadataType.TEXT,
isNullable: false,
}),
createMockField({
id: 'workspace-field-id',
name: 'workspace',
label: 'Workspace',
type: FieldMetadataType.RELATION,
isNullable: false,
relation: { type: RelationType.MANY_TO_ONE },
}),
],
});
const { missingRequiredRelationFields } =
getRecordInputPlaceholdersForRequiredFields(objectMetadataItem, {
workspaceId: 'existing-workspace-id',
});
expect(missingRequiredRelationFields).toHaveLength(0);
});
});
describe('getRequiredRelationFieldsMissingErrorMessage', () => {
it('should return formatted message with field labels', () => {
const fields = [
createMockField({
name: 'workspace',
label: 'Workspace',
type: FieldMetadataType.RELATION,
}),
createMockField({
name: 'owner',
label: 'Owner',
type: FieldMetadataType.RELATION,
}),
];
const message = getRequiredRelationFieldsMissingErrorMessage(fields);
expect(message).toContain('Workspace');
expect(message).toContain('Owner');
expect(message).toContain('required but cannot be auto-filled');
});
});
describe('REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE', () => {
it('should be the expected constant', () => {
expect(REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE).toBe(
'REQUIRED_RELATION_FIELDS_MISSING',
);
});
});
describe('missingRequiredFieldsUnfillable', () => {
it('should add required SELECT with no options to missingRequiredFieldsUnfillable', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelIdentifierFieldMetadataId: 'title-field-id',
fields: [
createMockField({
id: 'title-field-id',
name: 'title',
type: FieldMetadataType.TEXT,
isNullable: false,
}),
createMockField({
id: 'status-field-id',
name: 'status',
label: 'Status',
type: FieldMetadataType.SELECT,
isNullable: false,
options: [],
}),
],
});
const { placeholders, missingRequiredFieldsUnfillable } =
getRecordInputPlaceholdersForRequiredFields(objectMetadataItem, {});
expect(placeholders.title).toBeDefined();
expect(placeholders.status).toBeUndefined();
expect(missingRequiredFieldsUnfillable).toHaveLength(1);
expect(missingRequiredFieldsUnfillable[0].name).toBe('status');
});
});
describe('getRequiredFieldsUnfillableErrorMessage', () => {
it('should return formatted message', () => {
const fields = [createMockField({ name: 'status', label: 'Status' })];
const message = getRequiredFieldsUnfillableErrorMessage(fields);
expect(message).toContain('Status');
expect(message).toContain('cannot be auto-filled');
});
});
describe('REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE', () => {
it('should be the expected constant', () => {
expect(REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE).toBe(
'REQUIRED_FIELDS_UNFILLABLE',
);
});
});
@@ -0,0 +1,267 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { format } from 'date-fns';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const UNTITLED_PLACEHOLDER = 'Untitled';
// Unlike prefillRecord/generateEmptyFieldValue which produce empty/display values
// (null, ''), this utility produces valid non-null placeholders for required
// fields so the create mutation can succeed. The backend rejects null/empty
// for required TEXT fields.
//
// TODO: Long-term product vision: when quick-create has no/minimal input, show
// a creation form instead of auto-creating with placeholders, so users provide
// real data rather than editing placeholder records after creation.
type GetRecordInputPlaceholdersForRequiredFieldsResult = {
placeholders: Partial<ObjectRecord>;
missingRequiredRelationFields: FieldMetadataItem[];
missingRequiredFieldsUnfillable: FieldMetadataItem[];
};
const getPlaceholderForFieldType = (
field: FieldMetadataItem,
objectMetadataItem: ObjectMetadataItem,
): unknown => {
const { defaultValue } = field;
const isLabelIdentifier =
getLabelIdentifierFieldMetadataItem(objectMetadataItem)?.id === field.id;
const untitledLabel = isLabelIdentifier
? t`Untitled ${objectMetadataItem.labelSingular}`
: UNTITLED_PLACEHOLDER;
if (field.type === FieldMetadataType.TEXT) {
if (isNonEmptyString(defaultValue)) {
return defaultValue;
}
return untitledLabel;
}
if (field.type === FieldMetadataType.FULL_NAME) {
const defaultFirstName =
defaultValue &&
typeof defaultValue === 'object' &&
'firstName' in defaultValue
? (defaultValue as { firstName?: string }).firstName
: undefined;
const firstName = isNonEmptyString(defaultFirstName)
? defaultFirstName
: untitledLabel;
return { firstName, lastName: '' };
}
if (field.type === FieldMetadataType.UUID) {
return v4();
}
if (field.type === FieldMetadataType.BOOLEAN) {
return field.defaultValue ?? true;
}
if (
field.type === FieldMetadataType.NUMBER ||
field.type === FieldMetadataType.NUMERIC ||
field.type === FieldMetadataType.RATING ||
field.type === FieldMetadataType.POSITION
) {
return defaultValue ?? 0;
}
if (field.type === FieldMetadataType.DATE) {
return defaultValue ?? format(new Date(), 'yyyy-MM-dd');
}
if (field.type === FieldMetadataType.DATE_TIME) {
return defaultValue ?? new Date().toISOString();
}
if (field.type === FieldMetadataType.SELECT) {
const options = field.options?.map((opt) => opt.value) ?? [];
if (isDefined(defaultValue) && options.includes(defaultValue)) {
return defaultValue;
}
return options[0] ?? undefined;
}
if (field.type === FieldMetadataType.MULTI_SELECT) {
return defaultValue ?? [];
}
if (field.type === FieldMetadataType.LINKS) {
return {
primaryLinkUrl: '',
primaryLinkLabel: untitledLabel,
secondaryLinks: [],
};
}
if (field.type === FieldMetadataType.ADDRESS) {
return {
addressStreet1: untitledLabel,
addressStreet2: '',
addressCity: '',
addressState: '',
addressCountry: '',
addressPostcode: '',
addressLat: null,
addressLng: null,
};
}
if (field.type === FieldMetadataType.EMAILS) {
return {
primaryEmail: 'untitled@example.com',
additionalEmails: null,
};
}
if (field.type === FieldMetadataType.PHONES) {
return {
primaryPhoneNumber: '5551234567',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: null,
};
}
if (field.type === FieldMetadataType.RICH_TEXT_V2) {
return {
blocknote: null,
markdown: untitledLabel,
};
}
if (field.type === FieldMetadataType.CURRENCY) {
const defaultCurrency =
defaultValue &&
typeof defaultValue === 'object' &&
'currencyCode' in defaultValue
? (defaultValue as { amountMicros?: number; currencyCode?: string })
: undefined;
return {
amountMicros: defaultCurrency?.amountMicros ?? 0,
currencyCode: defaultCurrency?.currencyCode ?? 'USD',
};
}
return undefined;
};
const canProvidePlaceholderForFieldType = (
fieldType: FieldMetadataType,
): boolean => {
const placeholderableTypes: FieldMetadataType[] = [
FieldMetadataType.TEXT,
FieldMetadataType.FULL_NAME,
FieldMetadataType.UUID,
FieldMetadataType.BOOLEAN,
FieldMetadataType.NUMBER,
FieldMetadataType.NUMERIC,
FieldMetadataType.RATING,
FieldMetadataType.POSITION,
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.LINKS,
FieldMetadataType.ADDRESS,
FieldMetadataType.EMAILS,
FieldMetadataType.PHONES,
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.CURRENCY,
];
return placeholderableTypes.includes(fieldType);
};
const isRequiredRelationField = (field: FieldMetadataItem): boolean =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE &&
field.isNullable === false;
const isRequiredMorphRelationField = (field: FieldMetadataItem): boolean =>
field.type === FieldMetadataType.MORPH_RELATION &&
field.settings?.relationType === RelationType.MANY_TO_ONE &&
field.isNullable === false;
export const getRecordInputPlaceholdersForRequiredFields = (
objectMetadataItem: ObjectMetadataItem,
recordInput: Partial<ObjectRecord>,
): GetRecordInputPlaceholdersForRequiredFieldsResult => {
const placeholders: Partial<ObjectRecord> = {};
const missingRequiredRelationFields: FieldMetadataItem[] = [];
const missingRequiredFieldsUnfillable: FieldMetadataItem[] = [];
for (const field of objectMetadataItem.fields) {
if (field.isNullable !== false) {
continue;
}
// Skip if value already provided (filters, RLS, or explicit input)
const existingValue = recordInput[field.name];
const relationJoinColumnName =
isRequiredRelationField(field) || isRequiredMorphRelationField(field)
? (getJoinColumnName(field.settings) ?? `${field.name}Id`)
: undefined;
const joinColumnValue = relationJoinColumnName
? recordInput[relationJoinColumnName]
: undefined;
if (isDefined(existingValue) || isDefined(joinColumnValue)) {
continue;
}
if (isRequiredRelationField(field) || isRequiredMorphRelationField(field)) {
missingRequiredRelationFields.push(field);
continue;
}
if (!canProvidePlaceholderForFieldType(field.type)) {
missingRequiredFieldsUnfillable.push(field);
continue;
}
const placeholder = getPlaceholderForFieldType(field, objectMetadataItem);
if (isDefined(placeholder)) {
placeholders[field.name] = placeholder;
} else {
missingRequiredFieldsUnfillable.push(field);
}
}
return {
placeholders,
missingRequiredRelationFields,
missingRequiredFieldsUnfillable,
};
};
export const REQUIRED_RELATION_FIELDS_MISSING_ERROR_CODE =
'REQUIRED_RELATION_FIELDS_MISSING' as const;
export const REQUIRED_FIELDS_UNFILLABLE_ERROR_CODE =
'REQUIRED_FIELDS_UNFILLABLE' as const;
export const getRequiredRelationFieldsMissingErrorMessage = (
missingRequiredRelationFields: FieldMetadataItem[],
): string => {
const fieldLabels = missingRequiredRelationFields
.map((field) => field.label)
.join(', ');
return `Cannot create record: the field(s) "${fieldLabels}" are required but cannot be auto-filled. Make them optional in Settings > Data model, or add a default value.`;
};
export const getRequiredFieldsUnfillableErrorMessage = (
missingRequiredFieldsUnfillable: FieldMetadataItem[],
): string => {
const fieldLabels = missingRequiredFieldsUnfillable
.map((field) => field.label)
.join(', ');
return `Cannot create record: the field(s) "${fieldLabels}" are required but cannot be auto-filled (e.g. SELECT with no options). Make them optional in Settings > Data model, or add a default value.`;
};