Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/requirements/backend-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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.
- **Static vs dynamic props:** Simple-type props (enumerations, strings, numbers, booleans) are **constants for the lifetime of a widget instance**. They change only when the app developer reconfigures the app and redeploys, which is a completely new page load. They never change between re-renders. Only the platform value objects (EditableValue, DynamicValue, ListValue, ListAttributeValue, ListExpressionValue, ListWidgetValue, ActionValue, SelectionValue) change between re-renders. Consequences: read static props once (for example at store or controller construction) as plain fields, never as observables; never write a test that changes a static prop between renders, because that behaviour cannot occur.
- **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.

Expand Down
7 changes: 7 additions & 0 deletions packages/pluggableWidgets/combobox-web/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const { join } = require("path");
const baseConfig = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js");

module.exports = {
...baseConfig,
setupFilesAfterEnv: [...baseConfig.setupFilesAfterEnv, join(__dirname, "jest.setup.ts")]
};
10 changes: 10 additions & 0 deletions packages/pluggableWidgets/combobox-web/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// downshift checks in development that every getter of a hook in use is called and its ref applied,
// and only logs when it is not. The log is turned into a failure here; in the browser it floods the
// console on every render of the widget.
const error = console.error;
console.error = (...args: unknown[]): void => {
if (typeof args[0] === "string" && args[0].startsWith("downshift:")) {
throw new Error(args[0]);
}
error(...args);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-10-28

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

The selectors, options providers and captions providers under `src/helpers/` are the model of the combobox: they turn Mendix values into options, captions, a current value and status, and write selections back. Any refactoring of that layer (a different state container, a split of the families, a fix to one of the quirks below) has no net under it. Unit coverage today is 29 component cases on `SingleSelection`, `MultiSelection` and `StaticSelection`, plus 20 helper cases that only cover the database selector and its utils. The association, static, enumeration and boolean selectors have no direct tests. Neither do lazy loading, the filter-input debounce, the limit management in `LazyLoadProvider`, the client-side filter modes, or the re-render side channel in `useGetSelector`. The component cases cover open, toggle, select and clear, not what the downshift reducers do with the typed text and the highlight on leave, Escape or focus.

## Root Cause

The `Selector` interface in `src/helpers/types.ts` was always the seam between model and UI, but it was only ever exercised indirectly through the React components. Behaviour that lives purely in the selectors (status transitions, limit arithmetic, caption resolution for items outside the datasource, debounce of the datasource filter, the "forced to boxes" rule for multi-select) is therefore unprotected. A refactoring that changes any of it would only be caught, if at all, by an E2E run or by hand in Studio Pro.

## What Changes

This change adds tests only. No production code changes.

- `src/helpers/__tests__/fixtures.ts`: one shared builder for `ComboboxContainerProps` with per-family presets (association single and multi, enumeration, boolean, static, database single and multi), so every selector test starts from a complete, valid prop set.
- Characterisation tests at the `Selector` seam, one spec per family:
- `src/helpers/__tests__/getSelector.spec.ts`
- `src/helpers/Association/__tests__/AssociationSingleSelector.spec.ts`
- `src/helpers/Association/__tests__/AssociationMultiSelector.spec.ts`
- `src/helpers/EnumBool/__tests__/EnumBoolSingleSelector.spec.ts`
- `src/helpers/Static/__tests__/StaticSingleSelector.spec.ts`
- `src/helpers/Database/__tests__/DatabaseMultiSelectionSelector.spec.ts`
- extra cases in `src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts`
- Characterisation tests for the pieces that sit under the selectors:
- `src/helpers/__tests__/BaseOptionsProvider.spec.ts` (filter modes and search-term trigger)
- `src/helpers/__tests__/LazyLoadProvider.spec.ts` (limit arithmetic)
- `src/helpers/__tests__/datasourceFilter.spec.ts`
- `src/hooks/__tests__/useGetSelector.spec.tsx` (instance stability, re-render side channel, filter-input debounce)
- View cases in `src/__tests__/SingleSelection.spec.tsx` and `MultiSelection.spec.tsx` for the downshift state reducers: what the typed text, the highlight and the menu do on leave, toggle, Enter, Escape, focus and Backspace. These were only covered by the E2E suite.
- `jest.config.js` and `jest.setup.ts`: a test fails on any `downshift:` console error. downshift reports a getter that was not called or a ref that was not applied with `console.error` only, which Jest showed without failing.

## Impact

- **Code**: new test files, one fixture module, cases in two existing component specs and the Jest setup only. `src/helpers/`, `src/hooks/`, `src/components/` and the E2E suite are untouched.
- **Behaviour**: none. Every test must pass on the current code without modification. Where a test pins down a quirk (for example `StaticSingleSelector` keeps a stale `currentId` when the attribute is cleared externally), the quirk is marked in the test and listed in `design.md` under Notes, so a refactoring preserves it and a later change can fix it deliberately.
- **Release**: no CHANGELOG entry. Nothing user-facing changes.
- **Dependencies**: none added.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## 1. Baseline

- [x] 1.1 Confirm the unit suite is green before any change: `cd packages/pluggableWidgets/combobox-web && pnpm run test`. Baseline: 6 suites, 61 tests, 5 snapshots.
- [x] 1.2 Add `src/helpers/__tests__/fixtures.ts` with a complete `ComboboxContainerProps` builder and per-family presets. No `as any` on the result; cast only where the generated typings and the test builders disagree.

## 2. Characterisation tests (write, run, they must pass on current code)

- [x] 2.1 `src/helpers/__tests__/getSelector.spec.ts`
- [x] 2.2 `src/helpers/Association/__tests__/AssociationSingleSelector.spec.ts`
- [x] 2.3 `src/helpers/Association/__tests__/AssociationMultiSelector.spec.ts`
- [x] 2.4 `src/helpers/EnumBool/__tests__/EnumBoolSingleSelector.spec.ts`
- [x] 2.5 `src/helpers/Static/__tests__/StaticSingleSelector.spec.ts`
- [x] 2.6 `src/helpers/Database/__tests__/DatabaseMultiSelectionSelector.spec.ts`
- [x] 2.7 Extend `src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts`
- [x] 2.8 `src/helpers/__tests__/BaseOptionsProvider.spec.ts`
- [x] 2.9 `src/helpers/__tests__/LazyLoadProvider.spec.ts`
- [x] 2.10 `src/helpers/__tests__/datasourceFilter.spec.ts`
- [x] 2.11 `src/hooks/__tests__/useGetSelector.spec.tsx`
- [x] 2.12 Mark every pinned quirk with a `// Quirk:` comment in the test and list it in `design.md` Notes.
- [x] 2.13 View cases for the downshift state reducers in `src/__tests__/SingleSelection.spec.tsx` and `MultiSelection.spec.tsx`: typed text, highlight and menu state on leave, toggle, Enter, Escape, focus and Backspace.
- [x] 2.14 `jest.config.js` and `jest.setup.ts`: fail a test on any `downshift:` console error.

## 3. Verification

- [x] 3.1 Full suite green with the new files; no snapshot written or updated (`pnpm run test` without `-u`).
- [x] 3.2 `pnpm run lint` clean in the package.
- [x] 3.3 Mutation check: temporarily break one rule per family (for example invert the boxes-forcing condition in `AssociationMultiSelector`, drop the `setLimit(0)` for read-only in `LazyLoadProvider`) and confirm the matching test fails. Revert.
- [x] 3.4 Mutation check for the view cases: drop the input reset on close in the single reducer, open the multi menu with a default highlight, close the multi menu on selection; confirm the new cases fail. Revert.

## Notes

- Baseline before the change: 6 suites, 61 tests, 5 snapshots. After: 16 suites, 206 tests (194 at the selector seam and below, 12 view cases), the same 5 snapshots, none written or updated.
- Lint: 0 errors. The 13 remaining warnings are pre-existing in `src/components/`, `src/hooks/` and `src/helpers/` and were not touched.
- Mutation check (each mutation reverted afterwards): forcing `"text"` instead of `"boxes"` in `AssociationMultiSelector` fails 1 test; dropping the read-only branch in `LazyLoadProvider.getLimit` fails 1; off-by-one in `StaticSingleSelector` index resolution fails 6; `"yes"` instead of `"true"` in `EnumBoolOptionsProvider` fails 1; inverting the selection fallback in `DatabaseMultiSelectionSelector.setValue` fails 2; sending `""` instead of `undefined` from `useGetSelector` fails 1; page size 1 instead of 100 in `BaseDatasourceOptionsProvider.loadMore` fails 1. View cases: keeping the typed text on close in the single reducer fails 2; `defaultHighlightedIndex: 0` on the multi combobox fails 3; closing the multi menu on selection fails 2. The multi `InputBlur` branch (`highlightedIndex: -1`) is not reachable through the DOM, because leaving the widget closes the menu first and reopening starts without a highlight anyway.
- Quirks found while writing the tests, beyond those in `design.md`: `AssociationSingleSelector` and `AssociationMultiSelector` still expose the reference id as `currentId` while unavailable (the subclass overwrites what the base class cleared); the database multi selector never forces the boxes style because the forcing rule runs before the props are copied; without a datasource sort order, caption sorting of chips is descending because `sortOrder` is `undefined` and only `"asc"` sorts ascending.
- `pnpm run test <path>` does not filter in this package; the positional argument is treated as a second Jest project. Use `pnpm run test --testPathPatterns <regex>`.
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/combobox-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"publish-marketplace": "rui-publish-marketplace",
"release": "pluggable-widgets-tools release:web",
"start": "pluggable-widgets-tools start:server",
"test": "pluggable-widgets-tools test:unit:web",
"test": "jest --projects jest.config.js",
"update-changelog": "rui-update-changelog-widget",
"verify": "rui-verify-package-format"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ReferenceSetValueBuilder,
setupIntersectionObserverStub
} from "@mendix/widget-plugin-test-utils";
import { ComboboxContainerProps, OptionsSourceAssociationCaptionTypeEnum } from "../../typings/ComboboxProps";
import { ComboboxContainerProps } from "../../typings/ComboboxProps";
import Combobox from "../Combobox";

async function getInput(component: RenderResult): Promise<HTMLInputElement> {
Expand Down Expand Up @@ -437,10 +437,10 @@ describe("Combo box (Association)", () => {
describe("with lazy loading", () => {
it("calls loadMore only when menu opens", async () => {
const setLimit = jest.fn();
const lazyLoadingProps = {
const lazyLoadingProps: ComboboxContainerProps = {
...defaultProps,
lazyLoading: true,
optionsSourceAssociationCaptionType: "attribute" as OptionsSourceAssociationCaptionTypeEnum,
optionsSourceAssociationCaptionType: "attribute",
optionsSourceAssociationDataSource: {
...defaultProps.optionsSourceAssociationDataSource,
hasMoreItems: true,
Expand All @@ -462,4 +462,110 @@ describe("Combo box (Association)", () => {
});
});
});

describe("typed text, highlight and menu state", () => {
// The state reducer in useDownshiftMultiSelectProps decides what the typed text, the
// highlight and the menu do on each event. These cases pin that behaviour at the DOM.
async function open(component: RenderResult): Promise<HTMLInputElement> {
const input = await getInput(component);
fireEvent.click(input);
await waitFor(() => {
expect(component.getAllByRole("option")).toHaveLength(4);
});
return input;
}

function highlighted(component: RenderResult): string | undefined {
return component.container.querySelector(".widget-combobox-item-highlighted")?.textContent ?? undefined;
}

function type(input: HTMLInputElement, value: string): void {
fireEvent.change(input, { target: { value } });
}

it("keeps the menu open after a keyboard selection and keeps the highlight on the picked option", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = await open(component);
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(highlighted(component)).toBe("obj_222");

fireEvent.keyDown(input, { key: "Enter" });
component.rerender(<Combobox {...defaultProps} />);

expect(defaultProps.attributeAssociation?.setValue).toHaveBeenCalledWith([
{ id: "obj_111" },
{ id: "obj_222" }
]);
expect(component.getAllByRole("option")).toHaveLength(4);
expect(highlighted(component)).toBe("obj_222");
});

it("drops the typed text after a selection from a filtered list", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = await open(component);
type(input, "222");
await waitFor(() => {
expect(component.getAllByRole("option")).toHaveLength(1);
});
fireEvent.keyDown(input, { key: "ArrowDown" });

fireEvent.keyDown(input, { key: "Enter" });
component.rerender(<Combobox {...defaultProps} />);

expect(defaultProps.attributeAssociation?.setValue).toHaveBeenCalledWith([
{ id: "obj_111" },
{ id: "obj_222" }
]);
await waitFor(() => {
expect(component.getAllByRole("option")).toHaveLength(4);
});
expect(input.value).toBe("");
// Quirk: the reducer keeps the index of the picked option in the filtered list, and that
// index points to the first option once the typed text is gone.
expect(highlighted(component)).toBe("obj_111");
});

it("reopens without a highlight after the user left the widget", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = await open(component);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(highlighted(component)).toBe("obj_111");

fireEvent.blur(input);
expect(component.queryAllByRole("option")).toHaveLength(0);
fireEvent.click(input);
await waitFor(() => {
expect(component.getAllByRole("option")).toHaveLength(4);
});

expect(highlighted(component)).toBeUndefined();
});

it("Escape closes the menu and keeps the typed text and the selection", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = await open(component);
type(input, "222");

fireEvent.keyDown(input, { key: "Escape" });

expect(component.queryAllByRole("option")).toHaveLength(0);
expect(defaultProps.attributeAssociation?.setValue).not.toHaveBeenCalled();
// Quirk: unlike single mode, Escape leaves the typed text in place, so the menu reopens filtered.
expect(input.value).toBe("222");
fireEvent.click(input);
await waitFor(() => {
expect(component.getAllByRole("option")).toHaveLength(1);
});
});

it("focus keeps the menu closed", async () => {
const component = render(<Combobox {...defaultProps} />);
const input = await getInput(component);

fireEvent.focus(input);

expect(component.queryAllByRole("option")).toHaveLength(0);
});
});
});
Loading
Loading