Use view filters operands in step filters + migrate to twenty-shared (#13137)

Step operand will more or less be the same as view filter operand. 

This PR:
- moves `ViewFilterOperand` to twenty-shared
- use it as step operand
- check what operand should be available based on the selected field
type in filter action
- rewrite the function that evaluates filters so it uses
ViewFilterOperand instead

ViewFilterOperand may be renamed in a future PR.
This commit is contained in:
Thomas Trompette
2025-07-10 10:36:37 +02:00
committed by GitHub
parent d808cbeed9
commit 50e402af07
55 changed files with 677 additions and 665 deletions

View File

@ -2,8 +2,8 @@ import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { AppPath } from '@/types/AppPath';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const SeeRunsWorkflowSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();

View File

@ -2,8 +2,8 @@ import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { AppPath } from '@/types/AppPath';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const SeeVersionsWorkflowSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();

View File

@ -3,9 +3,9 @@ import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { AppPath } from '@/types/AppPath';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useRecoilValue } from 'recoil';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const SeeRunsWorkflowVersionSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();

View File

@ -3,9 +3,9 @@ import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { AppPath } from '@/types/AppPath';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useRecoilValue } from 'recoil';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const SeeVersionsWorkflowVersionSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();

View File

@ -2,8 +2,8 @@ import { ContextStoreTargetedRecordsRule } from '@/context-store/states/contextS
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { expect } from '@storybook/test';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
describe('computeContextStoreFilters', () => {

View File

@ -14,7 +14,7 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { MenuItem } from 'twenty-ui/navigation';
type AdvancedFilterRecordFilterOperandSelectContentProps = {

View File

@ -5,7 +5,6 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object-
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue';
import {
resolveDateViewFilterValue,
@ -13,6 +12,7 @@ import {
VariableDateViewFilterValueUnit,
} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
import { useState } from 'react';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';

View File

@ -6,7 +6,7 @@ import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewBarFilterDropdownVectorSearchInput } from '@/views/components/ViewBarFilterDropdownVectorSearchInput';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';

View File

@ -1,6 +1,6 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const selectedOperandInDropdownComponentState =
createComponentStateV2<ViewFilterOperand | null>({

View File

@ -1,4 +1,4 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { capitalize } from 'twenty-shared/utils';
import { getOperandLabel, getOperandLabelShort } from '../getOperandLabel';

View File

@ -1,4 +1,4 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isFilterOperandExpectingValue } from '../isFilterOperandExpectingValue';

View File

@ -1,4 +1,4 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const configurableViewFilterOperands = new Set<ViewFilterOperand>([
ViewFilterOperand.Is,

View File

@ -1,5 +1,5 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { t } from '@lingui/core/macro';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const getOperandLabel = (
operand: ViewFilterOperand | null | undefined,

View File

@ -1,4 +1,4 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const isFilterOperandExpectingValue = (operand: ViewFilterOperand) => {
switch (operand) {

View File

@ -3,8 +3,8 @@ import { renderHook } from '@testing-library/react';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { act } from 'react';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useRemoveRecordFilter } from '../useRemoveRecordFilter';

View File

@ -4,7 +4,7 @@ import { act } from 'react';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useUpsertRecordFilter } from '../useUpsertRecordFilter';

View File

@ -1,7 +1,7 @@
import { FilterableAndTSVectorFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export type RecordFilter = {
id: string;

View File

@ -1 +1 @@
export { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand';
export { ViewFilterOperand as RecordFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';

View File

@ -3,7 +3,7 @@ import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';

View File

@ -19,8 +19,8 @@ import {
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { computeEmptyGqlOperationFilterForEmails } from '@/object-record/record-filter/utils/compute-empty-record-gql-operation-filter/for-composite-field/computeEmptyGqlOperationFilterForEmails';
import { computeEmptyGqlOperationFilterForLinks } from '@/object-record/record-filter/utils/compute-empty-record-gql-operation-filter/for-composite-field/computeEmptyGqlOperationFilterForLinks';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isNonEmptyString } from '@sniptt/guards';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';

View File

@ -5,7 +5,7 @@ import {
FilterableFieldType,
} from '@/object-record/record-filter/types/FilterableFieldType';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand as RecordFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';

View File

@ -9,8 +9,8 @@ import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useRecoilCallback } from 'recoil';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
type UseHandleToggleTrashColumnFilterProps = {

View File

@ -19,8 +19,8 @@ import { AppPath } from '@/types/AppPath';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useLingui } from '@lingui/react/macro';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { RelationType } from '~/generated-metadata/graphql';
import { getAppPath } from '~/utils/navigation/getAppPath';

View File

@ -1,8 +1,8 @@
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { RelationType } from '~/generated-metadata/graphql';
import { buildValueFromFilter } from './buildRecordInputFromFilter';

View File

@ -7,7 +7,7 @@ import {
RecordFilterToRecordInputOperand,
} from '@/object-record/record-filter/types/RecordFilter';
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { assertUnreachable, parseJson } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';

View File

@ -9,10 +9,10 @@ import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { View } from '@/views/types/View';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType } from '@/views/types/ViewType';
import { act } from 'react';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';

View File

@ -5,11 +5,11 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useApplyViewFiltersToCurrentRecordFilters } from '../useApplyViewFiltersToCurrentRecordFilters';
import { isDefined } from 'twenty-shared/utils';
const mockObjectMetadataItemNameSingular = 'company';

View File

@ -15,8 +15,8 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { relationFilterValueSchemaObject } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
const filterQueryParamsSchema = z.object({

View File

@ -1,7 +1,7 @@
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';

View File

@ -1,7 +1,7 @@
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const useOpenVectorSearchFilter = (filterDropdownId?: string) => {
const setSelectedOperandInDropdown = useSetRecoilComponentStateV2(

View File

@ -3,7 +3,7 @@ import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRe
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { useVectorSearchFieldInRecordIndexContextOrThrow } from '@/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { useVectorSearchFilterState } from './useVectorSearchFilterState';

View File

@ -1,5 +1,5 @@
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand } from './ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export type ViewFilter = {
__typename: 'ViewFilter';

View File

@ -1,5 +1,5 @@
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { areViewFiltersEqual } from '../areViewFiltersEqual';
describe('areViewFiltersEqual', () => {

View File

@ -1,5 +1,5 @@
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { getViewFiltersToCreate } from '../getViewFiltersToCreate';
describe('getViewFiltersToCreate', () => {

View File

@ -1,5 +1,5 @@
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { getViewFiltersToDelete } from '../getViewFiltersToDelete';
describe('getViewFiltersToDelete', () => {

View File

@ -1,5 +1,5 @@
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { getViewFiltersToUpdate } from '../getViewFiltersToUpdate';
describe('getViewFiltersToUpdate', () => {

View File

@ -4,12 +4,12 @@ import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewSort } from '@/views/types/ViewSort';
import { mapColumnDefinitionsToViewFields } from '@/views/utils/mapColumnDefinitionToViewField';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { FieldMetadataType } from '~/generated-metadata/graphql';

View File

@ -1,5 +1,5 @@
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
export const isVectorSearchFilter = (filter: RecordFilter) => {
return filter.operand === ViewFilterOperand.VectorSearch;

View File

@ -1,5 +1,4 @@
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import {
addDays,
addMonths,
@ -19,6 +18,7 @@ import {
subWeeks,
subYears,
} from 'date-fns';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { z } from 'zod';

View File

@ -10,7 +10,7 @@ import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
ViewFilterOperand,
} from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLibraryPlus, IconPlus } from 'twenty-ui/display';
@ -21,6 +21,17 @@ type WorkflowStepFilterAddFilterRuleSelectProps = {
stepFilterGroup: StepFilterGroup;
};
const BASE_NEW_STEP_FILTER = {
id: v4(),
type: 'unknown',
label: '',
value: '',
operand: ViewFilterOperand.Is,
displayValue: '',
stepFilterGroupId: '',
stepOutputKey: '',
};
export const WorkflowStepFilterAddFilterRuleSelect = ({
stepFilterGroup,
}: WorkflowStepFilterAddFilterRuleSelectProps) => {
@ -42,16 +53,10 @@ export const WorkflowStepFilterAddFilterRuleSelect = ({
closeDropdown(dropdownId);
const newStepFilter = {
id: v4(),
type: 'text',
label: 'New Filter',
value: '',
operand: StepOperand.EQ,
displayValue: '',
...BASE_NEW_STEP_FILTER,
stepFilterGroupId: stepFilterGroup.id,
stepOutputKey: '',
positionInStepFilterGroup: newPositionInStepFilterGroup,
};
} satisfies StepFilter;
upsertStepFilterSettings({
stepFilterToUpsert: newStepFilter,
@ -71,15 +76,9 @@ export const WorkflowStepFilterAddFilterRuleSelect = ({
};
const newStepFilter: StepFilter = {
id: v4(),
type: 'text',
operand: StepOperand.EQ,
value: '',
displayValue: '',
...BASE_NEW_STEP_FILTER,
stepFilterGroupId: newStepFilterGroupId,
positionInStepFilterGroup: 1,
label: 'New Filter',
stepOutputKey: '',
};
upsertStepFilterSettings({

View File

@ -8,7 +8,7 @@ import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { StepFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@ -30,6 +30,7 @@ export const WorkflowStepFilterFieldSelect = ({
rawVariableName: stepFilter.stepOutputKey,
part: 'stepId',
});
const stepsOutputSchema = useRecoilValue(
stepsOutputSchemaFamilySelector({
workflowVersionId,
@ -37,6 +38,41 @@ export const WorkflowStepFilterFieldSelect = ({
}),
);
const handleChange = useRecoilCallback(
({ snapshot }) =>
(variableName: string) => {
const stepId = extractRawVariableNamePart({
rawVariableName: variableName,
part: 'stepId',
});
const currentStepOutputSchema = snapshot
.getLoadable(
stepsOutputSchemaFamilySelector({
workflowVersionId,
stepIds: [stepId],
}),
)
.getValue();
const { variableLabel, variableType } =
searchVariableThroughOutputSchema({
stepOutputSchema: currentStepOutputSchema?.[0],
rawVariableName: variableName,
isFullRecord: false,
});
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
stepOutputKey: variableName,
displayValue: variableLabel ?? '',
type: variableType ?? 'unknown',
},
});
},
[upsertStepFilterSettings, stepFilter, workflowVersionId],
);
if (!isDefined(stepId)) {
return null;
}
@ -50,16 +86,6 @@ export const WorkflowStepFilterFieldSelect = ({
const isSelectedFieldNotFound = !isDefined(variableLabel);
const label = isSelectedFieldNotFound ? t`No Field Selected` : variableLabel;
const handleChange = (variableName: string) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
stepOutputKey: variableName,
displayValue: label,
},
});
};
return (
<WorkflowVariablesDropdown
instanceId={`step-filter-field-${stepFilter.id}`}

View File

@ -1,36 +1,31 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
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 { getViewFilterOperands } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands';
import { useContext } from 'react';
import { StepFilter, StepOperand } from 'twenty-shared/src/types';
import { StepFilter, ViewFilterOperand } from 'twenty-shared/src/types';
type WorkflowStepFilterOperandSelectProps = {
stepFilter: StepFilter;
};
const STEP_OPERAND_OPTIONS = [
{ value: StepOperand.EQ, label: 'Equals' },
{ value: StepOperand.NE, label: 'Not equals' },
{ value: StepOperand.GT, label: 'Greater than' },
{ value: StepOperand.GTE, label: 'Greater than or equal' },
{ value: StepOperand.LT, label: 'Less than' },
{ value: StepOperand.LTE, label: 'Less than or equal' },
{ value: StepOperand.LIKE, label: 'Contains' },
{ value: StepOperand.ILIKE, label: 'Contains (case insensitive)' },
{ value: StepOperand.IN, label: 'In' },
{ value: StepOperand.IS, label: 'Is' },
];
export const WorkflowStepFilterOperandSelect = ({
stepFilter,
}: WorkflowStepFilterOperandSelectProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const operands = getViewFilterOperands({ filterType: stepFilter.type });
const handleChange = (operand: StepOperand) => {
const options = operands.map((operand) => ({
value: operand,
label: getOperandLabel(operand),
}));
const handleChange = (operand: ViewFilterOperand) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
@ -45,7 +40,7 @@ export const WorkflowStepFilterOperandSelect = ({
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownId={`step-filter-operand-${stepFilter.id}`}
value={stepFilter.operand}
options={STEP_OPERAND_OPTIONS}
options={options}
onChange={handleChange}
disabled={readonly}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}

View File

@ -4,7 +4,7 @@ import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
ViewFilterOperand,
} from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
@ -28,7 +28,7 @@ const TEXT_STEP_FILTER: StepFilter = {
type: 'text',
label: 'Company Name',
value: 'Acme',
operand: StepOperand.LIKE,
operand: ViewFilterOperand.Contains,
};
const meta: Meta<typeof WorkflowStepFilterColumn> = {

View File

@ -1,6 +1,6 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { StepFilter, ViewFilterOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
@ -16,7 +16,7 @@ const DEFAULT_STEP_FILTER: StepFilter = {
displayValue: '',
type: 'text',
label: 'New Filter',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
value: '',
positionInStepFilterGroup: 0,
};

View File

@ -1,14 +1,14 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { WorkflowStepFilterOperandSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { StepFilter, ViewFilterOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterOperandSelect } from '../WorkflowStepFilterOperandSelect';
const DEFAULT_STEP_FILTER: StepFilter = {
id: 'filter-1',
@ -17,23 +17,11 @@ const DEFAULT_STEP_FILTER: StepFilter = {
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Contains,
value: '',
positionInStepFilterGroup: 0,
};
const LIKE_OPERAND_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.LIKE,
value: 'Acme',
positionInStepFilterGroup: 0,
};
const GREATER_THAN_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
@ -41,7 +29,7 @@ const GREATER_THAN_FILTER: StepFilter = {
displayValue: 'Employee Count',
type: 'number',
label: 'Employee Count',
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
value: '100',
positionInStepFilterGroup: 0,
};
@ -69,17 +57,6 @@ export default meta;
type Story = StoryObj<typeof WorkflowStepFilterOperandSelect>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Equals')).toBeVisible();
},
};
export const WithLikeOperand: Story = {
args: {
stepFilter: LIKE_OPERAND_FILTER,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@ -94,6 +71,8 @@ export const WithGreaterThanOperand: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Greater than')).toBeVisible();
await expect(
await canvas.findByText('Greater than or equal'),
).toBeVisible();
},
};

View File

@ -1,7 +1,7 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { StepFilter, ViewFilterOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
@ -17,7 +17,7 @@ const TEXT_FILTER: StepFilter = {
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.LIKE,
operand: ViewFilterOperand.Contains,
value: 'Acme',
positionInStepFilterGroup: 0,
};
@ -29,7 +29,7 @@ const NUMBER_FILTER: StepFilter = {
displayValue: 'Employee Count',
type: 'number',
label: 'Employee Count',
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
value: '100',
positionInStepFilterGroup: 0,
};

View File

@ -11,7 +11,7 @@ import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
ViewFilterOperand,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
@ -48,10 +48,10 @@ export const useAddRootStepFilter = () => {
const newStepFilter: StepFilter = {
id: v4(),
type: 'text',
type: 'unknown',
label: 'New Filter',
value: '',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: '',
stepFilterGroupId: newStepFilterGroup.id,
stepOutputKey: '',

View File

@ -0,0 +1,115 @@
import { ViewFilterOperand } from 'twenty-shared/src/types';
const emptyOperands = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
] as const;
const relationOperands = [
ViewFilterOperand.Is,
ViewFilterOperand.IsNot,
] as const;
const defaultOperands = [
ViewFilterOperand.Is,
ViewFilterOperand.IsNot,
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
ViewFilterOperand.GreaterThanOrEqual,
ViewFilterOperand.LessThanOrEqual,
...emptyOperands,
] as const;
export const FILTER_OPERANDS_MAP = {
TEXT: [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
],
NUMBER: [
ViewFilterOperand.GreaterThanOrEqual,
ViewFilterOperand.LessThanOrEqual,
...emptyOperands,
],
RAW_JSON: [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
],
DATE_TIME: [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
...emptyOperands,
],
DATE: [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
...emptyOperands,
],
RATING: [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands],
RELATION: [...relationOperands, ...emptyOperands],
MULTI_SELECT: [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
],
SELECT: [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands],
ARRAY: [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
],
BOOLEAN: [ViewFilterOperand.Is],
UUID: [ViewFilterOperand.Is],
NUMERIC: [
ViewFilterOperand.GreaterThanOrEqual,
ViewFilterOperand.LessThanOrEqual,
...emptyOperands,
],
};
export const getViewFilterOperands = ({
filterType,
}: {
filterType: string;
}): readonly ViewFilterOperand[] => {
switch (filterType) {
case 'TEXT':
return FILTER_OPERANDS_MAP.TEXT;
case 'NUMBER':
return FILTER_OPERANDS_MAP.NUMBER;
case 'RAW_JSON':
return FILTER_OPERANDS_MAP.RAW_JSON;
case 'DATE_TIME':
case 'DATE':
return FILTER_OPERANDS_MAP.DATE_TIME;
case 'RATING':
return FILTER_OPERANDS_MAP.RATING;
case 'RELATION':
return FILTER_OPERANDS_MAP.RELATION;
case 'MULTI_SELECT':
return FILTER_OPERANDS_MAP.MULTI_SELECT;
case 'SELECT':
return FILTER_OPERANDS_MAP.SELECT;
case 'ARRAY':
return FILTER_OPERANDS_MAP.ARRAY;
case 'BOOLEAN':
return FILTER_OPERANDS_MAP.BOOLEAN;
case 'UUID':
return FILTER_OPERANDS_MAP.UUID;
case 'NUMERIC':
return FILTER_OPERANDS_MAP.NUMERIC;
default:
return defaultOperands;
}
};

View File

@ -76,6 +76,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Step 1 > undefined',
variableType: 'unknown',
});
});
@ -89,6 +90,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Name',
variablePathLabel: 'Step 1 > Company > Name',
variableType: 'unknown',
});
});
@ -102,6 +104,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Email',
variablePathLabel: 'Step 1 > Person > Email',
variableType: 'unknown',
});
});
@ -115,6 +118,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Company',
variablePathLabel: 'Step 1 > Company > Company',
variableType: 'unknown',
});
});
@ -128,6 +132,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Person',
variablePathLabel: 'Step 1 > Person > Person',
variableType: 'unknown',
});
});
@ -141,6 +146,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Simple Data',
variablePathLabel: 'Step 1 > Simple Data',
variableType: 'unknown',
});
});
@ -154,6 +160,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Field 1',
variablePathLabel: 'Step 1 > Nested Data > Field 1',
variableType: 'unknown',
});
});
@ -167,6 +174,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Step 1 > undefined',
variableType: 'unknown',
});
});
@ -180,6 +188,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Step 1 > undefined',
variableType: 'unknown',
});
});
@ -208,6 +217,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Field 1',
variablePathLabel: 'Step 1 > Complex Field > Field 1',
variableType: 'unknown',
});
});
});
@ -274,6 +284,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Name',
variablePathLabel: 'Record is Created > Name',
variableType: FieldMetadataType.TEXT,
});
});
@ -288,6 +299,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: ' Amount Micros',
variablePathLabel: 'Record is Created > ARR > Amount Micros',
variableType: FieldMetadataType.NUMERIC,
});
});
@ -301,6 +313,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: 'Company',
variablePathLabel: 'Record is Created > Company',
variableType: 'unknown',
});
});
@ -314,6 +327,7 @@ describe('searchVariableThroughOutputSchema', () => {
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Record is Created > undefined',
variableType: 'unknown',
});
});
});

View File

@ -4,6 +4,7 @@ import {
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
@ -30,6 +31,18 @@ const getDisplayedSubStepFieldLabel = (
return;
};
const getVariableType = (key: string, outputSchema: OutputSchema): string => {
if (isRecordOutputSchema(outputSchema)) {
return outputSchema.fields[key]?.type ?? 'unknown';
}
if (isLinkOutputSchema(outputSchema)) {
return 'unknown';
}
return outputSchema[key]?.type ?? 'unknown';
};
const searchCurrentStepOutputSchema = ({
stepOutputSchema,
path,
@ -90,6 +103,7 @@ const searchCurrentStepOutputSchema = ({
return {
variableLabel: undefined,
variablePathLabel: undefined,
variableType: undefined,
};
}
@ -102,6 +116,10 @@ const searchCurrentStepOutputSchema = ({
currentSubStep,
),
variablePathLabel,
variableType: getVariableType(
isSelectedFieldInNextKey ? nextKey : selectedField,
currentSubStep,
),
};
};
@ -142,15 +160,17 @@ export const searchVariableThroughOutputSchema = ({
};
}
const { variableLabel, variablePathLabel } = searchCurrentStepOutputSchema({
stepOutputSchema,
path,
isFullRecord,
selectedField,
});
const { variableLabel, variablePathLabel, variableType } =
searchCurrentStepOutputSchema({
stepOutputSchema,
path,
isFullRecord,
selectedField,
});
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
variableType,
};
};

View File

@ -2,12 +2,32 @@ import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
ViewFilterOperand,
} from 'twenty-shared/types';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
describe('evaluateFilterConditions', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
const createFilter = (
operand: ViewFilterOperand,
leftOperand: unknown,
rightOperand: unknown,
): ResolvedFilter => ({
id: 'filter1',
type: 'text',
label: 'Test Filter',
rightOperand,
operand,
displayValue: String(rightOperand),
stepFilterGroupId: 'group1',
leftOperand,
});
describe('empty inputs', () => {
it('should return true when no filters or groups are provided', () => {
const result = evaluateFilterConditions({
@ -26,190 +46,32 @@ describe('evaluateFilterConditions', () => {
});
describe('single filter operands', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
const createFilter = (
operand: StepOperand,
leftOperand: unknown,
rightOperand: unknown,
): ResolvedFilter => ({
id: 'filter1',
type: 'text',
label: 'Test Filter',
rightOperand,
operand,
displayValue: String(rightOperand),
stepFilterGroupId: 'group1',
leftOperand,
});
describe('eq operand', () => {
describe('Is operand', () => {
it('should return true when values are equal', () => {
const filter = createFilter(StepOperand.EQ, 'John', 'John');
const filter = createFilter(ViewFilterOperand.Is, 'John', 'John');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
it('should return true when values are loosely equal', () => {
const filter = createFilter(StepOperand.EQ, '123', 123);
const filter = createFilter(ViewFilterOperand.Is, '123', 123);
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
it('should return false when values are not equal', () => {
const filter = createFilter(StepOperand.EQ, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(false);
});
});
describe('ne operand', () => {
it('should return false when values are equal', () => {
const filter = createFilter(StepOperand.NE, 'John', 'John');
const filter = createFilter(ViewFilterOperand.Is, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(false);
});
it('should return true when values are not equal', () => {
const filter = createFilter(StepOperand.NE, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
});
describe('numeric operands', () => {
it('should handle gt operand correctly', () => {
const filter1 = createFilter(StepOperand.GT, 30, 25);
const filter2 = createFilter(StepOperand.GT, 20, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle gte operand correctly', () => {
const filter1 = createFilter(StepOperand.GTE, 25, 25);
const filter2 = createFilter(StepOperand.GTE, 30, 25);
const filter3 = createFilter(StepOperand.GTE, 20, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
it('should handle lt operand correctly', () => {
const filter1 = createFilter(StepOperand.LT, 20, 25);
const filter2 = createFilter(StepOperand.LT, 30, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle lte operand correctly', () => {
const filter1 = createFilter(StepOperand.LTE, 25, 25);
const filter2 = createFilter(StepOperand.LTE, 20, 25);
const filter3 = createFilter(StepOperand.LTE, 30, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
it('should convert string numbers for numeric comparisons', () => {
const filter1 = createFilter(StepOperand.GT, '30', '25');
const filter2 = createFilter(StepOperand.LT, '20', '25');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
});
});
describe('string operands', () => {
it('should handle like operand correctly', () => {
const filter1 = createFilter(StepOperand.LIKE, 'Hello World', 'World');
const filter2 = createFilter(StepOperand.LIKE, 'Hello', 'World');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle ilike operand correctly (case insensitive)', () => {
const filter1 = createFilter(StepOperand.ILIKE, 'Hello World', 'WORLD');
const filter2 = createFilter(StepOperand.ILIKE, 'Hello World', 'world');
const filter3 = createFilter(StepOperand.ILIKE, 'Hello', 'WORLD');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
});
describe('in operand', () => {
it('should handle JSON array values', () => {
const filter1 = createFilter(
StepOperand.IN,
'apple',
'["apple", "banana", "cherry"]',
);
const filter2 = createFilter(
StepOperand.IN,
'grape',
'["apple", "banana", "cherry"]',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle comma-separated string values when JSON parsing fails', () => {
const filter1 = createFilter(
StepOperand.IN,
'apple',
'apple, banana, cherry',
);
const filter2 = createFilter(
StepOperand.IN,
'grape',
'apple, banana, cherry',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle comma-separated values with whitespace', () => {
const filter = createFilter(
StepOperand.IN,
'apple',
' apple , banana , cherry ',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
});
it('should return false for non-array JSON values', () => {
const filter = createFilter(
StepOperand.IN,
'apple',
'{"key": "value"}',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
});
});
describe('is operand', () => {
it('should handle null checks', () => {
const filter1 = createFilter(StepOperand.IS, null, 'null');
const filter2 = createFilter(StepOperand.IS, undefined, 'NULL');
const filter3 = createFilter(StepOperand.IS, 'value', 'null');
const filter1 = createFilter(ViewFilterOperand.Is, null, 'null');
const filter2 = createFilter(ViewFilterOperand.Is, undefined, 'NULL');
const filter3 = createFilter(ViewFilterOperand.Is, 'value', 'null');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -217,43 +79,213 @@ describe('evaluateFilterConditions', () => {
});
it('should handle not null checks', () => {
const filter1 = createFilter(StepOperand.IS, 'value', 'not null');
const filter2 = createFilter(StepOperand.IS, 'value', 'NOT NULL');
const filter3 = createFilter(StepOperand.IS, null, 'not null');
const filter4 = createFilter(StepOperand.IS, undefined, 'not null');
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);
});
});
it('should handle exact value comparisons for non-null/not-null cases', () => {
const filter1 = createFilter(StepOperand.IS, 'exact', 'exact');
const filter2 = createFilter(StepOperand.IS, 'value', 'different');
describe('IsNot operand', () => {
it('should return false when values are equal', () => {
const filter = createFilter(ViewFilterOperand.IsNot, 'John', 'John');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(false);
});
it('should return true when values are not equal', () => {
const filter = createFilter(ViewFilterOperand.IsNot, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
});
describe('numeric operands', () => {
it('should handle GreaterThanOrEqual operand correctly', () => {
const filter1 = createFilter(
ViewFilterOperand.GreaterThanOrEqual,
25,
25,
);
const filter2 = createFilter(
ViewFilterOperand.GreaterThanOrEqual,
30,
25,
);
const filter3 = createFilter(
ViewFilterOperand.GreaterThanOrEqual,
20,
25,
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
it('should handle LessThanOrEqual operand correctly', () => {
const filter1 = createFilter(ViewFilterOperand.LessThanOrEqual, 25, 25);
const filter2 = createFilter(ViewFilterOperand.LessThanOrEqual, 20, 25);
const filter3 = createFilter(ViewFilterOperand.LessThanOrEqual, 30, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
});
});
describe('string and array operands', () => {
it('should handle Contains operand with strings', () => {
const filter1 = createFilter(
ViewFilterOperand.Contains,
'Hello World',
'World',
);
const filter2 = createFilter(
ViewFilterOperand.Contains,
'Hello',
'World',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle DoesNotContain operand with strings', () => {
const filter1 = createFilter(
ViewFilterOperand.DoesNotContain,
'Hello World',
'World',
);
const filter2 = createFilter(
ViewFilterOperand.DoesNotContain,
'Hello',
'World',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
});
it('should handle Contains operand with arrays', () => {
const filter1 = createFilter(
ViewFilterOperand.Contains,
['apple', 'banana', 'cherry'],
'apple',
);
const filter2 = createFilter(
ViewFilterOperand.Contains,
['apple', 'banana', 'cherry'],
'grape',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle DoesNotContain operand with arrays', () => {
const filter1 = createFilter(
ViewFilterOperand.DoesNotContain,
['apple', 'banana', 'cherry'],
'apple',
);
const filter2 = createFilter(
ViewFilterOperand.DoesNotContain,
['apple', 'banana', 'cherry'],
'grape',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
});
});
describe('empty operands', () => {
it('should handle IsEmpty operand correctly', () => {
const filter1 = createFilter(ViewFilterOperand.IsEmpty, null, '');
const filter2 = createFilter(ViewFilterOperand.IsEmpty, undefined, '');
const filter3 = createFilter(ViewFilterOperand.IsEmpty, '', '');
const filter4 = createFilter(ViewFilterOperand.IsEmpty, [], '');
const filter5 = createFilter(
ViewFilterOperand.IsEmpty,
'not empty',
'',
);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter4] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter5] })).toBe(false);
});
it('should handle IsNotEmpty operand correctly', () => {
const filter1 = createFilter(
ViewFilterOperand.IsNotEmpty,
'not empty',
'',
);
const filter2 = createFilter(
ViewFilterOperand.IsNotEmpty,
['item'],
'',
);
const filter3 = createFilter(ViewFilterOperand.IsNotEmpty, null, '');
const filter4 = createFilter(ViewFilterOperand.IsNotEmpty, '', '');
const filter5 = createFilter(ViewFilterOperand.IsNotEmpty, [], '');
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(evaluateFilterConditions({ filters: [filter5] })).toBe(false);
});
});
describe('date operands', () => {
it('should handle date operands (returning false as placeholder)', () => {
const dateOperands = [
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
];
dateOperands.forEach((operand) => {
const filter = createFilter(operand, new Date(), new Date());
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
});
});
});
describe('error cases', () => {
it('should throw error for unknown operand', () => {
const filter = createFilter('unknown' as StepOperand, 'value', 'value');
expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow(
'Unknown operand: unknown',
const filter = createFilter(
'unknown' as ViewFilterOperand,
'value',
'value',
);
expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow();
});
});
});
describe('multiple filters without groups', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
it('should apply AND logic by default for multiple filters', () => {
const filters: ResolvedFilter[] = [
{
@ -261,7 +293,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John',
@ -271,7 +303,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 30,
@ -290,7 +322,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John',
@ -300,7 +332,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 20, // This will fail
@ -314,11 +346,6 @@ describe('evaluateFilterConditions', () => {
});
describe('filter groups', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
describe('single group with AND logic', () => {
it('should return true when all filters pass', () => {
const filterGroups: StepFilterGroup[] = [
@ -334,7 +361,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John',
@ -344,7 +371,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 30,
@ -370,7 +397,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
@ -380,7 +407,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 30,
@ -408,7 +435,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
@ -418,7 +445,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 30, // This will pass
@ -444,7 +471,7 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
@ -454,7 +481,7 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group1',
leftOperand: 20, // This will fail
@ -467,8 +494,110 @@ describe('evaluateFilterConditions', () => {
});
});
describe('multiple groups', () => {
it('should handle multiple root groups with AND logic between them', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'group2',
logicalOperator: StepLogicalOperator.OR,
},
];
const filters: ResolvedFilter[] = [
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
{
id: 'filter2',
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'group2',
leftOperand: 30,
},
];
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(true);
});
});
describe('nested groups', () => {
it('should handle nested filter groups correctly', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'root',
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'child1',
logicalOperator: StepLogicalOperator.OR,
parentStepFilterGroupId: 'root',
positionInStepFilterGroup: 1,
},
{
id: 'child2',
logicalOperator: StepLogicalOperator.AND,
parentStepFilterGroupId: 'root',
positionInStepFilterGroup: 2,
},
];
const filters: ResolvedFilter[] = [
{
id: 'filter1',
type: 'text',
label: 'Filter 1',
rightOperand: 'John',
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'child1',
leftOperand: 'Jane', // This will fail
},
{
id: 'filter2',
type: 'text',
label: 'Filter 2',
rightOperand: 'Smith',
operand: ViewFilterOperand.Is,
displayValue: 'Smith',
stepFilterGroupId: 'child1',
leftOperand: 'Smith', // This will pass (OR group passes)
},
{
id: 'filter3',
type: 'number',
label: 'Filter 3',
rightOperand: 25,
operand: ViewFilterOperand.GreaterThanOrEqual,
displayValue: '25',
stepFilterGroupId: 'child2',
leftOperand: 30, // This will pass (AND group passes)
},
];
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(true); // child1 (OR) passes, child2 (AND) passes, root (AND) passes
});
});
describe('empty groups', () => {
it('should return true for empty group', () => {
it('should return true for empty filter groups', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
@ -482,254 +611,8 @@ describe('evaluateFilterConditions', () => {
});
});
describe('nested groups', () => {
it('should handle nested groups correctly', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'group2',
logicalOperator: StepLogicalOperator.OR,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 1,
},
];
const filters: ResolvedFilter[] = [
// Filter in parent group
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
// Filters in child group with OR logic
{
id: 'filter2',
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: StepOperand.GT,
displayValue: '25',
stepFilterGroupId: 'group2',
leftOperand: 20, // This will fail
},
{
id: 'filter3',
type: 'text',
label: 'City Filter',
rightOperand: 'NYC',
operand: StepOperand.EQ,
displayValue: 'NYC',
stepFilterGroupId: 'group2',
leftOperand: 'NYC', // This will pass
},
];
// Parent group uses AND: filter1 (pass) AND group2 (pass because filter3 passes)
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(true);
});
it('should handle multiple child groups with correct positioning', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'group2',
logicalOperator: StepLogicalOperator.OR,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 1,
},
{
id: 'group3',
logicalOperator: StepLogicalOperator.AND,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 2,
},
];
const filters: ResolvedFilter[] = [
// Group2 filters (OR logic)
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
displayValue: 'John',
stepFilterGroupId: 'group2',
leftOperand: 'Jane', // This will fail
},
{
id: 'filter2',
type: 'text',
label: 'Status Filter',
rightOperand: 'active',
operand: StepOperand.EQ,
displayValue: 'active',
stepFilterGroupId: 'group2',
leftOperand: 'active', // This will pass
},
// Group3 filters (AND logic)
{
id: 'filter3',
type: 'number',
label: 'Age Filter',
rightOperand: 18,
operand: StepOperand.GTE,
displayValue: '18',
stepFilterGroupId: 'group3',
leftOperand: 25, // This will pass
},
{
id: 'filter4',
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: StepOperand.GT,
displayValue: '80',
stepFilterGroupId: 'group3',
leftOperand: 85, // This will pass
},
];
// Group1 uses AND: group2 (pass) AND group3 (pass)
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(true);
});
});
describe('multiple root groups', () => {
it('should combine multiple root groups with AND logic', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: StepLogicalOperator.OR,
},
{
id: 'group2',
logicalOperator: StepLogicalOperator.AND,
},
];
const filters: ResolvedFilter[] = [
// Group1 filters (OR logic)
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
{
id: 'filter2',
type: 'text',
label: 'Status Filter',
rightOperand: 'inactive',
operand: StepOperand.EQ,
displayValue: 'inactive',
stepFilterGroupId: 'group1',
leftOperand: 'active', // This will fail
},
// Group2 filters (AND logic)
{
id: 'filter3',
type: 'number',
label: 'Age Filter',
rightOperand: 18,
operand: StepOperand.GTE,
displayValue: '18',
stepFilterGroupId: 'group2',
leftOperand: 25, // This will pass
},
{
id: 'filter4',
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: StepOperand.GT,
displayValue: '80',
stepFilterGroupId: 'group2',
leftOperand: 85, // This will pass
},
];
// Root groups combined with AND: group1 (pass) AND group2 (pass)
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(true);
});
it('should return false when one root group fails', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: StepLogicalOperator.OR,
},
{
id: 'group2',
logicalOperator: StepLogicalOperator.AND,
},
];
const filters: ResolvedFilter[] = [
// Group1 filters (OR logic) - will pass
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
// Group2 filters (AND logic) - will fail
{
id: 'filter2',
type: 'number',
label: 'Age Filter',
rightOperand: 30,
operand: StepOperand.GT,
displayValue: '30',
stepFilterGroupId: 'group2',
leftOperand: 25, // This will fail
},
{
id: 'filter3',
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: StepOperand.GT,
displayValue: '80',
stepFilterGroupId: 'group2',
leftOperand: 85, // This will pass
},
];
// Root groups combined with AND: group1 (pass) AND group2 (fail)
const result = evaluateFilterConditions({ filterGroups, filters });
expect(result).toBe(false);
});
});
describe('error cases', () => {
it('should throw error when filter group is not found', () => {
it('should throw error when filter references non-existent group', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
@ -743,42 +626,16 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
operand: ViewFilterOperand.Is,
displayValue: 'John',
stepFilterGroupId: 'nonexistent-group',
stepFilterGroupId: 'nonexistent',
leftOperand: 'John',
},
];
expect(() =>
evaluateFilterConditions({ filterGroups, filters }),
).toThrow('Filter group with id nonexistent-group not found');
});
it('should throw error for unknown logical operator', () => {
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: 'UNKNOWN' as StepLogicalOperator,
},
];
const filters: ResolvedFilter[] = [
{
id: 'filter1',
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: StepOperand.EQ,
displayValue: 'John',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
];
expect(() =>
evaluateFilterConditions({ filterGroups, filters }),
).toThrow('Unknown logical operator: UNKNOWN');
).toThrow('Filter group with id nonexistent not found');
});
});
});

View File

@ -1,4 +1,9 @@
import { StepFilter, StepFilterGroup } from 'twenty-shared/types';
import {
StepFilter,
StepFilterGroup,
ViewFilterOperand,
} from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
@ -10,46 +15,7 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
const rightValue = filter.rightOperand;
switch (filter.operand) {
case 'eq':
return leftValue == rightValue;
case 'ne':
return leftValue != rightValue;
case 'gt':
return Number(leftValue) > Number(rightValue);
case 'gte':
return Number(leftValue) >= Number(rightValue);
case 'lt':
return Number(leftValue) < Number(rightValue);
case 'lte':
return Number(leftValue) <= Number(rightValue);
case 'like':
return String(leftValue).includes(String(rightValue));
case 'ilike':
return String(leftValue)
.toLowerCase()
.includes(String(rightValue).toLowerCase());
case 'in':
try {
const values = JSON.parse(String(rightValue));
return Array.isArray(values) && values.includes(leftValue);
} catch {
const values = String(rightValue)
.split(',')
.map((v) => v.trim());
return values.includes(String(leftValue));
}
case 'is':
case ViewFilterOperand.Is:
if (String(rightValue).toLowerCase() === 'null') {
return leftValue === null || leftValue === undefined;
}
@ -57,16 +23,68 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
return leftValue !== null && leftValue !== undefined;
}
return leftValue === rightValue;
return leftValue == rightValue;
case ViewFilterOperand.IsNot:
return leftValue != rightValue;
case ViewFilterOperand.GreaterThanOrEqual:
return Number(leftValue) >= Number(rightValue);
case ViewFilterOperand.LessThanOrEqual:
return Number(leftValue) <= Number(rightValue);
case ViewFilterOperand.Contains:
if (Array.isArray(leftValue)) {
return leftValue.includes(rightValue);
}
return String(leftValue).includes(String(rightValue));
case ViewFilterOperand.DoesNotContain:
if (Array.isArray(leftValue)) {
return !leftValue.includes(rightValue);
}
return !String(leftValue).includes(String(rightValue));
case ViewFilterOperand.IsEmpty:
return (
leftValue === null ||
leftValue === undefined ||
leftValue === '' ||
(Array.isArray(leftValue) && leftValue.length === 0)
);
case ViewFilterOperand.IsNotEmpty:
return (
leftValue !== null &&
leftValue !== undefined &&
leftValue !== '' &&
(!Array.isArray(leftValue) || leftValue.length > 0)
);
case ViewFilterOperand.IsNotNull:
return leftValue !== null && leftValue !== undefined;
case ViewFilterOperand.IsRelative:
case ViewFilterOperand.IsInPast:
case ViewFilterOperand.IsInFuture:
case ViewFilterOperand.IsToday:
case ViewFilterOperand.IsBefore:
case ViewFilterOperand.IsAfter:
// Date/time operands - for now, return false as placeholder
// These would need proper date logic implementation
return false;
case ViewFilterOperand.VectorSearch:
return false;
default:
throw new Error(`Unknown operand: ${filter.operand}`);
assertUnreachable(filter.operand);
}
}
/**
* Recursively evaluates a filter group and its children
*/
function evaluateFilterGroup(
groupId: string,
filterGroups: StepFilterGroup[],
@ -78,7 +96,6 @@ function evaluateFilterGroup(
throw new Error(`Filter group with id ${groupId} not found`);
}
// Get all direct child groups
const childGroups = filterGroups
.filter((g) => g.parentStepFilterGroupId === groupId)
.sort(

View File

@ -1,21 +1,10 @@
import { ViewFilterOperand } from './ViewFilterOperand';
export enum StepLogicalOperator {
AND = 'AND',
OR = 'OR',
}
export enum StepOperand {
EQ = 'eq',
NE = 'ne',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
LIKE = 'like',
ILIKE = 'ilike',
IN = 'in',
IS = 'is',
}
export type StepFilterGroup = {
id: string;
logicalOperator: StepLogicalOperator;
@ -28,7 +17,7 @@ export type StepFilter = {
type: string;
label: string;
value: string;
operand: StepOperand;
operand: ViewFilterOperand;
displayValue: string;
stepFilterGroupId: string;
stepOutputKey: string;

View File

@ -14,4 +14,5 @@ export type { IsExactly } from './IsExactly';
export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions';
export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId';
export type { StepFilterGroup, StepFilter } from './StepFilters';
export { StepLogicalOperator, StepOperand } from './StepFilters';
export { StepLogicalOperator } from './StepFilters';
export { ViewFilterOperand } from './ViewFilterOperand';