Tt filter step input per variable type (#13371)

- add fieldMetadataId to step output schema
- use it to display FormFieldInput in Filter input
- few fixes for a few fields

Next step:
- Handle composite fields
- Design review
This commit is contained in:
Thomas Trompette
2025-07-23 13:54:06 +02:00
committed by GitHub
parent a0a575fa0b
commit 015c4477a7
28 changed files with 347 additions and 79 deletions

View File

@ -28,9 +28,17 @@ type Link = {
export type BaseOutputSchema = Record<string, Leaf | Node>;
export type FieldOutputSchema =
| ((Leaf | Node) & { fieldMetadataId?: string })
| RecordOutputSchema;
export type RecordOutputSchema = {
object: { nameSingular: string; fieldIdName: string } & Leaf;
fields: BaseOutputSchema;
object: {
nameSingular: string;
fieldIdName: string;
objectMetadataId: string;
} & Leaf;
fields: Record<string, FieldOutputSchema>;
_outputSchemaType: 'RECORD';
};

View File

@ -55,6 +55,7 @@ describe('generateFakeField', () => {
const result = generateFakeField({
type: FieldMetadataType.TEXT,
label: 'Text Field',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -63,6 +64,7 @@ describe('generateFakeField', () => {
icon: undefined,
label: 'Text Field',
value: 'Fake Text',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(generateFakeValueSpy).toHaveBeenCalledWith(
@ -76,6 +78,7 @@ describe('generateFakeField', () => {
type: FieldMetadataType.TEXT,
label: 'Text Field',
value: 'Test value',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -84,6 +87,7 @@ describe('generateFakeField', () => {
icon: undefined,
label: 'Text Field',
value: 'Test value',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(generateFakeValueSpy).not.toHaveBeenCalled();
@ -96,6 +100,7 @@ describe('generateFakeField', () => {
type: FieldMetadataType.NUMBER,
label: 'Number Field',
icon: 'IconNumber',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -104,6 +109,7 @@ describe('generateFakeField', () => {
icon: 'IconNumber',
label: 'Number Field',
value: 42,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
});
@ -115,6 +121,7 @@ describe('generateFakeField', () => {
const result = generateFakeField({
type: FieldMetadataType.DATE,
label: 'Date Field',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -123,6 +130,7 @@ describe('generateFakeField', () => {
icon: undefined,
label: 'Date Field',
value: fakeDate,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
});
});
@ -140,6 +148,7 @@ describe('generateFakeField', () => {
const result = generateFakeField({
type: FieldMetadataType.LINKS,
label: 'Links Field',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -161,6 +170,7 @@ describe('generateFakeField', () => {
value: 'https://example.com',
},
},
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(generateFakeValueSpy).toHaveBeenCalledTimes(2);
@ -179,6 +189,7 @@ describe('generateFakeField', () => {
type: FieldMetadataType.CURRENCY,
label: 'Currency Field',
icon: 'IconCurrency',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -200,6 +211,7 @@ describe('generateFakeField', () => {
value: 'USD',
},
},
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
});
});
@ -213,6 +225,7 @@ describe('generateFakeField', () => {
const result = generateFakeField({
type: unknownType,
label: 'Unknown Field',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -221,6 +234,7 @@ describe('generateFakeField', () => {
icon: undefined,
label: 'Unknown Field',
value: 'Unknown Value',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
});
@ -230,6 +244,7 @@ describe('generateFakeField', () => {
const result = generateFakeField({
type: FieldMetadataType.BOOLEAN,
label: '',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result).toEqual({
@ -238,6 +253,7 @@ describe('generateFakeField', () => {
icon: undefined,
label: '',
value: 'Fake Boolean',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
});
});
});

View File

@ -59,6 +59,7 @@ describe('generateFakeFormResponse', () => {
expect(result).toMatchInlineSnapshot(`
{
"age": {
"fieldMetadataId": undefined,
"icon": undefined,
"isLeaf": true,
"label": "Age",
@ -72,6 +73,7 @@ describe('generateFakeFormResponse', () => {
"_outputSchemaType": "RECORD",
"fields": {
"domainName": {
"fieldMetadataId": "domainNameFieldMetadataId",
"icon": "test-field-icon",
"isLeaf": false,
"label": "Domain Name",
@ -98,6 +100,7 @@ describe('generateFakeFormResponse', () => {
},
},
"name": {
"fieldMetadataId": "nameFieldMetadataId",
"icon": "test-field-icon",
"isLeaf": true,
"label": "Name",
@ -111,11 +114,13 @@ describe('generateFakeFormResponse', () => {
"isLeaf": true,
"label": "Company",
"nameSingular": "company",
"objectMetadataId": "20202020-c03c-45d6-a4b0-04afe1357c5c",
"value": "A company",
},
},
},
"date": {
"fieldMetadataId": undefined,
"icon": undefined,
"isLeaf": true,
"label": "Date",
@ -123,6 +128,7 @@ describe('generateFakeFormResponse', () => {
"value": "mm/dd/yyyy",
},
"name": {
"fieldMetadataId": undefined,
"icon": undefined,
"isLeaf": true,
"label": "Name",

View File

@ -1,7 +1,7 @@
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/__mocks__/mockObjectMetadataItemsWithFieldMaps';
jest.mock(
'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields',
@ -13,8 +13,16 @@ describe('generateFakeObjectRecordEvent', () => {
});
const mockFields = {
field1: { type: 'TEXT', value: 'test' },
field2: { type: 'NUMBER', value: 123 },
field1: {
type: 'TEXT',
value: 'test',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
field2: {
type: 'NUMBER',
value: 123,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174001',
},
};
const companyMockObjectMetadataItem =
@ -55,10 +63,19 @@ describe('generateFakeObjectRecordEvent', () => {
value: 'A company',
nameSingular: 'company',
fieldIdName: 'properties.after.id',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
},
fields: {
'properties.after.field1': { type: 'TEXT', value: 'test' },
'properties.after.field2': { type: 'NUMBER', value: 123 },
'properties.after.field1': {
type: 'TEXT',
value: 'test',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
'properties.after.field2': {
type: 'NUMBER',
value: 123,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174001',
},
},
_outputSchemaType: 'RECORD',
});
@ -78,10 +95,19 @@ describe('generateFakeObjectRecordEvent', () => {
value: 'A company',
nameSingular: 'company',
fieldIdName: 'properties.after.id',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
},
fields: {
'properties.after.field1': { type: 'TEXT', value: 'test' },
'properties.after.field2': { type: 'NUMBER', value: 123 },
'properties.after.field1': {
type: 'TEXT',
value: 'test',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
'properties.after.field2': {
type: 'NUMBER',
value: 123,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174001',
},
},
_outputSchemaType: 'RECORD',
});
@ -101,10 +127,19 @@ describe('generateFakeObjectRecordEvent', () => {
value: 'A company',
nameSingular: 'company',
fieldIdName: 'properties.before.id',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
},
fields: {
'properties.before.field1': { type: 'TEXT', value: 'test' },
'properties.before.field2': { type: 'NUMBER', value: 123 },
'properties.before.field1': {
type: 'TEXT',
value: 'test',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
'properties.before.field2': {
type: 'NUMBER',
value: 123,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174001',
},
},
_outputSchemaType: 'RECORD',
});
@ -124,10 +159,19 @@ describe('generateFakeObjectRecordEvent', () => {
value: 'A company',
nameSingular: 'company',
fieldIdName: 'properties.before.id',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
},
fields: {
'properties.before.field1': { type: 'TEXT', value: 'test' },
'properties.before.field2': { type: 'NUMBER', value: 123 },
'properties.before.field1': {
type: 'TEXT',
value: 'test',
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
'properties.before.field2': {
type: 'NUMBER',
value: 123,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174001',
},
},
_outputSchemaType: 'RECORD',
});

View File

@ -43,6 +43,7 @@ describe('generateFakeObjectRecord', () => {
value: 'A company',
nameSingular: 'company',
fieldIdName: 'id',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
},
fields: {
field1: { type: 'TEXT', value: 'test' },

View File

@ -13,12 +13,14 @@ export const generateFakeField = ({
label,
icon,
value,
fieldMetadataId,
}: {
type: FieldMetadataType;
label: string;
fieldMetadataId?: string;
icon?: string;
value?: string;
}): Leaf | Node => {
}): (Leaf | Node) & { fieldMetadataId?: string } => {
const compositeType = compositeTypeDefinitions.get(type);
if (compositeType) {
@ -27,6 +29,7 @@ export const generateFakeField = ({
type: type,
icon: icon,
label: label,
fieldMetadataId,
value: compositeType.properties.reduce((acc, property) => {
// @ts-expect-error legacy noImplicitAny
acc[property.name] = {
@ -47,5 +50,6 @@ export const generateFakeField = ({
icon: icon,
label: label,
value: value || generateFakeValue(type, 'FieldMetadataType'),
fieldMetadataId,
};
};

View File

@ -60,7 +60,10 @@ export const generateFakeFormResponse = async ({
}),
);
return result.filter(isDefined).reduce((acc, curr) => {
return { ...acc, ...curr };
}, {});
return result.filter(isDefined).reduce(
(acc, curr) => {
return { ...acc, ...curr };
},
{} as Record<string, Leaf | Node>,
);
};

View File

@ -1,7 +1,7 @@
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import {
BaseOutputSchema,
FieldOutputSchema,
RecordOutputSchema,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
@ -20,7 +20,7 @@ const generateFakeObjectRecordEventWithPrefix = ({
return acc;
},
{} as BaseOutputSchema,
{} as Record<string, FieldOutputSchema>,
);
return {
@ -33,6 +33,7 @@ const generateFakeObjectRecordEventWithPrefix = ({
nameSingular:
objectMetadataInfo.objectMetadataItemWithFieldsMaps.nameSingular,
fieldIdName: `${prefix}.id`,
objectMetadataId: objectMetadataInfo.objectMetadataItemWithFieldsMaps.id,
},
fields: prefixedRecordFields,
_outputSchemaType: 'RECORD',

View File

@ -19,6 +19,7 @@ export const generateFakeObjectRecord = ({
nameSingular:
objectMetadataInfo.objectMetadataItemWithFieldsMaps.nameSingular,
fieldIdName: 'id',
objectMetadataId: objectMetadataInfo.objectMetadataItemWithFieldsMaps.id,
},
fields: generateObjectRecordFields({
objectMetadataInfo,

View File

@ -2,7 +2,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { BaseOutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { FieldOutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateFakeField } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-field';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record';
import { shouldGenerateFieldFakeValue } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value';
@ -15,11 +15,11 @@ export const generateObjectRecordFields = ({
}: {
objectMetadataInfo: ObjectMetadataInfo;
depth?: number;
}): BaseOutputSchema => {
}): Record<string, FieldOutputSchema> => {
const objectMetadata = objectMetadataInfo.objectMetadataItemWithFieldsMaps;
return Object.values(objectMetadata.fieldsById).reduce(
(acc: BaseOutputSchema, field) => {
(acc: Record<string, FieldOutputSchema>, field) => {
if (!shouldGenerateFieldFakeValue(field)) {
return acc;
}
@ -29,6 +29,7 @@ export const generateObjectRecordFields = ({
type: field.type,
label: field.label,
icon: field.icon ?? undefined,
fieldMetadataId: field.id,
});
return acc;
@ -51,6 +52,7 @@ export const generateObjectRecordFields = ({
isLeaf: false,
icon: field.icon ?? undefined,
label: field.label,
fieldMetadataId: field.id,
value: generateFakeObjectRecord({
objectMetadataInfo: {
objectMetadataItemWithFieldsMaps: relationTargetObjectMetadata,
@ -63,6 +65,6 @@ export const generateObjectRecordFields = ({
return acc;
},
{} as BaseOutputSchema,
{} as Record<string, FieldOutputSchema>,
);
};

View File

@ -68,30 +68,11 @@ describe('evaluateFilterConditions', () => {
expect(result).toBe(false);
});
it('should handle null checks', () => {
const filter1 = createFilter(ViewFilterOperand.Is, null, 'null');
const filter2 = createFilter(ViewFilterOperand.Is, undefined, 'NULL');
const filter3 = createFilter(ViewFilterOperand.Is, 'value', 'null');
it('should return true when values are equal but different types', () => {
const filter = createFilter(ViewFilterOperand.Is, '123', 123);
const result = evaluateFilterConditions({ filters: [filter] });
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
it('should handle not null checks', () => {
const filter1 = createFilter(ViewFilterOperand.Is, 'value', 'not null');
const filter2 = createFilter(ViewFilterOperand.Is, 'value', 'NOT NULL');
const filter3 = createFilter(ViewFilterOperand.Is, null, 'not null');
const filter4 = createFilter(
ViewFilterOperand.Is,
undefined,
'not null',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter4] })).toBe(false);
expect(result).toBe(true);
});
});
@ -182,12 +163,12 @@ describe('evaluateFilterConditions', () => {
const filter1 = createFilter(
ViewFilterOperand.Contains,
['apple', 'banana', 'cherry'],
'apple',
['apple'],
);
const filter2 = createFilter(
ViewFilterOperand.Contains,
['apple', 'banana', 'cherry'],
'grape',
['grape'],
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
@ -198,12 +179,12 @@ describe('evaluateFilterConditions', () => {
const filter1 = createFilter(
ViewFilterOperand.DoesNotContain,
['apple', 'banana', 'cherry'],
'apple',
['apple'],
);
const filter2 = createFilter(
ViewFilterOperand.DoesNotContain,
['apple', 'banana', 'cherry'],
'grape',
['grape'],
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false);

View File

@ -16,17 +16,19 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
switch (filter.operand) {
case ViewFilterOperand.Is:
if (String(rightValue).toLowerCase() === 'null') {
return leftValue === null || leftValue === undefined;
switch (typeof leftValue) {
case 'string':
return (
String(leftValue).toLowerCase() === String(rightValue).toLowerCase()
);
case 'boolean':
return Boolean(leftValue) === Boolean(rightValue);
default:
return leftValue === rightValue;
}
if (String(rightValue).toLowerCase() === 'not null') {
return leftValue !== null && leftValue !== undefined;
}
return leftValue == rightValue;
case ViewFilterOperand.IsNot:
return leftValue != rightValue;
return String(leftValue) !== String(rightValue);
case ViewFilterOperand.GreaterThanOrEqual:
return Number(leftValue) >= Number(rightValue);
@ -36,14 +38,38 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
case ViewFilterOperand.Contains:
if (Array.isArray(leftValue)) {
return leftValue.includes(rightValue);
try {
const parsedRightValue = Array.isArray(rightValue)
? rightValue
: JSON.parse(rightValue as string);
if (Array.isArray(parsedRightValue)) {
return parsedRightValue.every((item) => leftValue.includes(item));
} else {
return leftValue.includes(parsedRightValue);
}
} catch (error) {
return leftValue.includes(rightValue);
}
}
return String(leftValue).includes(String(rightValue));
case ViewFilterOperand.DoesNotContain:
if (Array.isArray(leftValue)) {
return !leftValue.includes(rightValue);
try {
const parsedRightValue = Array.isArray(rightValue)
? rightValue
: JSON.parse(rightValue as string);
if (Array.isArray(parsedRightValue)) {
return !parsedRightValue.every((item) => leftValue.includes(item));
} else {
return !leftValue.includes(parsedRightValue);
}
} catch (error) {
return !leftValue.includes(rightValue);
}
}
return !String(leftValue).includes(String(rightValue));
@ -67,17 +93,43 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
case ViewFilterOperand.IsNotNull:
return leftValue !== null && leftValue !== undefined;
case ViewFilterOperand.IsRelative:
case ViewFilterOperand.IsInPast:
if (typeof leftValue === 'string') {
return Date.now() - new Date(leftValue).getTime() > 0;
}
return false;
case ViewFilterOperand.IsInFuture:
if (typeof leftValue === 'string') {
return Date.now() - new Date(leftValue).getTime() < 0;
}
return false;
case ViewFilterOperand.IsToday:
if (typeof leftValue === 'string') {
return new Date(leftValue).toDateString() === new Date().toDateString();
}
return false;
case ViewFilterOperand.IsBefore:
if (typeof leftValue === 'string' && typeof rightValue === 'string') {
return new Date(leftValue).getTime() < new Date(rightValue).getTime();
}
return false;
case ViewFilterOperand.IsAfter:
// Date/time operands - for now, return false as placeholder
// These would need proper date logic implementation
if (typeof leftValue === 'string' && typeof rightValue === 'string') {
return new Date(leftValue).getTime() > new Date(rightValue).getTime();
}
return false;
case ViewFilterOperand.VectorSearch:
case ViewFilterOperand.IsRelative:
return false;
default:

View File

@ -40,6 +40,7 @@ const settings: WorkflowFormActionSettings = {
label: 'Id',
value: '123e4567-e89b-12d3-a456-426614174000',
isLeaf: true,
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
},
object: {
@ -49,6 +50,7 @@ const settings: WorkflowFormActionSettings = {
isLeaf: true,
fieldIdName: 'id',
nameSingular: 'company',
objectMetadataId: '123e4567-e89b-12d3-a456-426614174000',
},
_outputSchemaType: 'RECORD',
},