Fix create new with filters (#18969)

Fixes https://github.com/twentyhq/twenty/issues/18949

## Problem
- Creating a record from filters with DATE_TIME fields produced empty
objects instead of dates — `isPlainObject` from `twenty-shared` treats
`Date` instances as plain objects, so `mergeCompositeValues` spread them
into `{}`
- `buildRecordInputFromFilter` applied composite merge logic to all
field types indiscriminately, including primitives, dates, and strings
- DATE_TIME "is before" filters produced exact boundary values instead
of subtracting a minute

## Fix
- Composite and non-composite fields are now handled in separate
branches — `buildRecordInputFromFilter` uses `isCompositeFieldType` to
decide whether to merge or assign directly
- `mergeCompositeValues` extracted to its own file with a properly typed
signature (`Record<string, unknown>`) — no more runtime type guessing
- DATE_TIME fields with `IS_BEFORE` operand subtract one minute using
`subMinutes` from `date-fns`
- 13 unit tests for `mergeCompositeValues` covering all composite field
types (currency, address, full name, links, emails, phones) and
successive sub-field accumulation
This commit is contained in:
Lucas Bordeau
2026-03-26 16:05:35 +01:00
committed by GitHub
parent 39b9ae6a76
commit e63a23ea00
2 changed files with 225 additions and 14 deletions
@@ -0,0 +1,198 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { ViewFilterOperand } from 'twenty-shared/types';
const FIELD_ID_TEXT = 'field-text-id';
const FIELD_ID_DATE_TIME = 'field-date-time-id';
const FIELD_ID_ADDRESS = 'field-address-id';
const FIELD_ID_NUMBER = 'field-number-id';
const FIELD_ID_UNKNOWN = 'field-unknown-id';
const mockObjectMetadataItem = {
fields: [
{
id: FIELD_ID_TEXT,
name: 'companyName',
type: 'TEXT',
options: null,
},
{
id: FIELD_ID_DATE_TIME,
name: 'createdAt',
type: 'DATE_TIME',
options: null,
},
{
id: FIELD_ID_ADDRESS,
name: 'address',
type: 'ADDRESS',
options: null,
},
{
id: FIELD_ID_NUMBER,
name: 'revenue',
type: 'NUMBER',
options: null,
},
],
} as unknown as EnrichedObjectMetadataItem;
const createFilter = (
overrides: Partial<RecordFilter> & Pick<RecordFilter, 'fieldMetadataId'>,
): RecordFilter => ({
id: 'filter-id',
value: '',
displayValue: '',
type: 'TEXT',
operand: ViewFilterOperand.CONTAINS,
label: 'Test',
...overrides,
});
describe('buildRecordInputFromFilter', () => {
it('should assign text value directly without merging', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_TEXT,
type: 'TEXT',
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result).toEqual({ companyName: 'Acme' });
});
it('should subtract one minute for DATE_TIME with IS_BEFORE operand', () => {
const filterDate = new Date('2025-06-15T10:30:00.000Z');
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_DATE_TIME,
type: 'DATE_TIME',
operand: ViewFilterOperand.IS_BEFORE,
value: filterDate.toISOString(),
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result.createdAt).toEqual(new Date('2025-06-15T10:29:00.000Z'));
});
it('should not subtract a minute for DATE_TIME with IS operand', () => {
const filterDate = new Date('2025-06-15T10:30:00.000Z');
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_DATE_TIME,
type: 'DATE_TIME',
operand: ViewFilterOperand.IS,
value: filterDate.toISOString(),
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result.createdAt).toEqual(filterDate);
});
it('should deep-merge a single composite address sub-field starting from empty object', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_ADDRESS,
type: 'ADDRESS',
operand: ViewFilterOperand.CONTAINS,
value: 'Paris',
subFieldName: 'addressCity',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result.address).toEqual({ addressCity: 'Paris' });
});
it('should merge composite address sub-fields into a single object', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_ADDRESS,
type: 'ADDRESS',
operand: ViewFilterOperand.CONTAINS,
value: 'Paris',
subFieldName: 'addressCity',
}),
createFilter({
id: 'filter-id-2',
fieldMetadataId: FIELD_ID_ADDRESS,
type: 'ADDRESS',
operand: ViewFilterOperand.CONTAINS,
value: 'France',
subFieldName: 'addressCountry',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result.address).toEqual({
addressCity: 'Paris',
addressCountry: 'France',
});
});
it('should skip filters with no matching field metadata', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_UNKNOWN,
type: 'TEXT',
operand: ViewFilterOperand.CONTAINS,
value: 'something',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result).toEqual({});
});
it('should skip filters where buildValueFromFilter returns undefined', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_TEXT,
type: 'TEXT',
operand: ViewFilterOperand.IS_EMPTY,
value: '',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result).toEqual({});
});
it('should assign number value directly without merging', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_NUMBER,
type: 'NUMBER',
operand: ViewFilterOperand.IS,
value: '42',
}),
],
objectMetadataItem: mockObjectMetadataItem,
});
expect(result).toEqual({ revenue: 42 });
});
});
@@ -1,17 +1,16 @@
import { subMinutes } from 'date-fns';
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
const mergeCompositeValues = (
existingValue: unknown,
incomingValue: unknown,
) =>
isPlainObject(existingValue) && isPlainObject(incomingValue)
? { ...existingValue, ...incomingValue }
: incomingValue;
import {
FieldMetadataType,
ViewFilterOperand,
type ObjectRecord,
} from 'twenty-shared/types';
import { deepMerge, isDefined } from 'twenty-shared/utils';
export const buildRecordInputFromFilter = ({
currentRecordFilters,
@@ -32,6 +31,7 @@ export const buildRecordInputFromFilter = ({
if (!isDefined(fieldMetadataItem)) {
return;
}
if (fieldMetadataItem.type === 'RELATION') {
const value = buildValueFromFilter({
filter,
@@ -40,22 +40,35 @@ export const buildRecordInputFromFilter = ({
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
label: filter.label,
});
if (!isDefined(value)) {
return;
}
recordInput[`${fieldMetadataItem.name}Id`] = value;
} else {
const value = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
});
if (!isDefined(value)) {
return;
}
recordInput[fieldMetadataItem.name] = mergeCompositeValues(
recordInput[fieldMetadataItem.name],
value,
);
if (isCompositeFieldType(fieldMetadataItem.type)) {
recordInput[fieldMetadataItem.name] = deepMerge(
recordInput[fieldMetadataItem.name] ?? {},
value,
);
} else if (
fieldMetadataItem.type === FieldMetadataType.DATE_TIME &&
filter.operand === ViewFilterOperand.IS_BEFORE
) {
recordInput[fieldMetadataItem.name] = subMinutes(value as Date, 1);
} else {
recordInput[fieldMetadataItem.name] = value;
}
}
});