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
132 changes: 132 additions & 0 deletions packages/pluggableWidgets/combobox-web/e2e/OnChange.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect, test } from "@mendix/run-e2e/fixtures";
import { waitForMendixApp, waitFrames } from "@mendix/run-e2e/mendix-helpers";
import Combobox from "./utils/Combobox.pageObject";
import { parseLogEntries } from "./utils/logEntryParser";

test.describe("combobox-web onChange", () => {
test.describe("boolean", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/boolean");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Yes");

const entries = await getLogs(page);
expect(entries[entries.length - 1].booleanAttr).toBe(true);
});
});

test.describe("enum", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/enum");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Green");

const entries = await getLogs(page);
expect(entries[entries.length - 1].enumColorAttr).toBe("Green");
});
});

test.describe("single assoc", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/singleassoc");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Single Option nr.1");

const entries = await getLogs(page);
expect(entries[entries.length - 1].singleAssocTitle).toBe("Single Option nr.1");
});
});

test.describe("milti assoc", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/multiassoc");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Multi Option nr.1");
await combobox.selectOption("Multi Option nr.2");

const entries = await getLogs(page);
console.log(entries);
expect(entries[entries.length - 1].multiAssocTitles).toEqual(["Multi Option nr.1", "Multi Option nr.2"]);
});
});

test.describe("database options over string", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/databaseoverstring");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Single Option nr.2");

const entries = await getLogs(page);
expect(entries[entries.length - 1].stringAsOptionAttr).toBe("Single Option nr.2");
});
});

test.describe("static options over string", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/staticoverstring");
await waitForMendixApp(page);
});

test("should trigger onChange event", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Option 3");

const entries = await getLogs(page);
expect(entries[entries.length - 1].stringAsOptionAttr).toBe("option3");
});
});

test.describe("read association to pass to onChange", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/p/events/onchange/passassoc");
await waitForMendixApp(page);
});

test("should trigger onChange event with updated value", async ({ page }) => {
const combobox = new Combobox(getCombobox(page));

await combobox.selectOption("Single Option nr.2");

const entries = await getLogs(page);
console.log(entries);
expect(entries[entries.length - 1].passedAssociationTitle).toBe("Single Option nr.2");
});
});
});

function getCombobox(page) {
return page.locator(".mx-name-comboBox3");
}

async function getLogs(page) {
await waitFrames(page, 10);
const text = await page.locator(".mx-name-text2").innerText();

return parseLogEntries(text);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export default class Combobox {
constructor(locator) {
this.locator = locator;
}

async open() {
await this.locator.locator(".widget-combobox").click();
}

async close() {
await this.locator.page().keyboard.press("Escape");
}

getFilterInput() {
return this.locator.locator("input");
}

getMenu() {
return this.locator.locator(".widget-combobox-menu").first();
}

getOptions() {
return this.locator.locator("[role=listbox] [role=option]");
}

getOptionByText(text) {
return this.locator.locator(`[role=listbox] [role=option]:has-text("${text}")`);
}

getSelectedText() {
return this.locator.locator(".widget-combobox-placeholder-text");
}

async filter(text) {
await this.getFilterInput().fill(text);
}

isOpen() {
return this.getMenu().isVisible();
}

async selectOption(text) {
if (!(await this.isOpen())) {
await this.open();
}
await this.getOptionByText(text).click({ delay: 10 });
}

async clear() {
await this.locator.locator(".widget-combobox-clear-button").first().click();
}

async removeSelectedOption(index = 0) {
await this.locator.locator(".widget-combobox-icon-container").nth(index).click();
}
}
119 changes: 119 additions & 0 deletions packages/pluggableWidgets/combobox-web/e2e/utils/logEntryParser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Helper for parsing onChange log output produced by the test project's
* change-tracking microflow, e.g.:
*
* [#false#Green####]
* [#false#Blue####]
* [#false#Red####]
*
* Which is generated in Mendix by concatenating:
*
* '[' +
* Title + '#' +
* toString(BooleanAttr) + '#' +
* toString(EnumColorAttr) + '#' +
* StringAsOptionAttr + '#' +
* OnChangeEntity_OnChangeSingleRelation/TitleSingle + '#' +
* Variable + '#' +
* OnChangeSingleRelation/TitleSingle +
* ']'
*
* Note: `Variable` here is
* `$OnChangeEntity/MyFirstModule.OnChangeEntity_OnChangeMultiRelation/MyFirstModule.OnChangeMultiRelation`,
* i.e. a multi-relation (list of objects). When Mendix renders a list
* association reference as part of a string concatenation, it produces a
* "!"-prefixed, "!"-separated list of the associated objects' name
* attributes, e.g.:
*
* [#false####!Multi Option nr.1#]
* [#false####!Multi Option nr.1!Multi Option nr.2#]
* [#false####!Multi Option nr.1!Multi Option nr.2!Multi Option nr.3#]
*
* This field is therefore parsed into an array of strings (e.g.
* `["Multi Option nr.1", "Multi Option nr.2"]`) rather than a single
* scalar value.
*/

/** Ordered field names matching the microflow concatenation above. */
const LOG_ENTRY_FIELDS = [
"title",
"booleanAttr",
"enumColorAttr",
"stringAsOptionAttr",
"singleAssocTitle",
"multiAssocTitles",
"passedAssociationTitle"
];

/** Field names whose raw value is a comma-separated list (multi-relation). */
const LIST_FIELDS = new Set(["multiAssocTitles"]);

/**
* Converts a raw field string into a more meaningful JS value.
* Empty strings become `null`, and "true"/"false" become booleans.
* @param {string} value
* @returns {string | boolean | null}
*/
function coerceValue(value) {
if (value === "") {
return null;
}
if (value === "true" || value === "false") {
return value === "true";
}
return value;
}

/**
* Converts a raw "!"-prefixed, "!"-separated field string into an array of
* trimmed, non-empty strings. An empty input yields an empty array.
*
* e.g. "!Multi Option nr.1!Multi Option nr.2" -> ["Multi Option nr.1", "Multi Option nr.2"]
* @param {string} value
* @returns {string[]}
*/
function coerceListValue(value) {
if (value === "") {
return [];
}
return value
.split("!")
.map(item => item.trim())
.filter(Boolean);
}

/**
* Parses a single log entry line, e.g. "[#false#Green####]", into a
* structured object.
* @param {string} line
* @returns {Record<string, string | boolean | string[] | null>}
*/
export function parseLogEntry(line) {
const trimmed = line.trim();
const match = trimmed.match(/^\[(.*)]$/);
if (!match) {
throw new Error(`Invalid log entry format: "${line}"`);
}

const fields = match[1].split("#");

return LOG_ENTRY_FIELDS.reduce((entry, fieldName, index) => {
const rawValue = fields[index] ?? "";
entry[fieldName] = LIST_FIELDS.has(fieldName) ? coerceListValue(rawValue) : coerceValue(rawValue);
return entry;
}, {});
}

/**
* Parses multi-line log output into an array of structured entries.
* Blank lines are ignored.
* @param {string} text
* @returns {Array<Record<string, string | boolean | string[] | null>>}
*/
export function parseLogEntries(text) {
return text
.split("\n")
.map(line => line.trim())
.filter(Boolean)
.map(parseLogEntry);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## Context

The combobox widget supports multiple data sources (enum/boolean, static, association, database). Each has its own selector class. All of them currently fire `onChangeEvent` by calling `executeAction(this.onChangeEvent)` manually inside `setValue()`. This is boilerplate that every selector must remember to include and maintain.

The Mendix Pluggable Widgets API supports declaring `onChange="<actionKey>"` on `type="attribute"` and `type="association"` properties in XML. When declared, the platform fires the bound action automatically whenever the widget calls `.setValue()` on that attribute — no code needed.

## Goals / Non-Goals

**Goals:**

- Remove all manual `executeAction(this.onChangeEvent)` calls from selectors.
- Wire `onChange="onChangeEvent"` in XML for all five relevant attribute properties.
- Keep `onChangeDatabaseEvent` (Selection API path) exactly as-is.

**Non-Goals:**

- Changing when the action fires for any source type (this is a pure internal refactor — behaviour is identical).
- Modifying `DatabaseMultiSelectionSelector` or the `onChangeDatabaseEvent` mechanism.
- Adding new user-facing capabilities or changing the action's semantics.

## Decisions

### Decision: Wire onChange in XML rather than a shared base class

Adding `onChange="onChangeEvent"` in XML is the platform-idiomatic approach. It removes the coupling between selector code and the action, and ensures the action fires even if a selector forgets to call it. The alternative — creating a shared base class method — still requires every selector to call the base, which is the same maintenance burden as today.

### Decision: Remove the \_valuesIsEqual guard on database single select

`DatabaseSingleSelectionSelector.setAttributeValue()` currently guards `executeAction` with `_valuesIsEqual`. This guard is redundant with the platform's own behaviour: the platform only fires `onChange` when the attribute value actually changes. The guard can be removed without any change in observable behaviour.

### Decision: Keep onChangeEvent extraction in extractDatabaseProps only if needed elsewhere

After removing `onChangeEvent` from `DatabaseSingleSelectionSelector`, the field is no longer needed in `extractDatabaseProps`'s return type. Remove it from there and from all other `utils.ts` extraction helpers where it is no longer consumed.

## Risks / Trade-offs

- **[Risk] Generated typings change** → Removing `onChangeEvent` from `updateProps` arguments means the generated `ComboboxProps.ts` (via `typings/`) may change. Verify that the generated props still include `onChangeEvent?: ActionValue` from the XML action definition, independent of the attribute `onChange` binding.

## Migration Plan

No migration required. This is a pure internal refactor:

- XML `onChange` binding is additive.
- Removing `executeAction` calls from selectors is invisible to consumers.
- No public API, prop names, or user-facing behaviour changes.

Rollback: revert the XML change and restore `executeAction` calls in the affected selector files.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

The `onChangeEvent` action is currently fired manually via `executeAction()` in every selector's `setValue()` method. This is error-prone and inconsistent — each selector duplicates the call, and the database single selector has an extra guard that makes its behavior subtly different from the rest. The Mendix platform already supports automatic action firing via `onChange` on attribute properties in XML, which is the idiomatic way to hook into value changes.

## What Changes

- Add `onChange="onChangeEvent"` to each attribute property in `Combobox.xml`: `attributeEnumeration`, `attributeBoolean`, `staticAttribute`, `attributeAssociation`, and `databaseAttributeString`.
- Remove manual `executeAction(this.onChangeEvent)` calls from:
- `BaseAssociationSelector.setValue()`
- `EnumBoolSingleSelector.setValue()`
- `StaticSingleSelector.setValue()`
- `DatabaseSingleSelectionSelector.setAttributeValue()`
- Remove the `onChangeEvent` field and its extraction from selector classes and `extractDatabaseProps` / `extractAssociationProps` / other `utils.ts` helpers.
- No behavioral difference: the platform also only fires `onChange` when the attribute value actually changes, so the existing `_valuesIsEqual` guard in `DatabaseSingleSelectionSelector` becomes redundant and is removed.

## Capabilities

### New Capabilities

- `attribute-driven-onchange`: The `onChangeEvent` action is wired to attribute properties in XML so the platform fires it automatically on every attribute value change, eliminating manual `executeAction` calls in selectors.

### Modified Capabilities

<!-- No existing specs to modify — no openspec/specs/ directory exists yet. -->

## Impact

- `src/Combobox.xml`: 5 attribute properties gain `onChange="onChangeEvent"`.
- `src/helpers/Association/BaseAssociationSelector.ts`: remove `onChangeEvent` field and `executeAction` call.
- `src/helpers/EnumBool/EnumBoolSingleSelector.tsx`: remove `onChangeEvent` field and `executeAction` call.
- `src/helpers/Static/StaticSingleSelector.ts`: remove `onChangeEvent` field and `executeAction` call.
- `src/helpers/Database/DatabaseSingleSelectionSelector.ts`: remove `onChangeEvent` field and `executeAction` call (and the `_valuesIsEqual` guard around it).
- `src/helpers/Database/utils.ts`: remove `onChangeEvent` from `ExtractionReturnValue` and `extractDatabaseProps`.
- `src/helpers/Association/utils.ts`, `src/helpers/Static/utils.ts`, `src/helpers/EnumBool/utils.ts`: remove `onChangeEvent` extraction where present.
- No public API changes. No new dependencies. `DatabaseMultiSelectionSelector` is unaffected.
Loading
Loading