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 { SelectControl } from '@/ui/input/components/SelectControl';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext'; import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector'; import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
@ -38,6 +39,8 @@ export const WorkflowStepFilterFieldSelect = ({
}), }),
); );
const { getFieldMetadataItemById } = useGetFieldMetadataItemById();
const handleChange = useRecoilCallback( const handleChange = useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
(variableName: string) => { (variableName: string) => {
@ -54,24 +57,39 @@ export const WorkflowStepFilterFieldSelect = ({
) )
.getValue(); .getValue();
const { variableLabel, variableType } = const {
searchVariableThroughOutputSchema({ variableLabel,
variableType,
fieldMetadataId,
compositeFieldSubFieldName,
} = searchVariableThroughOutputSchema({
stepOutputSchema: currentStepOutputSchema?.[0], stepOutputSchema: currentStepOutputSchema?.[0],
rawVariableName: variableName, rawVariableName: variableName,
isFullRecord: false, isFullRecord: false,
}); });
const filterType = isDefined(fieldMetadataId)
? getFieldMetadataItemById(fieldMetadataId).type
: variableType;
upsertStepFilterSettings({ upsertStepFilterSettings({
stepFilterToUpsert: { stepFilterToUpsert: {
...stepFilter, ...stepFilter,
stepOutputKey: variableName, stepOutputKey: variableName,
displayValue: variableLabel ?? '', displayValue: variableLabel ?? '',
type: variableType ?? 'unknown', type: filterType ?? 'unknown',
value: '', value: '',
fieldMetadataId,
compositeFieldSubFieldName,
}, },
}); });
}, },
[upsertStepFilterSettings, stepFilter, workflowVersionId], [
upsertStepFilterSettings,
stepFilter,
workflowVersionId,
getFieldMetadataItemById,
],
); );
if (!isDefined(stepId)) { 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 { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext'; import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; 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 { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; 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 { useLingui } from '@lingui/react/macro';
import { isObject, isString } from '@sniptt/guards'; import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType, StepFilter } from 'twenty-shared/src/types'; import { FieldMetadataType, StepFilter } from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
@ -22,6 +18,14 @@ type WorkflowStepFilterValueInputProps = {
stepFilter: StepFilter; stepFilter: StepFilter;
}; };
const COMPOSITE_FIELD_METADATA_TYPES = [
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.CURRENCY,
];
const isFilterableFieldMetadataType = ( const isFilterableFieldMetadataType = (
type: string, type: string,
): type is FieldMetadataType => { ): type is FieldMetadataType => {
@ -37,6 +41,8 @@ const isFilterableFieldMetadataType = (
FieldMetadataType.RAW_JSON, FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT_V2, FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ARRAY, FieldMetadataType.ARRAY,
FieldMetadataType.UUID,
...COMPOSITE_FIELD_METADATA_TYPES,
].includes(type as FieldMetadataType); ].includes(type as FieldMetadataType);
}; };
@ -47,24 +53,6 @@ export const WorkflowStepFilterValueInput = ({
const { readonly } = useContext(WorkflowStepFilterContext); const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings(); 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 handleValueChange = (value: JsonValue) => {
const valueToUpsert = isString(value) const valueToUpsert = isString(value)
@ -92,11 +80,44 @@ export const WorkflowStepFilterValueInput = ({
return null; return null;
} }
if (isDefined(variableType) && isFilterableFieldMetadataType(variableType)) { const {
fieldMetadataId,
type: variableType,
compositeFieldSubFieldName,
} = stepFilter;
const selectedFieldMetadataItem = isDefined(fieldMetadataId) const selectedFieldMetadataItem = isDefined(fieldMetadataId)
? getFieldMetadataItemById(fieldMetadataId) ? getFieldMetadataItemById(fieldMetadataId)
: undefined; : 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 = { const field = {
type: variableType as FieldMetadataType, type: variableType as FieldMetadataType,
label: '', label: '',
@ -116,15 +137,4 @@ export const WorkflowStepFilterValueInput = ({
placeholder={t`Enter value`} 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; description?: string;
value: any; value: any;
fieldMetadataId?: string; fieldMetadataId?: string;
isCompositeSubField?: boolean;
}; };
type Node = { type Node = {
@ -18,6 +19,7 @@ type Node = {
value: OutputSchema; value: OutputSchema;
description?: string; description?: string;
fieldMetadataId?: string; fieldMetadataId?: string;
isCompositeSubField?: boolean;
}; };
type Link = { 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 { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils'; 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) => { const getDisplayedSubStepObjectLabel = (outputSchema: OutputSchema) => {
if (!isRecordOutputSchema(outputSchema)) { if (!isRecordOutputSchema(outputSchema)) {
return; return;
@ -54,6 +62,17 @@ const getFieldMetadataId = (
return undefined; return undefined;
}; };
const isCompositeSubField = (
key: string,
outputSchema: OutputSchema,
): boolean => {
if (isBaseOutputSchema(outputSchema) && outputSchema[key]?.isLeaf) {
return outputSchema[key]?.isCompositeSubField ?? false;
}
return false;
};
const searchCurrentStepOutputSchema = ({ const searchCurrentStepOutputSchema = ({
stepOutputSchema, stepOutputSchema,
path, path,
@ -70,6 +89,7 @@ const searchCurrentStepOutputSchema = ({
let nextKey = path[nextKeyIndex]; let nextKey = path[nextKeyIndex];
let variablePathLabel = stepOutputSchema.name; let variablePathLabel = stepOutputSchema.name;
let isSelectedFieldInNextKey = false; let isSelectedFieldInNextKey = false;
let parentFieldMetadataId: string | undefined;
const handleFieldNotFound = () => { const handleFieldNotFound = () => {
if (nextKeyIndex + 1 < path.length) { if (nextKeyIndex + 1 < path.length) {
@ -94,6 +114,7 @@ const searchCurrentStepOutputSchema = ({
currentSubStep = currentField.value; currentSubStep = currentField.value;
nextKey = path[nextKeyIndex + 1]; nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField.label}`; variablePathLabel = `${variablePathLabel} > ${currentField.label}`;
parentFieldMetadataId = currentField.fieldMetadataId;
} else { } else {
handleFieldNotFound(); handleFieldNotFound();
} }
@ -103,6 +124,7 @@ const searchCurrentStepOutputSchema = ({
currentSubStep = currentField.value; currentSubStep = currentField.value;
nextKey = path[nextKeyIndex + 1]; nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField.label}`; variablePathLabel = `${variablePathLabel} > ${currentField.label}`;
parentFieldMetadataId = currentField.fieldMetadataId;
} else { } else {
handleFieldNotFound(); handleFieldNotFound();
} }
@ -118,23 +140,24 @@ const searchCurrentStepOutputSchema = ({
}; };
} }
return { const variableName = isSelectedFieldInNextKey ? nextKey : selectedField;
variableLabel: const variableLabel =
isFullRecord && isRecordOutputSchema(currentSubStep) isFullRecord && isRecordOutputSchema(currentSubStep)
? getDisplayedSubStepObjectLabel(currentSubStep) ? getDisplayedSubStepObjectLabel(currentSubStep)
: getDisplayedSubStepFieldLabel( : getDisplayedSubStepFieldLabel(variableName, currentSubStep);
isSelectedFieldInNextKey ? nextKey : selectedField,
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType: getVariableType(variableName, currentSubStep),
fieldMetadataId:
getFieldMetadataId(variableName, currentSubStep) ?? parentFieldMetadataId,
compositeFieldSubFieldName: isCompositeSubField(
variableName,
currentSubStep, currentSubStep,
), )
variablePathLabel, ? variableName
variableType: getVariableType( : undefined,
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
fieldMetadataId: getFieldMetadataId(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
}; };
}; };
@ -146,7 +169,7 @@ export const searchVariableThroughOutputSchema = ({
stepOutputSchema: StepOutputSchema; stepOutputSchema: StepOutputSchema;
rawVariableName: string; rawVariableName: string;
isFullRecord?: boolean; isFullRecord?: boolean;
}) => { }): VariableInfo => {
if (!isDefined(stepOutputSchema)) { if (!isDefined(stepOutputSchema)) {
return { return {
variableLabel: undefined, variableLabel: undefined,
@ -175,18 +198,10 @@ export const searchVariableThroughOutputSchema = ({
}; };
} }
const { variableLabel, variablePathLabel, variableType, fieldMetadataId } = return searchCurrentStepOutputSchema({
searchCurrentStepOutputSchema({
stepOutputSchema, stepOutputSchema,
path, path,
isFullRecord, isFullRecord,
selectedField, selectedField,
}); });
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType,
fieldMetadataId,
};
}; };

View File

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

View File

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

View File

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

View File

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

View File

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