Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
let store: StringInputFilterStore;

beforeEach(() => {
attr = listAttribute(() => "") as ListAttributeValue<string>;
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<string>;

beforeEach(() => {
attr = listAttribute(() => "") as ListAttributeValue<string>;
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");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,21 @@ import { Argument } from "./Argument";
type StateTuple<Fn, V> = [Fn] | [Fn, V] | [Fn, V, V];
type Val<A extends Argument> = 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<A extends Argument, Fn extends AllFunctions> {
protected _attributes: AttributeMetaData[] = [];
private _filterFunction: Fn;
private _isFilterFunctionAdjustable: boolean = true;
protected buildEmpty: EmptyConditionBuilder = defaultBuildEmpty;
arg1: A;
arg2: A;
isInitialized = false;
Expand Down Expand Up @@ -77,7 +88,9 @@ export class BaseInputFilterStore<A extends Argument, Fn extends AllFunctions> {

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];
Expand Down Expand Up @@ -122,7 +135,8 @@ function getFilterCondition<T extends string | Big | Date>(
listAttribute: AttributeMetaData,
value: T | undefined,
valueR: T | undefined,
operation: AllFunctions
operation: AllFunctions,
buildEmpty: EmptyConditionBuilder
): FilterCondition | undefined {
if (
!listAttribute ||
Expand All @@ -133,6 +147,10 @@ function getFilterCondition<T extends string | Big | Date>(
return undefined;
}

if (operation === "empty" || operation === "notEmpty") {
return buildEmpty(listAttribute, operation);
}

if (operation === "between") {
return and(
greaterThan(attribute(listAttribute.id), literal(value)),
Expand All @@ -149,13 +167,8 @@ function getFilterCondition<T extends string | Big | Date>(
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));
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string> & { formatter?: SimpleFormatter<string> };

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 === "";
Comment on lines +37 to +40
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What? If mock is not behaving the same as the real life, we should fix the mock

}

// 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<StringArgument, StrFns>
implements String_InputFilterInterface
Expand All @@ -27,6 +74,7 @@ export class StringInputFilterStore
constructor(attributes: AttrMeta[], initCond: FilterCondition | null) {
const formatter = getFormatter<string>(attributes[0]);
super(new StringArgument(formatter), new StringArgument(formatter), "equal", attributes);
this.buildEmpty = buildStringEmpty;
makeObservable(this, {
updateProps: action,
fromJSON: action,
Expand Down Expand Up @@ -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 => {
Expand Down
Loading