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

@ -47,6 +47,7 @@ import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isF
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { FieldMetadataType } from 'twenty-shared/types';
import { JsonValue } from 'type-fest';
type FormFieldInputProps = {
@ -70,7 +71,7 @@ export const FormFieldInput = ({
error,
onError,
}: FormFieldInputProps) => {
return isFieldNumber(field) ? (
return isFieldNumber(field) || field.type === FieldMetadataType.NUMERIC ? (
<FormNumberFieldInput
label={field.label}
defaultValue={defaultValue as string | number | undefined}

View File

@ -48,7 +48,7 @@ export const FormBooleanFieldInput = ({
}
: {
type: 'static',
value: defaultValue ?? false,
value: Boolean(defaultValue),
},
);

View File

@ -19,6 +19,7 @@ import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useLis
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { ChangeEvent, KeyboardEvent, useId, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
@ -106,9 +107,10 @@ export const FormDateTimeFieldInput = ({
},
);
const draftValueAsDate = isDefined(draftValue.value)
? new Date(draftValue.value)
: null;
const draftValueAsDate =
isDefined(draftValue.value) && isNonEmptyString(draftValue.value)
? new Date(draftValue.value)
: null;
const [pickerDate, setPickerDate] =
useState<Nullable<Date>>(draftValueAsDate);

View File

@ -10,7 +10,9 @@ import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/rootLevelStepFilterGroupComponentSelector';
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/isStepFilterGroupChildAStepFilterGroup';
import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
@ -27,6 +29,10 @@ const StyledChildContainer = styled.div`
width: 100%;
`;
const StyledDangerContainer = styled.div`
color: ${({ theme }) => theme.font.color.danger};
`;
type WorkflowEditActionFilterBodyProps = {
action: WorkflowFilterAction;
actionOptions:
@ -43,6 +49,8 @@ export const WorkflowEditActionFilterBody = ({
action,
actionOptions,
}: WorkflowEditActionFilterBodyProps) => {
const { t } = useLingui();
const rootStepFilterGroup = useRecoilComponentValueV2(
rootLevelStepFilterGroupComponentSelector,
);
@ -69,6 +77,22 @@ export const WorkflowEditActionFilterBody = ({
});
};
const availableVariablesInWorkflowStep = useAvailableVariablesInWorkflowStep(
{},
);
const noAvailableVariables = availableVariablesInWorkflowStep.length === 0;
if (noAvailableVariables) {
return (
<WorkflowStepBody>
<StyledDangerContainer>
{t`No Available Step Outputs`}
</StyledDangerContainer>
</WorkflowStepBody>
);
}
return (
<WorkflowStepFilterContext.Provider
value={{

View File

@ -67,6 +67,7 @@ export const WorkflowStepFilterFieldSelect = ({
stepOutputKey: variableName,
displayValue: variableLabel ?? '',
type: variableType ?? 'unknown',
value: '',
},
});
},
@ -85,10 +86,11 @@ export const WorkflowStepFilterFieldSelect = ({
const isSelectedFieldNotFound = !isDefined(variableLabel);
const label = isSelectedFieldNotFound ? t`No Field Selected` : variableLabel;
const dropdownId = `step-filter-field-${stepFilter.id}`;
return (
<WorkflowVariablesDropdown
instanceId={`step-filter-field-${stepFilter.id}`}
instanceId={dropdownId}
onVariableSelect={handleChange}
disabled={readonly}
clickableComponent={

View File

@ -1,15 +1,45 @@
import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
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 { 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 { StepFilter } from 'twenty-shared/src/types';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType, StepFilter } from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils';
import { JsonValue } from 'type-fest';
type WorkflowStepFilterValueInputProps = {
stepFilter: StepFilter;
};
const isFilterableFieldMetadataType = (
type: string,
): type is FieldMetadataType => {
return [
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.BOOLEAN,
FieldMetadataType.DATE_TIME,
FieldMetadataType.DATE,
FieldMetadataType.NUMERIC,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT_V2,
FieldMetadataType.ARRAY,
].includes(type as FieldMetadataType);
};
export const WorkflowStepFilterValueInput = ({
stepFilter,
}: WorkflowStepFilterValueInputProps) => {
@ -17,15 +47,76 @@ 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)
? value
: Array.isArray(value) || isObject(value)
? JSON.stringify(value)
: String(value);
const handleValueChange = (value: string) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
value,
value: valueToUpsert,
},
});
};
const { getFieldMetadataItemById } = useGetFieldMetadataItemById();
const isDisabled = !stepFilter.operand;
const operandHasNoInput =
(stepFilter && !configurableViewFilterOperands.has(stepFilter.operand)) ??
true;
if (isDisabled || operandHasNoInput) {
return null;
}
if (isDefined(variableType) && isFilterableFieldMetadataType(variableType)) {
const selectedFieldMetadataItem = isDefined(fieldMetadataId)
? getFieldMetadataItemById(fieldMetadataId)
: undefined;
const field = {
type: variableType as FieldMetadataType,
label: '',
metadata: {
fieldName: selectedFieldMetadataItem?.name ?? '',
options: selectedFieldMetadataItem?.options ?? [],
} as FieldMetadata,
};
return (
<FormFieldInput
field={field}
defaultValue={stepFilter.value}
onChange={handleValueChange}
readonly={readonly}
VariablePicker={WorkflowVariablePicker}
placeholder={t`Enter value`}
/>
);
}
return (
<FormTextFieldInput

View File

@ -38,7 +38,6 @@ export const FILTER_OPERANDS_MAP = {
],
DATE_TIME: [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
@ -48,7 +47,6 @@ export const FILTER_OPERANDS_MAP = {
],
DATE: [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,

View File

@ -18,7 +18,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { WorkflowAdvancedFilterValueFormCompositeFieldInput } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowAdvancedFilterValueFormCompositeFieldInput';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { isObject } from '@sniptt/guards';
import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@ -56,7 +56,7 @@ export const WorkflowAdvancedFilterValueFormInput = ({
useApplyObjectFilterDropdownFilterValue();
const handleChange = (newValue: JsonValue) => {
if (typeof newValue === 'string') {
if (isString(newValue)) {
applyObjectFilterDropdownFilterValue(newValue);
} else if (Array.isArray(newValue) || isObject(newValue)) {
applyObjectFilterDropdownFilterValue(JSON.stringify(newValue));

View File

@ -7,6 +7,7 @@ type Leaf = {
label?: string;
description?: string;
value: any;
fieldMetadataId?: string;
};
type Node = {
@ -16,6 +17,7 @@ type Node = {
label?: string;
value: OutputSchema;
description?: string;
fieldMetadataId?: string;
};
type Link = {
@ -28,7 +30,11 @@ type Link = {
export type BaseOutputSchema = Record<string, Leaf | Node>;
export type RecordOutputSchema = {
object: { nameSingular: string; fieldIdName: string } & Leaf;
object: {
nameSingular: string;
fieldIdName: string;
objectMetadataId: string;
} & Leaf;
fields: BaseOutputSchema;
_outputSchemaType: 'RECORD';
};

View File

@ -12,6 +12,7 @@ describe('filterOutputSchema', () => {
fieldIdName: 'id',
isLeaf: true,
value: 'Fake value',
objectMetadataId: '123',
},
fields: {},
};
@ -35,6 +36,7 @@ describe('filterOutputSchema', () => {
fieldIdName: 'id',
isLeaf: true,
value: 'Fake value',
objectMetadataId: '123',
},
fields,
});

View File

@ -16,6 +16,7 @@ const mockStep = {
label: 'Company',
value: 'John',
isLeaf: true,
objectMetadataId: '123',
},
fields: {
name: { label: 'Name', value: 'Twenty', isLeaf: true },

View File

@ -16,6 +16,7 @@ const mockStep = {
label: 'Company',
value: 'John',
isLeaf: true,
objectMetadataId: '123',
},
fields: {
name: { label: 'Name', value: 'Twenty', isLeaf: true },

View File

@ -19,6 +19,7 @@ describe('searchVariableThroughOutputSchema', () => {
label: 'Company',
value: 'John',
isLeaf: true,
objectMetadataId: '123',
},
fields: {
name: { label: 'Name', value: 'Twenty', isLeaf: true },
@ -38,6 +39,7 @@ describe('searchVariableThroughOutputSchema', () => {
label: 'Person',
value: 'Jane',
isLeaf: true,
objectMetadataId: '123',
},
fields: {
firstName: { label: 'First Name', value: 'Jane', isLeaf: true },
@ -270,6 +272,7 @@ describe('searchVariableThroughOutputSchema', () => {
isLeaf: true,
fieldIdName: 'properties.after.id',
nameSingular: 'company',
objectMetadataId: '123',
},
_outputSchemaType: 'RECORD',
},

View File

@ -43,6 +43,17 @@ const getVariableType = (key: string, outputSchema: OutputSchema): string => {
return outputSchema[key]?.type ?? 'unknown';
};
const getFieldMetadataId = (
key: string,
outputSchema: OutputSchema,
): string | undefined => {
if (isRecordOutputSchema(outputSchema)) {
return outputSchema.fields[key]?.fieldMetadataId;
}
return undefined;
};
const searchCurrentStepOutputSchema = ({
stepOutputSchema,
path,
@ -120,6 +131,10 @@ const searchCurrentStepOutputSchema = ({
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
fieldMetadataId: getFieldMetadataId(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
};
};
@ -160,7 +175,7 @@ export const searchVariableThroughOutputSchema = ({
};
}
const { variableLabel, variablePathLabel, variableType } =
const { variableLabel, variablePathLabel, variableType, fieldMetadataId } =
searchCurrentStepOutputSchema({
stepOutputSchema,
path,
@ -172,5 +187,6 @@ export const searchVariableThroughOutputSchema = ({
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType,
fieldMetadataId,
};
};