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:
+198
@@ -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 });
|
||||
});
|
||||
});
|
||||
+27
-14
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user