diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md index ab4e58f3ab..0361e42928 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the "Empty" and "Not empty" filter operators returned the same results for rows whose text was entered, cleared, and saved again. Cleared text fields are now correctly matched by both operators. + ## [3.10.0] - 2026-05-06 ### Fixed diff --git a/packages/shared/widget-plugin-filtering/src/__tests__/StringInputFilterStore.spec.ts b/packages/shared/widget-plugin-filtering/src/__tests__/StringInputFilterStore.spec.ts new file mode 100644 index 0000000000..2e784022ff --- /dev/null +++ b/packages/shared/widget-plugin-filtering/src/__tests__/StringInputFilterStore.spec.ts @@ -0,0 +1,96 @@ +jest.mock("mendix/filters/builders"); +import { ListAttributeValue } from "mendix"; +import { and, attribute, equals, literal, notEqual, or } from "mendix/filters/builders"; +import { configure } from "mobx"; +import { attrId, listAttribute } from "@mendix/widget-plugin-test-utils"; +import { StringInputFilterStore } from "../stores/input/StringInputFilterStore"; + +configure({ enforceActions: "never" }); + +describe("StringInputFilterStore", () => { + describe("get condition()", () => { + let attr: ListAttributeValue; + let store: StringInputFilterStore; + + beforeEach(() => { + attr = listAttribute(() => "") as ListAttributeValue; + attr.id = attrId("attr_unset"); + store = new StringInputFilterStore([attr], null); + }); + + it("returns compound or() for 'empty' so it catches both null and ''", () => { + store.filterFunction = "empty"; + attr.id = attrId("attr_empty_string"); + expect(store.condition).toEqual( + or(equals(attribute(attr.id), literal(undefined)), equals(attribute(attr.id), literal(""))) + ); + }); + + it("returns compound and() for 'notEmpty' so it excludes both null and ''", () => { + store.filterFunction = "notEmpty"; + attr.id = attrId("attr_notempty_string"); + expect(store.condition).toEqual( + and(notEqual(attribute(attr.id), literal(undefined)), notEqual(attribute(attr.id), literal(""))) + ); + }); + + it("returns plain equals when filterFunction is 'equal'", () => { + store.filterFunction = "equal"; + store.arg1.value = "abc"; + attr.id = attrId("attr_equal"); + expect(store.condition).toEqual(equals(attribute(attr.id), literal("abc"))); + }); + }); + + describe("fromViewState()", () => { + let attr: ListAttributeValue; + + beforeEach(() => { + attr = listAttribute(() => "") as ListAttributeValue; + attr.id = attrId("attr_view"); + }); + + it("restores 'empty' from compound or() shape", () => { + const cond = or(equals(attribute(attr.id), literal(undefined)), equals(attribute(attr.id), literal(""))); + const store = new StringInputFilterStore([attr], cond); + expect(store.filterFunction).toBe("empty"); + expect(store.isInitialized).toBe(true); + }); + + it("restores 'empty' regardless of arg order in compound or()", () => { + const cond = or(equals(attribute(attr.id), literal("")), equals(attribute(attr.id), literal(undefined))); + const store = new StringInputFilterStore([attr], cond); + expect(store.filterFunction).toBe("empty"); + }); + + it("restores 'notEmpty' from compound and() shape", () => { + const cond = and( + notEqual(attribute(attr.id), literal(undefined)), + notEqual(attribute(attr.id), literal("")) + ); + const store = new StringInputFilterStore([attr], cond); + expect(store.filterFunction).toBe("notEmpty"); + expect(store.isInitialized).toBe(true); + }); + + it("restores 'notEmpty' regardless of arg order in compound and()", () => { + const cond = and( + notEqual(attribute(attr.id), literal("")), + notEqual(attribute(attr.id), literal(undefined)) + ); + const store = new StringInputFilterStore([attr], cond); + expect(store.filterFunction).toBe("notEmpty"); + }); + + it("does NOT confuse a between-style and() with notEmpty", () => { + // sanity guard: arbitrary and(...) shape that doesn't match the compound contract + // must NOT be misread as "notEmpty" + const cond = and( + notEqual(attribute(attr.id), literal("foo")), + notEqual(attribute(attr.id), literal("bar")) + ); + const store = new StringInputFilterStore([attr], cond); + expect(store.filterFunction).not.toBe("notEmpty"); + }); + }); +}); diff --git a/packages/shared/widget-plugin-filtering/src/stores/input/BaseInputFilterStore.ts b/packages/shared/widget-plugin-filtering/src/stores/input/BaseInputFilterStore.ts index e6fa451a85..ef5584bc58 100644 --- a/packages/shared/widget-plugin-filtering/src/stores/input/BaseInputFilterStore.ts +++ b/packages/shared/widget-plugin-filtering/src/stores/input/BaseInputFilterStore.ts @@ -24,10 +24,21 @@ import { Argument } from "./Argument"; type StateTuple = [Fn] | [Fn, V] | [Fn, V, V]; type Val = A["value"]; +export type EmptyConditionBuilder = ( + listAttribute: AttributeMetaData, + operation: "empty" | "notEmpty" +) => FilterCondition; + +const defaultBuildEmpty: EmptyConditionBuilder = (listAttribute, operation) => { + const fn = operation === "empty" ? equals : notEqual; + return fn(attribute(listAttribute.id), literal(undefined)); +}; + export class BaseInputFilterStore { protected _attributes: AttributeMetaData[] = []; private _filterFunction: Fn; private _isFilterFunctionAdjustable: boolean = true; + protected buildEmpty: EmptyConditionBuilder = defaultBuildEmpty; arg1: A; arg2: A; isInitialized = false; @@ -77,7 +88,9 @@ export class BaseInputFilterStore { get condition(): FilterCondition | undefined { const conditions = this._attributes - .map(attr => getFilterCondition(attr, this.arg1.value, this.arg2.value, this.filterFunction)) + .map(attr => + getFilterCondition(attr, this.arg1.value, this.arg2.value, this.filterFunction, this.buildEmpty) + ) .filter((filter): filter is FilterCondition => filter !== undefined); return conditions?.length > 1 ? or(...conditions) : conditions?.[0]; @@ -122,7 +135,8 @@ function getFilterCondition( listAttribute: AttributeMetaData, value: T | undefined, valueR: T | undefined, - operation: AllFunctions + operation: AllFunctions, + buildEmpty: EmptyConditionBuilder ): FilterCondition | undefined { if ( !listAttribute || @@ -133,6 +147,10 @@ function getFilterCondition( return undefined; } + if (operation === "empty" || operation === "notEmpty") { + return buildEmpty(listAttribute, operation); + } + if (operation === "between") { return and( greaterThan(attribute(listAttribute.id), literal(value)), @@ -149,13 +167,8 @@ function getFilterCondition( equal: equals, notEqual, smaller: lessThan, - smallerEqual: lessThanOrEqual, - empty: equals, - notEmpty: notEqual + smallerEqual: lessThanOrEqual }; - return filters[operation]( - attribute(listAttribute.id), - literal(operation === "empty" || operation === "notEmpty" ? undefined : value) - ); + return filters[operation](attribute(listAttribute.id), literal(value)); } diff --git a/packages/shared/widget-plugin-filtering/src/stores/input/StringInputFilterStore.ts b/packages/shared/widget-plugin-filtering/src/stores/input/StringInputFilterStore.ts index 40431862ee..30d950ee1e 100644 --- a/packages/shared/widget-plugin-filtering/src/stores/input/StringInputFilterStore.ts +++ b/packages/shared/widget-plugin-filtering/src/stores/input/StringInputFilterStore.ts @@ -1,7 +1,8 @@ import { AttributeMetaData, ListAttributeValue, SimpleFormatter } from "mendix"; import { FilterCondition } from "mendix/filters"; +import { attribute, equals, literal, notEqual, and, or } from "mendix/filters/builders"; import { action, comparer, makeObservable } from "mobx"; -import { inputStateFromCond } from "@mendix/filter-commons/condition-utils"; +import { inputStateFromCond, isAnd, isBinary, isOr } from "@mendix/filter-commons/condition-utils"; import { FilterFunctionBinary, FilterFunctionGeneric, @@ -10,13 +11,59 @@ import { } from "@mendix/filter-commons/typings/FilterFunctions"; import { FilterData, InputData } from "@mendix/filter-commons/typings/settings"; import { StringArgument } from "./Argument"; -import { BaseInputFilterStore } from "./BaseInputFilterStore"; +import { BaseInputFilterStore, EmptyConditionBuilder } from "./BaseInputFilterStore"; import { baseNames } from "./fn-mappers"; import { String_InputFilterInterface } from "../../typings/InputFilterInterface"; type StrFns = FilterFunctionString | FilterFunctionGeneric | FilterFunctionNonValue | FilterFunctionBinary; type AttrMeta = AttributeMetaData & { formatter?: SimpleFormatter }; +const buildStringEmpty: EmptyConditionBuilder = (listAttribute, operation) => { + const attrExp = attribute(listAttribute.id); + if (operation === "empty") { + return or(equals(attrExp, literal(undefined)), equals(attrExp, literal(""))); + } + return and(notEqual(attrExp, literal(undefined)), notEqual(attrExp, literal(""))); +}; + +function isUndefinedLiteralEq(exp: FilterCondition, op: "=" | "!="): boolean { + return isBinary(exp) && exp.name === op && exp.arg2.type === "literal" && exp.arg2.valueType === "undefined"; +} + +function isEmptyStringLiteralEq(exp: FilterCondition, op: "=" | "!="): boolean { + if (!isBinary(exp) || exp.name !== op || exp.arg2.type !== "literal") { + return false; + } + // Mendix runtime emits lowercase "string"; the shared test mock emits "String". + // Accept both so detection works in tests AND in production. + const vt = exp.arg2.valueType as string; + return (vt === "string" || vt === "String") && exp.arg2.value === ""; +} + +// or(equals(_, undef), equals(_, "")) +function isStringEmptyExp(cond: FilterCondition): boolean { + if (!isOr(cond) || cond.args.length !== 2) { + return false; + } + const [a, b] = cond.args; + return ( + (isUndefinedLiteralEq(a, "=") && isEmptyStringLiteralEq(b, "=")) || + (isUndefinedLiteralEq(b, "=") && isEmptyStringLiteralEq(a, "=")) + ); +} + +// and(notEqual(_, undef), notEqual(_, "")) +function isStringNotEmptyExp(cond: FilterCondition): boolean { + if (!isAnd(cond) || cond.args.length !== 2) { + return false; + } + const [a, b] = cond.args; + return ( + (isUndefinedLiteralEq(a, "!=") && isEmptyStringLiteralEq(b, "!=")) || + (isUndefinedLiteralEq(b, "!=") && isEmptyStringLiteralEq(a, "!=")) + ); +} + export class StringInputFilterStore extends BaseInputFilterStore implements String_InputFilterInterface @@ -27,6 +74,7 @@ export class StringInputFilterStore constructor(attributes: AttrMeta[], initCond: FilterCondition | null) { const formatter = getFormatter(attributes[0]); super(new StringArgument(formatter), new StringArgument(formatter), "equal", attributes); + this.buildEmpty = buildStringEmpty; makeObservable(this, { updateProps: action, fromJSON: action, @@ -65,6 +113,19 @@ export class StringInputFilterStore } fromViewState(cond: FilterCondition): void { + // Compound empty/notEmpty must be detected BEFORE delegating to inputStateFromCond, + // otherwise the and(...) shape for notEmpty would fall into betweenToState. + if (isStringEmptyExp(cond)) { + this.setState(["empty"]); + this.isInitialized = true; + return; + } + if (isStringNotEmptyExp(cond)) { + this.setState(["notEmpty"]); + this.isInitialized = true; + return; + } + const initState = inputStateFromCond( cond, (fn): StrFns => {