Manage composite fields in step filters (#13407)

- add to step output schema the information that field is a composite
sub field
- from output schema, when selecting the variable, copy all info
required to stepFilter
- from stepFilter, when selecting a value, display a special component
for composites
This commit is contained in:
Thomas Trompette
2025-07-24 17:50:07 +02:00
committed by GitHub
parent 3aba04abcd
commit 191d3531bf
10 changed files with 228 additions and 78 deletions

View File

@ -1,3 +1,4 @@
import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
@ -38,6 +39,8 @@ export const WorkflowStepFilterFieldSelect = ({
}),
);
const { getFieldMetadataItemById } = useGetFieldMetadataItemById();
const handleChange = useRecoilCallback(
({ snapshot }) =>
(variableName: string) => {
@ -54,24 +57,39 @@ export const WorkflowStepFilterFieldSelect = ({
)
.getValue();
const { variableLabel, variableType } =
searchVariableThroughOutputSchema({
const {
variableLabel,
variableType,
fieldMetadataId,
compositeFieldSubFieldName,
} = searchVariableThroughOutputSchema({
stepOutputSchema: currentStepOutputSchema?.[0],
rawVariableName: variableName,
isFullRecord: false,
});
const filterType = isDefined(fieldMetadataId)
? getFieldMetadataItemById(fieldMetadataId).type
: variableType;
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
stepOutputKey: variableName,
displayValue: variableLabel ?? '',
type: variableType ?? 'unknown',
type: filterType ?? 'unknown',
value: '',
fieldMetadataId,
compositeFieldSubFieldName,
},
});
},
[upsertStepFilterSettings, stepFilter, workflowVersionId],
[
upsertStepFilterSettings,
stepFilter,
workflowVersionId,
getFieldMetadataItemById,
],
);
if (!isDefined(stepId)) {

View File

@ -0,0 +1,84 @@
import { FormCountryMultiSelectInput } from '@/object-record/record-field/form-types/components/FormCountryMultiSelectInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useContext } from 'react';
import { StepFilter } from 'twenty-shared/types';
import { JsonValue } from 'type-fest';
export const WorkflowStepFilterValueCompositeInput = ({
stepFilter,
onChange,
}: {
stepFilter: StepFilter;
onChange: (newValue: JsonValue) => void;
}) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { type: filterType, compositeFieldSubFieldName: subFieldName } =
stepFilter;
return (
<>
{filterType === 'ADDRESS' ? (
subFieldName === 'addressCountry' ? (
<FormCountryMultiSelectInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
) : (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
)
) : filterType === 'CURRENCY' ? (
subFieldName === 'currencyCode' ? (
<FormMultiSelectFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
options={CURRENCIES}
readonly={readonly}
/>
) : subFieldName === 'amountMicros' ? (
<FormNumberFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
) : null
) : filterType === 'PHONES' ? (
subFieldName === 'primaryPhoneNumber' ? (
<FormNumberFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
) : (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
)
) : (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={onChange}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
)}
</>
);
};

View File

@ -3,17 +3,13 @@ import { configurableViewFilterOperands } from '@/object-record/object-filter-dr
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
import { useLingui } from '@lingui/react/macro';
import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType, StepFilter } from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils';
import { JsonValue } from 'type-fest';
@ -22,6 +18,14 @@ type WorkflowStepFilterValueInputProps = {
stepFilter: StepFilter;
};
const COMPOSITE_FIELD_METADATA_TYPES = [
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.CURRENCY,
];
const isFilterableFieldMetadataType = (
type: string,
): type is FieldMetadataType => {
@ -37,6 +41,8 @@ const isFilterableFieldMetadataType = (
FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ARRAY,
FieldMetadataType.UUID,
...COMPOSITE_FIELD_METADATA_TYPES,
].includes(type as FieldMetadataType);
};
@ -47,24 +53,6 @@ export const WorkflowStepFilterValueInput = ({
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const { workflowVersionId } = useWorkflowStepContextOrThrow();
const stepId = extractRawVariableNamePart({
rawVariableName: stepFilter.stepOutputKey,
part: 'stepId',
});
const stepsOutputSchema = useRecoilValue(
stepsOutputSchemaFamilySelector({
workflowVersionId,
stepIds: [stepId],
}),
);
const { variableType, fieldMetadataId } = searchVariableThroughOutputSchema({
stepOutputSchema: stepsOutputSchema?.[0],
rawVariableName: stepFilter.stepOutputKey,
isFullRecord: false,
});
const handleValueChange = (value: JsonValue) => {
const valueToUpsert = isString(value)
@ -92,11 +80,44 @@ export const WorkflowStepFilterValueInput = ({
return null;
}
if (isDefined(variableType) && isFilterableFieldMetadataType(variableType)) {
const {
fieldMetadataId,
type: variableType,
compositeFieldSubFieldName,
} = stepFilter;
const selectedFieldMetadataItem = isDefined(fieldMetadataId)
? getFieldMetadataItemById(fieldMetadataId)
: undefined;
if (
!isDefined(variableType) ||
!isFilterableFieldMetadataType(variableType) ||
!isDefined(selectedFieldMetadataItem)
) {
return (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={handleValueChange}
readonly={readonly}
VariablePicker={WorkflowVariablePicker}
placeholder={t`Enter value`}
/>
);
}
if (
isDefined(compositeFieldSubFieldName) &&
COMPOSITE_FIELD_METADATA_TYPES.includes(variableType as FieldMetadataType)
) {
return (
<WorkflowStepFilterValueCompositeInput
stepFilter={stepFilter}
onChange={handleValueChange}
/>
);
}
const field = {
type: variableType as FieldMetadataType,
label: '',
@ -116,15 +137,4 @@ export const WorkflowStepFilterValueInput = ({
placeholder={t`Enter value`}
/>
);
}
return (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={handleValueChange}
readonly={readonly}
VariablePicker={WorkflowVariablePicker}
placeholder={t`Enter value`}
/>
);
};

View File

@ -8,6 +8,7 @@ type Leaf = {
description?: string;
value: any;
fieldMetadataId?: string;
isCompositeSubField?: boolean;
};
type Node = {
@ -18,6 +19,7 @@ type Node = {
value: OutputSchema;
description?: string;
fieldMetadataId?: string;
isCompositeSubField?: boolean;
};
type Link = {

View File

@ -8,6 +8,14 @@ import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOu
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
type VariableInfo = {
variableLabel: string | undefined;
variablePathLabel: string | undefined;
variableType?: string | undefined;
fieldMetadataId?: string | undefined;
compositeFieldSubFieldName?: string | undefined;
};
const getDisplayedSubStepObjectLabel = (outputSchema: OutputSchema) => {
if (!isRecordOutputSchema(outputSchema)) {
return;
@ -54,6 +62,17 @@ const getFieldMetadataId = (
return undefined;
};
const isCompositeSubField = (
key: string,
outputSchema: OutputSchema,
): boolean => {
if (isBaseOutputSchema(outputSchema) && outputSchema[key]?.isLeaf) {
return outputSchema[key]?.isCompositeSubField ?? false;
}
return false;
};
const searchCurrentStepOutputSchema = ({
stepOutputSchema,
path,
@ -70,6 +89,7 @@ const searchCurrentStepOutputSchema = ({
let nextKey = path[nextKeyIndex];
let variablePathLabel = stepOutputSchema.name;
let isSelectedFieldInNextKey = false;
let parentFieldMetadataId: string | undefined;
const handleFieldNotFound = () => {
if (nextKeyIndex + 1 < path.length) {
@ -94,6 +114,7 @@ const searchCurrentStepOutputSchema = ({
currentSubStep = currentField.value;
nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField.label}`;
parentFieldMetadataId = currentField.fieldMetadataId;
} else {
handleFieldNotFound();
}
@ -103,6 +124,7 @@ const searchCurrentStepOutputSchema = ({
currentSubStep = currentField.value;
nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField.label}`;
parentFieldMetadataId = currentField.fieldMetadataId;
} else {
handleFieldNotFound();
}
@ -118,23 +140,24 @@ const searchCurrentStepOutputSchema = ({
};
}
return {
variableLabel:
const variableName = isSelectedFieldInNextKey ? nextKey : selectedField;
const variableLabel =
isFullRecord && isRecordOutputSchema(currentSubStep)
? getDisplayedSubStepObjectLabel(currentSubStep)
: getDisplayedSubStepFieldLabel(
isSelectedFieldInNextKey ? nextKey : selectedField,
: getDisplayedSubStepFieldLabel(variableName, currentSubStep);
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType: getVariableType(variableName, currentSubStep),
fieldMetadataId:
getFieldMetadataId(variableName, currentSubStep) ?? parentFieldMetadataId,
compositeFieldSubFieldName: isCompositeSubField(
variableName,
currentSubStep,
),
variablePathLabel,
variableType: getVariableType(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
fieldMetadataId: getFieldMetadataId(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
)
? variableName
: undefined,
};
};
@ -146,7 +169,7 @@ export const searchVariableThroughOutputSchema = ({
stepOutputSchema: StepOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}) => {
}): VariableInfo => {
if (!isDefined(stepOutputSchema)) {
return {
variableLabel: undefined,
@ -175,18 +198,10 @@ export const searchVariableThroughOutputSchema = ({
};
}
const { variableLabel, variablePathLabel, variableType, fieldMetadataId } =
searchCurrentStepOutputSchema({
return searchCurrentStepOutputSchema({
stepOutputSchema,
path,
isFullRecord,
selectedField,
});
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType,
fieldMetadataId,
};
};

View File

@ -8,6 +8,7 @@ export type Leaf = {
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
isCompositeSubField?: boolean;
};
export type Node = {
@ -29,7 +30,9 @@ type Link = {
export type BaseOutputSchema = Record<string, Leaf | Node>;
export type FieldOutputSchema =
| ((Leaf | Node) & { fieldMetadataId?: string })
| ((Leaf | Node) & {
fieldMetadataId?: string;
})
| RecordOutputSchema;
export type RecordOutputSchema = {

View File

@ -158,12 +158,16 @@ describe('generateFakeField', () => {
type: FieldMetadataType.LINKS,
value: {
label: {
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
isCompositeSubField: true,
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Label',
value: 'Fake Label',
},
url: {
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
isCompositeSubField: true,
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Url',
@ -199,12 +203,16 @@ describe('generateFakeField', () => {
type: FieldMetadataType.CURRENCY,
value: {
amount: {
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
isCompositeSubField: true,
isLeaf: true,
type: FieldMetadataType.NUMBER,
label: 'Amount',
value: 100,
},
currencyCode: {
fieldMetadataId: '123e4567-e89b-12d3-a456-426614174000',
isCompositeSubField: true,
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Currency Code',

View File

@ -80,18 +80,24 @@ describe('generateFakeFormResponse', () => {
"type": "LINKS",
"value": {
"primaryLinkLabel": {
"fieldMetadataId": "domainNameFieldMetadataId",
"isCompositeSubField": true,
"isLeaf": true,
"label": "Primary Link Label",
"type": "TEXT",
"value": "My text",
},
"primaryLinkUrl": {
"fieldMetadataId": "domainNameFieldMetadataId",
"isCompositeSubField": true,
"isLeaf": true,
"label": "Primary Link Url",
"type": "TEXT",
"value": "My text",
},
"secondaryLinks": {
"fieldMetadataId": "domainNameFieldMetadataId",
"isCompositeSubField": true,
"isLeaf": true,
"label": "Secondary Links",
"type": "RAW_JSON",

View File

@ -37,6 +37,8 @@ export const generateFakeField = ({
type: property.type,
label: camelToTitleCase(property.name),
value: value || generateFakeValue(property.type, 'FieldMetadataType'),
fieldMetadataId,
isCompositeSubField: true,
};
return acc;

View File

@ -16,10 +16,12 @@ export type StepFilter = {
id: string;
type: string;
label: string;
stepOutputKey: string;
operand: ViewFilterOperand;
value: string;
displayValue: string;
stepFilterGroupId: string;
stepOutputKey: string;
positionInStepFilterGroup?: number;
fieldMetadataId?: string;
compositeFieldSubFieldName?: string;
};