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
429 changes: 429 additions & 0 deletions packages/pluggableWidgets/combobox-web/AGENTS.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/pluggableWidgets/combobox-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

## [2.8.1] - 2026-04-30

### Fixed

- We fixed an issue where the combobox in database mode did not update its displayed value when the attribute was changed externally (e.g., via a popup combobox).

## [2.8.0] - 2026-03-09

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/combobox-web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@mendix/combobox-web",
"widgetName": "Combobox",
"version": "2.8.0",
"version": "2.8.1",
"description": "Configurable Combo box widget with suggestions and autocomplete.",
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
"license": "Apache-2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ export class DatabaseSingleSelectionSelector<
return;
}
if (targetAttribute?.status === "available") {
if (targetAttribute.value && !this.currentId) {
if (
targetAttribute.value &&
(!this.currentId || !_valuesIsEqual(targetAttribute.value, this.values.get(this.currentId)))
) {
const allOptions = this.options.getAll();
if (allOptions.length > 0) {
const obj = this.options.getAll().find(option => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
dynamic,
EditableValueBuilder,
list,
listAttribute,
obj,
SelectionSingleValueBuilder
} from "@mendix/widget-plugin-test-utils";
import { ListAttributeValue, ObjectItem } from "mendix";
import { ComboboxContainerProps } from "../../../../typings/ComboboxProps";
import { DatabaseSingleSelectionSelector } from "../DatabaseSingleSelectionSelector";

type PropsOverrides = {
items: ObjectItem[];
values: Map<string, string>;
targetValue?: string;
selection?: ReturnType<SelectionSingleValueBuilder["build"]>;
};

function buildProps({ items, values, targetValue, selection }: PropsOverrides): ComboboxContainerProps {
const valueAttr = listAttribute<string>(item => values.get(item.id) ?? "") as ListAttributeValue<string | Big>;
(valueAttr as unknown as { id: string }).id = "valueAttrId" as any;

const captionAttr = listAttribute<string>(item => `caption_${item.id}`);
(captionAttr as unknown as { id: string }).id = "captionAttrId" as any;

const targetAttr =
targetValue === undefined
? new EditableValueBuilder<string>().build()
: new EditableValueBuilder<string>().withValue(targetValue).build();

return {
name: "comboBox",
id: "comboBox1",
source: "database",
optionsSourceType: "association",
optionsSourceDatabaseDataSource: list(items),
optionsSourceDatabaseItemSelection: selection ?? new SelectionSingleValueBuilder().build(),
optionsSourceDatabaseCaptionType: "attribute",
optionsSourceDatabaseCaptionAttribute: captionAttr,
optionsSourceDatabaseValueAttribute: valueAttr,
databaseAttributeString: targetAttr as any,
emptyOptionText: dynamic("Select..."),
optionsSourceDatabaseCustomContentType: "no",
optionsSourceAssociationCustomContentType: "no",
staticDataSourceCustomContentType: "no",
optionsSourceAssociationCaptionType: "attribute",
clearable: true,
filterType: "contains",
lazyLoading: false,
loadingType: "spinner",
customEditability: "default",
customEditabilityExpression: dynamic(false),
filterInputDebounceInterval: 200,
selectedItemsStyle: "text",
readOnlyStyle: "bordered",
selectionMethod: "checkbox",
selectAllButton: false,
selectAllButtonCaption: dynamic("Select All"),
ariaRequired: dynamic(true),
showFooter: false,
selectedItemsSorting: "none",
attributeEnumeration: new EditableValueBuilder<string>().build(),
attributeBoolean: new EditableValueBuilder<boolean>().build(),
attributeAssociation: undefined as any,
staticAttribute: new EditableValueBuilder<string>().build(),
optionsSourceStaticDataSource: []
} as ComboboxContainerProps;
}

describe("DatabaseSingleSelectionSelector.updateProps — external target-attribute changes", () => {
const optionA = obj("A");
const optionB = obj("B");
const items = [optionA, optionB];
const values = new Map<string, string>([
[optionA.id, "v1"],
[optionB.id, "v2"]
]);

it("resolves currentId from targetAttribute.value on initial updateProps", () => {
const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 });
selector.updateProps(buildProps({ items, values, targetValue: "v1" }));

expect(selector.currentId).toBe(optionA.id);
});

it("refreshes currentId when targetAttribute.value changes externally after a selection exists", () => {
// WC-3355: without this behavior, an external value change (e.g. from a microflow)
// leaves currentId pointing at the stale option.
const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 });
const selection = new SelectionSingleValueBuilder().build();

selector.updateProps(buildProps({ items, values, targetValue: "v1", selection }));
expect(selector.currentId).toBe(optionA.id);

selector.updateProps(buildProps({ items, values, targetValue: "v2", selection }));
expect(selector.currentId).toBe(optionB.id);
});

it("falls back to loadSelectedValue when new value is not in loaded options", () => {
const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 });
const selection = new SelectionSingleValueBuilder().build();
const soleItems = [optionA];
const soleValues = new Map<string, string>([[optionA.id, "v1"]]);

selector.updateProps(buildProps({ items: soleItems, values: soleValues, targetValue: "v1", selection }));
const loadSpy = jest.spyOn(selector.options, "loadSelectedValue");

selector.updateProps(buildProps({ items: soleItems, values: soleValues, targetValue: "v-unknown", selection }));

expect(loadSpy).toHaveBeenCalledWith("v-unknown", expect.anything());
});

it("clears currentId and selection when targetAttribute.value is cleared externally", () => {
const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 });
const selection = new SelectionSingleValueBuilder().build();
const setSelectionSpy = jest.spyOn(selection, "setSelection");

selector.updateProps(buildProps({ items, values, targetValue: "v1", selection }));
expect(selector.currentId).toBe(optionA.id);

selector.updateProps(buildProps({ items, values, targetValue: undefined, selection }));

expect(selector.currentId).toBeNull();
expect(setSelectionSpy).toHaveBeenCalledWith(undefined);
});

it("does not re-resolve currentId when targetAttribute.value is unchanged", () => {
const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 });
const selection = new SelectionSingleValueBuilder().build();

selector.updateProps(buildProps({ items, values, targetValue: "v1", selection }));
const getAllSpy = jest.spyOn(selector.options, "getAll");

selector.updateProps(buildProps({ items, values, targetValue: "v1", selection }));

expect(getAllSpy).not.toHaveBeenCalled();
expect(selector.currentId).toBe(optionA.id);
});
});
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/combobox-web/src/package.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="Combobox" version="2.8.0" xmlns="http://www.mendix.com/clientModule/1.0/">
<clientModule name="Combobox" version="2.8.1" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="Combobox.xml" />
</widgetFiles>
Expand Down
Loading