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
2 changes: 1 addition & 1 deletion docs/requirements/backend-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Although pluggable widgets are primarily client-side components, they operate wi
- **textTemplate:** Passed as a DynamicValue<string>.
- **objects/object:** Provided as a ListValue or ObjectValue.
- **Simple types:** Passed as plain JS values.
- **action:** Provided as an ActionValue with methods like `execute()`.
- **action:** Provided as an ActionValue with methods like `execute()`. **Exception:** when an action is linked to an attribute or association as its `onChange` handler (system-managed), the Mendix runtime invokes it automatically and it is **not** passed to the widget as a prop. Such actions are intentionally absent from the generated `*ContainerProps` typings — this is correct, not a sync issue.
- **Offline Capable:** XML can mark widgets as offline capable, meaning they are designed to work without a network connection.

## Data Flow: Reading and Updating Data
Expand Down
130 changes: 130 additions & 0 deletions packages/pluggableWidgets/combobox-web/e2e/OnChange.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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("multi 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);
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);
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.getOptions().filter({ hasText: 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.
Loading
Loading