diff --git a/docs/requirements/backend-structure.md b/docs/requirements/backend-structure.md index 2dd0d1a307..3f24568a11 100644 --- a/docs/requirements/backend-structure.md +++ b/docs/requirements/backend-structure.md @@ -15,6 +15,7 @@ Although pluggable widgets are primarily client-side components, they operate wi - **textTemplate:** Passed as a DynamicValue. - **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. diff --git a/packages/pluggableWidgets/combobox-web/jest.config.js b/packages/pluggableWidgets/combobox-web/jest.config.js new file mode 100644 index 0000000000..24f6460896 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/jest.config.js @@ -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")] +}; diff --git a/packages/pluggableWidgets/combobox-web/jest.setup.ts b/packages/pluggableWidgets/combobox-web/jest.setup.ts new file mode 100644 index 0000000000..cae07e08e6 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/jest.setup.ts @@ -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); +}; diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/.openspec.yaml b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/.openspec.yaml new file mode 100644 index 0000000000..4bc65f697c --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-10-28 diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/design.md b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/design.md new file mode 100644 index 0000000000..da6356e891 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/design.md @@ -0,0 +1,281 @@ +## Test Cases + +All tests are unit tests at the `Selector` seam unless marked otherwise. They are written against the current code and must pass unchanged on any refactoring of the selectors. Props come from `src/helpers/__tests__/fixtures.ts`; Mendix values come from the builders in `@mendix/widget-plugin-test-utils`. + +### Selector factory (`getSelector.spec.ts`) + +- Routes every Studio Pro configuration to its selector class + - **Given**: props with `source` and `optionsSourceType` for each family (context + enumeration, context + boolean, context + association with `Reference`, context + association with `ReferenceSet`, database + `Single` selection, database + `Multi` selection, database without selection, static) + - **When**: `getSelector(props)` + - **Then**: instance of `EnumBooleanSingleSelector`, `EnumBooleanSingleSelector`, `AssociationSingleSelector`, `AssociationMultiSelector`, `DatabaseSingleSelectionSelector`, `DatabaseMultiSelectionSelector`, `DatabaseSingleSelectionSelector`, `StaticSingleSelector` respectively, with `type` `"single"` or `"multi"` to match +- Rejects unsupported configurations + - **Given**: `source: "context"` with an unknown `optionsSourceType`, or an unknown `source` + - **When**: `getSelector(props)` + - **Then**: throws + +### Association, single (`AssociationSingleSelector.spec.ts`) + +- Exposes the bound value and flags once props are available + - **Given**: `ReferenceValue` with a value, datasource with that item, caption attribute, `clearable: true` + - **When**: `updateProps` + - **Then**: `status` `"available"`, `currentId` is the item id, `readOnly` false, `clearable` true, `validation` undefined, `type` `"single"`, `customContentType` and `loadingType` passed through +- Treats a missing or unavailable reference, datasource or empty-option text as unavailable + - **Given**: one of those three unavailable (including `emptyOptionText: undefined`) + - **When**: `updateProps` + - **Then**: `status` `"unavailable"`, `currentId` null, `clearable` false +- Transitions from unavailable to available + - **Given**: first `updateProps` with a loading reference, second with an available one + - **When**: both calls + - **Then**: status and `currentId` follow the second call +- Passes read-only and validation through + - **Given**: reference built with `isReadOnly()` and `withValidation("Required")` + - **When**: `updateProps` + - **Then**: `readOnly` true, `validation` `"Required"` +- Writes the selection to the reference + - **Given**: available props + - **When**: `setValue(id)` then `setValue(null)` + - **Then**: `attr.setValue` called with the `ObjectItem` for the id, then with `undefined` +- Resolves captions through the attribute and through the expression + - **Given**: caption type `"attribute"`, then `"expression"` + - **When**: `caption.get(id)`, `caption.get(null)`, `caption.get("unknown")` + - **Then**: the caption, the empty-option text, `"<...>"` +- Keeps a caption for a selected item that is not in the datasource page + - **Given**: reference value whose item is not in `ds.items` + - **When**: `updateProps` + - **Then**: `options.getAll()` does not include it, `caption.get(id)` still resolves +- Filters options client-side by caption + - **Given**: four items with captions, `filterType` `"contains"`, `"containsExact"`, `"startsWith"`, `"none"` + - **When**: `options.setSearchTerm(term)` and `options.getAll()` + - **Then**: `contains` and `containsExact` match substrings, `startsWith` matches word starts only, `none` returns every option regardless of the term; an empty term returns options in datasource order +- Notifies after a search-term change + - **Given**: callback registered with `options.onAfterSearchTermChange` + - **When**: `options.setSearchTerm("a")` + - **Then**: callback called once +- Manages the datasource limit + - **Given**: `lazyLoading: false` + - **When**: `updateProps` + - **Then**: `ds.setLimit(undefined)` is called (limit removed) + - **Given**: `lazyLoading: false`, read-only reference + - **Then**: `ds.setLimit(0)` is called (no options loaded for a read-only control) + - **Given**: `lazyLoading: true`, caption attribute, available + - **Then**: `lazyLoading` true on the selector, `ds.setLimit` not called when the limit is already right + - **Given**: `lazyLoading: true` with caption type `"expression"` + - **Then**: `lazyLoading` false (server-side filter needs an attribute) +- Lazy loading loads the next page + - **Given**: `lazyLoading: true`, `ds.hasMoreItems` true, `ds.limit` 100 + - **When**: `options.loadMore()` + - **Then**: `ds.setLimit(200)`, `options.isLoading` true until the next `updateProps` with an available datasource + - **Given**: `ds.hasMoreItems` false + - **Then**: `setLimit` not called +- Lazy loading moves the filter to the datasource (fake timers) + - **Given**: `lazyLoading: true`, `filterInputDebounceInterval` 200 + - **When**: `options.setSearchTerm("ab")`, `options.getAll()`, advance 200 ms + - **Then**: `getAll` returns the unfiltered page, `ds.setFilter` called once after the interval; with an empty term `ds.setFilter(undefined)` +- Reports datasource state + - **Given**: `ds.sortOrder` `[[attr, "desc"]]`, `ds.hasMoreItems` true + - **When**: read `options.sortOrder`, `options.hasMore`, `options.status` + - **Then**: `"desc"`, true, `"available"` +- Renders captions according to custom-content type (unit, React) + - **Given**: `customContentType` `"no"`, `"listItem"`, `"yes"` with a `ListWidgetValue` + - **When**: `caption.render(id, "label")` and `caption.render(id, "options")` + - **Then**: `"no"` renders text in both placements; `"listItem"` renders text for `"label"` and custom content for `"options"`; `"yes"` renders custom content in both; `null` renders the empty text + +### Association, multi (`AssociationMultiSelector.spec.ts`) + +- Exposes the selected ids in reference order + - **Given**: `ReferenceSetValue` with items B, A + - **When**: `updateProps` with `selectedItemsSorting: "none"` + - **Then**: `currentId` `[B, A]` +- Sorts selected ids by caption when configured + - **Given**: same, `selectedItemsSorting: "caption"`, datasource sort order asc then desc + - **Then**: `[A, B]` then `[B, A]` +- Writes the selection to the reference set + - **When**: `setValue([A, B])`, `setValue([])` + - **Then**: `attr.setValue` with the two `ObjectItem`s, then with `[]` +- Hides selected options in row-click mode + - **Given**: `selectionMethod: "rowclick"`, A selected + - **When**: `getOptions()` + - **Then**: A absent; with `"checkbox"` A present +- Reports the three-state select-all value + - **Given**: none selected, some selected, all selected, and no options at all + - **When**: `isOptionsSelected()` + - **Then**: `"none"`, `"some"`, `"all"`, `"some"` +- Forces boxes style for row click and custom content + - **Given**: `selectedItemsStyle: "text"` with `selectionMethod: "rowclick"`, then with `customContentType: "yes"` + - **Then**: `selectedItemsStyle` `"boxes"` in both; stays `"text"` otherwise +- Passes multi-select configuration through + - **Then**: `selectAllButton`, `selectionMethod`, `selectedItemsSorting` equal the props +- Unavailable reference set clears the selection + - **Then**: `status` `"unavailable"`, `currentId` null + +### Enumeration and boolean (`EnumBoolSingleSelector.spec.ts`) + +- Builds options from the attribute universe + - **Given**: enumeration attribute with universe `["red", "green", "blue"]`, value `"green"` + - **When**: `updateProps` + - **Then**: `options.getAll()` `["red", "green", "blue"]`, `currentId` `"green"`, `clearable` from props, `status` `"available"` +- Captions come from the attribute formatter + - **When**: `caption.get("red")`, `caption.get(null)` + - **Then**: `formatter.format("red")`, empty-option text +- Writes enumeration values + - **When**: `setValue("blue")`, `setValue(null)` + - **Then**: `attr.setValue("blue")`, `attr.setValue(undefined)` +- Boolean attribute maps to string options and is never clearable + - **Given**: boolean attribute with universe `[true, false]`, value `true`, `clearable: true` + - **Then**: `options.getAll()` `["true", "false"]`, `currentId` `"true"`, `clearable` false +- Writes boolean values + - **When**: `setValue("false")`, `setValue("true")` + - **Then**: `attr.setValue(false)`, `attr.setValue(true)` +- Prefers the enumeration attribute when both are defined + - **Given**: both `attributeEnumeration` and `attributeBoolean` available + - **Then**: options come from the enumeration universe +- Unavailable attribute or empty-option text + - **Then**: `status` `"unavailable"`, `currentId` null, `clearable` true +- Read-only and validation pass through +- Filters by formatted caption + - **Given**: `filterType: "startsWith"`, term `"gr"` + - **Then**: `["green"]` + +### Static (`StaticSingleSelector.spec.ts`) + +- Options are the datasource indices + - **Given**: three static entries + - **Then**: `options.getAll()` `["0", "1", "2"]`, `caption.get("1")` is the entry caption +- Resolves `currentId` by value equality for string, Big, Date and boolean + - **Given**: attribute value equal to the second entry in each type + - **Then**: `currentId` `"1"` +- Writes the entry value and updates `currentId` synchronously + - **When**: `setValue("2")`, `setValue(null)` + - **Then**: `attr.setValue(entry2.value)` and `currentId` `"2"`; then `attr.setValue(undefined)` and `currentId` null +- Empty caption falls back to the attribute display value + - **Given**: attribute with a display value + - **When**: `caption.get(null)` + - **Then**: the display value; with no display value, the empty-option text +- Boolean attribute is never clearable +- Reports `attributeType` `"boolean"` for a boolean universe, `"date"` for a datetime formatter, `"string"` otherwise +- Unavailable attribute, entry value, entry caption or empty-option text + - **Then**: `status` `"unavailable"`, `currentId` null +- Filters by entry caption for all four filter types +- Quirk, preserved: keeps the previous `currentId` when the attribute value is cleared externally + - **Given**: value matching entry 1, then `updateProps` with value `undefined` + - **Then**: `currentId` stays `"1"` + +### Database, multi (`DatabaseMultiSelectionSelector.spec.ts`) + +- Exposes selected ids from the selection, optionally sorted by caption +- Writes known ids as `ObjectItem`s to the selection + - **When**: `setValue([A, B])` + - **Then**: `selection.setSelection([itemA, itemB])` +- Resolves an id that is in the selection but not in the loaded page + - **Given**: selection contains C, datasource page does not + - **When**: `setValue([C])` + - **Then**: `setSelection([itemC])` +- Drops ids that are known nowhere and warns + - **When**: `setValue(["nope"])` + - **Then**: `setSelection([])`, `console.warn` called +- Row-click hides selected options; `isOptionsSelected` three-state; boxes forced for row click and custom content +- Keeps selection across datasource reloads + - **Then**: `selection.setKeepSelection` called +- Unavailable datasource or empty-option text + - **Then**: `status` `"unavailable"`, `currentId` null, `clearable` false + +### Database, single (additions to `DatabaseSingleSelectionSelector.spec.ts`) + +- Writes the value attribute and the selection + - **When**: `setValue(A)` + - **Then**: `targetAttribute.setValue("v1")`, `selection.setSelection(itemA)`, `currentId` A +- Clears both + - **When**: `setValue(null)` + - **Then**: `targetAttribute.setValue(undefined)`, `setSelection(undefined)`, `currentId` null +- Ignores updates while a non-lazy datasource is loading + - **Given**: `ds.status` `"loading"`, `lazyLoading: false` + - **When**: `updateProps` + - **Then**: `status` stays `"unavailable"`, `setKeepSelection` not called +- Read-only follows `customEditability` + - **Given**: `customEditability: "never"` + - **Then**: `readOnly` true and `ds.setLimit(0)` + +### Options provider filtering (`BaseOptionsProvider.spec.ts`) + +- Each `filterType` against the same caption set, with `searchTerm` empty and non-empty, verifying both the set and the order (datasource order when empty, match-sorter ranking otherwise) +- `setSearchTerm` triggers the registered callback; registering again replaces it +- `hasMore` false, `isLoading` false, `status` `"available"`, `loadMore` no-op + +### Limit arithmetic (`LazyLoadProvider.spec.ts`) + +- `getLimit` table: + - unavailable → 0 + - loading, not lazy → 0 + - loading, lazy, remembered limit 0 or Infinity → 0; remembered 50 → 50 + - available, read-only → 0 + - available, not lazy → undefined + - available, lazy, requested 100 after remembered 100 → 100; requested 50 after remembered 100 → 100; requested 200 → 200 and remembered +- `setLimit` calls `ds.setLimit` only when the value differs from `ds.limit` + +### Datasource filter (`datasourceFilter.spec.ts`) + +- `"none"` or empty search → undefined +- `"contains"` and `"containsExact"` → `contains(attribute(id), literal(search))` +- `"startsWith"` → `startsWith(attribute(id), literal(search))` + +### Hook seam (`useGetSelector.spec.tsx`, React) + +- Returns the same selector instance across re-renders +- Calls `updateProps` with the latest props on every render +- Re-renders the host when the search term changes + - **Given**: render counter + - **When**: `act(() => selector.options.setSearchTerm("a"))` + - **Then**: render count +1 +- Quirk, preserved: `onFilterInputChange` is undefined on the first render and set from the second render on +- Debounces the filter-input action (fake timers) + - **Given**: `onChangeFilterInputEvent` that can execute, interval 200 + - **When**: `onFilterInputChange("a")`, `onFilterInputChange("ab")`, advance 200 ms + - **Then**: `execute` called once with `{ filterInput: "ab" }` +- Sends `undefined` for an empty term +- Skips the action when `canExecute` is false or `isExecuting` is true + +### Views: typed text, highlight and menu (`SingleSelection.spec.tsx`, `MultiSelection.spec.tsx`, React) + +The state reducers in `useDownshiftSingleSelectProps` and `useDownshiftMultiSelectProps` decide what the typed filter text, the highlight and the menu do on each downshift event. The component specs only exercised open, toggle, select and clear, so these cases pin the rest at the DOM, through the widget root. + +- Single: leaving the widget closes the menu and drops the typed text and the filter + - **Given**: menu open, `"222"` typed, one option left + - **When**: blur on the input + - **Then**: no options, input empty; the next open lists all four options +- Single: the toggle button drops the typed text when it closes the menu +- Single: a keyboard selection keeps the input empty and shows the caption next to it + - **Given**: menu open, ArrowDown highlights the second option + - **When**: Enter + - **Then**: `setValue` with that item, menu closed, input empty, placeholder shows the caption +- Single: Escape closes the menu, drops the typed text and keeps the selection, also over a closed menu (downshift would clear the selection there) +- Single: focus keeps the menu closed +- Single: Backspace in the empty input clears the selection when clearable, and does nothing otherwise +- Multi: a keyboard selection keeps the menu open and the highlight on the picked option +- Multi: a selection from a filtered list drops the typed text; the highlight lands on the first option (quirk, see Notes) +- Multi: after leaving the widget the menu reopens without a highlight +- Multi: Escape closes the menu and keeps the selection; the typed text stays (quirk, see Notes) +- Multi: focus keeps the menu closed + +### Jest setup + +`jest.config.js` extends the tools config with `jest.setup.ts`, which turns any `console.error` starting with `downshift:` into a test failure. downshift reports in development when a getter of a mounted hook was not called or its ref was not applied, and it only logs. The suite is clean, so no test changes; a future miss fails the component specs instead of flooding the browser console. + +## Notes + +- Quirks pinned on purpose (see the tests marked `Quirk`): static selector keeps a stale `currentId` on external clear; `useGetSelector` only attaches `onFilterInputChange` from the second render; `isOptionsSelected` returns `"some"` when there are no options; `emptyOptionText: undefined` makes every family unavailable; a read-only control limits its datasource to zero items. Each is a candidate for a deliberate fix in its own change, never a silent one inside a refactoring. +- Quirks pinned in the view cases: in multi mode Escape leaves the typed text in place, so the menu reopens filtered (single mode drops it); after selecting from a filtered list in multi mode, the reducer keeps the index of the picked option within the filtered list, which points to the first option once the text is gone. The single-mode focus branch that means to drop the highlight while a value is selected is not observable: downshift only dispatches the focus event over a closed menu, and its focus changes carry no `selectedItem`. +- `caption.render` is tested lightly. It is a rendering concern and the component tests cover it with their snapshots. +- Preview providers (`helpers/*/Preview/`) stay untested here. They are exercised only by the editor preview, which keeps its own rendering. +- The datasource `setFilter` debounce and the filter-input action debounce are two separate timers with the same interval. Both are covered with fake timers. + +### Quirk severity (rated 2026-10-28, none fixed in this change) + +Real bugs, each to be fixed in its own change by flipping the pinned expectation and deleting the `// Quirk:` comment: + +1. Static selector keeps a stale `currentId` on external clear. Medium. Same class as WC-3355 on the database selector. Needs a changelog line. +2. Chip sorting by caption is descending when the datasource has no sort order. Medium-low. Default `sortOrder` to `"asc"`. +3. Database multi never forces the boxes style for row click or custom content. Low. Changes rendering, so it needs a reviewed snapshot diff; check first whether the editor config already blocks the combination. + +Low, from the view cases (rated 2026-11-03): multi-mode Escape keeping the typed text is arguably a feature, but it differs from single mode; the highlight landing on the first option after a selection from a filtered list is a cosmetic slip, fixed by resolving the index against the unfiltered list. + +Not bugs: association `currentId` while unavailable (nothing renders in that state); missing `emptyOptionText` makes the selector unavailable (Studio Pro always supplies it); read-only limits the datasource to zero (deliberate); filter callback wired from the second render on (no practical effect). diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/proposal.md b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/proposal.md new file mode 100644 index 0000000000..f2b1dff415 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/proposal.md @@ -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. diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/tasks.md b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/tasks.md new file mode 100644 index 0000000000..42d098b6f9 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/combobox-selector-safety-net/tasks.md @@ -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 ` does not filter in this package; the positional argument is treated as a second Jest project. Use `pnpm run test --testPathPatterns `. diff --git a/packages/pluggableWidgets/combobox-web/package.json b/packages/pluggableWidgets/combobox-web/package.json index 3302618163..1dff199d52 100644 --- a/packages/pluggableWidgets/combobox-web/package.json +++ b/packages/pluggableWidgets/combobox-web/package.json @@ -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" }, diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx index ee7264f1b3..ba32ecde94 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx @@ -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 { @@ -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, @@ -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 { + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + 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(); + const input = await getInput(component); + + fireEvent.focus(input); + + expect(component.queryAllByRole("option")).toHaveLength(0); + }); + }); }); diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx b/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx index 70436dba9a..a06f70b9df 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx @@ -13,7 +13,7 @@ import { ReferenceValueBuilder, setupIntersectionObserverStub } from "@mendix/widget-plugin-test-utils"; -import { ComboboxContainerProps, OptionsSourceAssociationCaptionTypeEnum } from "../../typings/ComboboxProps"; +import { ComboboxContainerProps } from "../../typings/ComboboxProps"; import Combobox from "../Combobox"; // function helper to ease DOM changes in development @@ -172,10 +172,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, @@ -197,4 +197,113 @@ describe("Combo box (Association)", () => { }); }); }); + + describe("typed text, highlight and menu state", () => { + // The state reducer in useDownshiftSingleSelectProps 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 { + 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("drops the typed text and the filter when the user leaves the widget", async () => { + const component = render(); + const input = await open(component); + type(input, "222"); + await waitFor(() => { + expect(component.getAllByRole("option")).toHaveLength(1); + }); + + fireEvent.blur(input); + + expect(component.queryAllByRole("option")).toHaveLength(0); + expect(input.value).toBe(""); + fireEvent.click(input); + await waitFor(() => { + expect(component.getAllByRole("option")).toHaveLength(4); + }); + }); + + it("drops the typed text when the toggle button closes the menu", async () => { + const component = render(); + const input = await open(component); + type(input, "222"); + + fireEvent.click(await getToggleButton(component)); + + expect(component.queryAllByRole("option")).toHaveLength(0); + expect(input.value).toBe(""); + }); + + it("keeps the input empty after a keyboard selection and shows the caption next to it", async () => { + const component = render(); + const input = await open(component); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(highlighted(component)).toBe("obj_222"); + + fireEvent.keyDown(input, { key: "Enter" }); + component.rerender(); + + expect(defaultProps.attributeAssociation?.setValue).toHaveBeenCalledWith({ id: "obj_222" }); + expect(component.queryAllByRole("option")).toHaveLength(0); + expect(input.value).toBe(""); + expect((await getVisibleValueNode(component)).textContent).toBe("obj_222"); + }); + + it("Escape closes the menu, drops the typed text and keeps the selection", async () => { + const component = render(); + const input = await open(component); + type(input, "222"); + + fireEvent.keyDown(input, { key: "Escape" }); + + expect(component.queryAllByRole("option")).toHaveLength(0); + expect(input.value).toBe(""); + expect((await getVisibleValueNode(component)).textContent).toBe("obj_111"); + expect(defaultProps.attributeAssociation?.setValue).not.toHaveBeenCalled(); + + // downshift clears the selection on Escape over a closed menu; the reducer keeps it. + fireEvent.keyDown(input, { key: "Escape" }); + expect(defaultProps.attributeAssociation?.setValue).not.toHaveBeenCalled(); + }); + + it("focus keeps the menu closed", async () => { + const component = render(); + const input = await getInput(component); + + fireEvent.focus(input); + + expect(component.queryAllByRole("option")).toHaveLength(0); + }); + + it("Backspace in the empty input clears the selection when clearable", async () => { + const component = render(); + const input = await getInput(component); + + fireEvent.keyDown(input, { key: "Backspace" }); + + expect(defaultProps.attributeAssociation?.setValue).toHaveBeenCalledWith(undefined); + }); + + it("Backspace in the empty input keeps the selection when not clearable", async () => { + const component = render(); + const input = await getInput(component); + + fireEvent.keyDown(input, { key: "Backspace" }); + + expect(defaultProps.attributeAssociation?.setValue).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationMultiSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationMultiSelector.spec.ts new file mode 100644 index 0000000000..95e4c533f8 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationMultiSelector.spec.ts @@ -0,0 +1,144 @@ +import { ListValue } from "mendix"; +import { list, ListValueBuilder, ReferenceSetValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { allItems, associationMultiProps, captionAttributeId, itemA, itemB, itemC } from "../../__tests__/fixtures"; +import { AssociationMultiSelector } from "../AssociationMultiSelector"; + +function createSelector(): AssociationMultiSelector { + return new AssociationMultiSelector({ filterInputDebounceInterval: 200 }); +} + +function sortedList(direction: "asc" | "desc"): ListValue { + return new ListValueBuilder() + .withItems(allItems) + .withSortOrder([[captionAttributeId, direction]]) + .build(); +} + +describe("AssociationMultiSelector", () => { + it("exposes the selected ids in reference order", () => { + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ attr: new ReferenceSetValueBuilder().withValue([itemB, itemA]).build() }) + ); + + expect(selector.type).toBe("multi"); + expect(selector.status).toBe("available"); + expect(selector.currentId).toEqual([itemB.id, itemA.id]); + }); + + it("sorts the selected ids by caption following the datasource sort order", () => { + const attr = new ReferenceSetValueBuilder().withValue([itemB, itemA]).build(); + + const asc = createSelector(); + asc.updateProps(associationMultiProps({ attr, ds: sortedList("asc"), selectedItemsSorting: "caption" })); + expect(asc.currentId).toEqual([itemA.id, itemB.id]); + + const desc = createSelector(); + desc.updateProps(associationMultiProps({ attr, ds: sortedList("desc"), selectedItemsSorting: "caption" })); + expect(desc.currentId).toEqual([itemB.id, itemA.id]); + }); + + it("sorts descending by caption when the datasource has no sort order", () => { + // Quirk: sortOrder is undefined without a datasource sort, and only "asc" sorts ascending. + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ + attr: new ReferenceSetValueBuilder().withValue([itemA, itemB]).build(), + selectedItemsSorting: "caption" + }) + ); + + expect(selector.options.sortOrder).toBeUndefined(); + expect(selector.currentId).toEqual([itemB.id, itemA.id]); + }); + + it("writes the selection to the reference set", () => { + const attr = new ReferenceSetValueBuilder().withValue([itemA]).build(); + const selector = createSelector(); + selector.updateProps(associationMultiProps({ attr })); + + selector.setValue([itemA.id, itemB.id]); + expect(attr.setValue).toHaveBeenLastCalledWith([itemA, itemB]); + + selector.setValue([]); + expect(attr.setValue).toHaveBeenLastCalledWith([]); + }); + + it("hides selected options in row-click mode only", () => { + const attr = new ReferenceSetValueBuilder().withValue([itemA]).build(); + + const rowClick = createSelector(); + rowClick.updateProps(associationMultiProps({ attr, selectionMethod: "rowclick" })); + expect(rowClick.getOptions()).toEqual([itemB.id, itemC.id, allItems[3].id]); + + const checkbox = createSelector(); + checkbox.updateProps(associationMultiProps({ attr, selectionMethod: "checkbox" })); + expect(checkbox.getOptions()).toEqual(allItems.map(i => i.id)); + }); + + it.each([ + ["none", [], "none"], + ["some", [itemA], "some"], + ["all", allItems, "all"] + ] as const)("reports '%s' selected for the select-all checkbox", (_label, selected, expected) => { + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ attr: new ReferenceSetValueBuilder().withValue([...selected]).build() }) + ); + + expect(selector.isOptionsSelected()).toBe(expected); + }); + + it("reports 'some' when there are no options at all", () => { + // Quirk: an empty option list with nothing selected reads as "some", not "none". + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ attr: new ReferenceSetValueBuilder().withValue([]).build(), ds: list([]) }) + ); + + expect(selector.isOptionsSelected()).toBe("some"); + }); + + it("forces the boxes style for row click and for custom content", () => { + const rowClick = createSelector(); + rowClick.updateProps(associationMultiProps({ selectedItemsStyle: "text", selectionMethod: "rowclick" })); + expect(rowClick.selectedItemsStyle).toBe("boxes"); + + const customContent = createSelector(); + customContent.updateProps( + associationMultiProps({ selectedItemsStyle: "text", optionsSourceAssociationCustomContentType: "yes" }) + ); + expect(customContent.selectedItemsStyle).toBe("boxes"); + + const plain = createSelector(); + plain.updateProps(associationMultiProps({ selectedItemsStyle: "text" })); + expect(plain.selectedItemsStyle).toBe("text"); + }); + + it("passes multi-select configuration through", () => { + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ + selectAllButton: true, + selectionMethod: "checkbox", + selectedItemsSorting: "caption" + }) + ); + + expect(selector.selectAllButton).toBe(true); + expect(selector.selectionMethod).toBe("checkbox"); + expect(selector.selectedItemsSorting).toBe("caption"); + }); + + it("has no selection while the reference set is unavailable", () => { + const selector = createSelector(); + selector.updateProps( + associationMultiProps({ attr: new ReferenceSetValueBuilder().withValue([itemA]).isUnavailable().build() }) + ); + + expect(selector.status).toBe("unavailable"); + expect(selector.clearable).toBe(false); + // currentId is recomputed from the (unavailable) reference value after the base class clears it. + expect(selector.currentId).toEqual([itemA.id]); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationSingleSelector.spec.tsx b/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationSingleSelector.spec.tsx new file mode 100644 index 0000000000..bfc4d43e6b --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/Association/__tests__/AssociationSingleSelector.spec.tsx @@ -0,0 +1,359 @@ +import { render } from "@testing-library/react"; +import { ListValue } from "mendix"; +import { dynamic, list, ListValueBuilder, listWidget, ReferenceValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { + allItems, + associationSingleProps, + captionAttributeId, + captions, + itemA, + itemB, + itemC, + itemD +} from "../../__tests__/fixtures"; +import { AssociationSingleSelector } from "../AssociationSingleSelector"; + +function createSelector(): AssociationSingleSelector { + return new AssociationSingleSelector({ filterInputDebounceInterval: 200 }); +} + +function unavailableList(): ListValue { + return new ListValueBuilder().withItems(allItems).isUnavailable().build(); +} + +describe("AssociationSingleSelector", () => { + describe("status and value", () => { + it("exposes the bound value and flags once props are available", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ clearable: true, loadingType: "skeleton" })); + + expect(selector.type).toBe("single"); + expect(selector.status).toBe("available"); + expect(selector.currentId).toBe(itemA.id); + expect(selector.readOnly).toBe(false); + expect(selector.clearable).toBe(true); + expect(selector.validation).toBeUndefined(); + expect(selector.customContentType).toBe("no"); + expect(selector.loadingType).toBe("skeleton"); + }); + + it("exposes no value when the reference is empty", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ attr: new ReferenceValueBuilder().build() })); + + expect(selector.status).toBe("available"); + expect(selector.currentId).toBeNull(); + }); + + it("is unavailable while the reference is unavailable", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ attr: new ReferenceValueBuilder().withValue(itemA).isUnavailable().build() }) + ); + + expect(selector.status).toBe("unavailable"); + // Quirk: the single selector still mirrors the reference id while unavailable + // (the base class clears it, the subclass overwrites it). Nothing renders in this status. + expect(selector.currentId).toBe(itemA.id); + expect(selector.clearable).toBe(false); + }); + + it("is unavailable while the datasource is unavailable", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds: unavailableList() })); + + expect(selector.status).toBe("unavailable"); + expect(selector.clearable).toBe(false); + }); + + it("is unavailable when the empty-option text is unavailable or not configured", () => { + const withUnavailable = createSelector(); + withUnavailable.updateProps(associationSingleProps({ emptyOptionText: dynamic.unavailable() })); + expect(withUnavailable.status).toBe("unavailable"); + + // Quirk: a missing emptyOptionText makes the whole selector unavailable. + const withMissing = createSelector(); + withMissing.updateProps(associationSingleProps({ emptyOptionText: undefined })); + expect(withMissing.status).toBe("unavailable"); + }); + + it("reports 'loading' while the reference is loading", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ attr: new ReferenceValueBuilder().isLoading().build() })); + + expect(selector.status).toBe("loading"); + }); + + it("transitions from unavailable to available", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ attr: new ReferenceValueBuilder().withValue(itemB).isUnavailable().build() }) + ); + expect(selector.status).toBe("unavailable"); + + selector.updateProps( + associationSingleProps({ attr: new ReferenceValueBuilder().withValue(itemB).build() }) + ); + + expect(selector.status).toBe("available"); + expect(selector.currentId).toBe(itemB.id); + }); + + it("passes read-only and validation through", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ + attr: new ReferenceValueBuilder().withValue(itemA).isReadOnly().withValidation("Required").build() + }) + ); + + expect(selector.readOnly).toBe(true); + expect(selector.validation).toBe("Required"); + }); + + it("writes the selection to the reference", () => { + const attr = new ReferenceValueBuilder().withValue(itemA).build(); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ attr })); + + selector.setValue(itemC.id); + expect(attr.setValue).toHaveBeenLastCalledWith(itemC); + + selector.setValue(null); + expect(attr.setValue).toHaveBeenLastCalledWith(undefined); + }); + }); + + describe("captions", () => { + it("resolves captions through the caption attribute", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ captionType: "attribute" })); + + expect(selector.caption.get(itemB.id)).toBe("Banana"); + expect(selector.caption.get(null)).toBe("Select..."); + expect(selector.caption.get("unknown")).toBe("<...>"); + expect(selector.caption.emptyCaption).toBe("Select..."); + }); + + it("resolves captions through the caption expression", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ captionType: "expression" })); + + expect(selector.caption.get(itemB.id)).toBe("expr:Banana"); + }); + + it("keeps a caption for a selected item that is not in the datasource page", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ + attr: new ReferenceValueBuilder().withValue(itemD).build(), + ds: list([itemA, itemB]) + }) + ); + + expect(selector.currentId).toBe(itemD.id); + expect(selector.options.getAll()).toEqual([itemA.id, itemB.id]); + expect(selector.caption.get(itemD.id)).toBe("Date"); + }); + + it("renders plain text in every placement without custom content", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps()); + + const label = render(<>{selector.caption.render(itemA.id, "label")}); + expect(label.container.textContent).toBe("Apple"); + expect(label.container.querySelector(".widget-combobox-caption-text")).not.toBeNull(); + + const option = render(<>{selector.caption.render(itemA.id, "options")}); + expect(option.container.textContent).toBe("Apple"); + }); + + it("renders custom content in the options only for 'listItem'", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ + optionsSourceAssociationCustomContentType: "listItem", + optionsSourceAssociationCustomContent: listWidget(i => {`custom ${captions[i.id]}`}) + }) + ); + + const label = render(<>{selector.caption.render(itemA.id, "label")}); + expect(label.container.textContent).toBe("Apple"); + + const option = render(<>{selector.caption.render(itemA.id, "options")}); + expect(option.container.textContent).toBe("custom Apple"); + expect(option.container.querySelector(".widget-combobox-caption-custom")).not.toBeNull(); + }); + + it("renders custom content everywhere for 'yes', except for the empty value", () => { + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ + optionsSourceAssociationCustomContentType: "yes", + optionsSourceAssociationCustomContent: listWidget(i => {`custom ${captions[i.id]}`}) + }) + ); + + expect(render(<>{selector.caption.render(itemA.id, "label")}).container.textContent).toBe( + "custom Apple" + ); + expect(render(<>{selector.caption.render(itemA.id, "options")}).container.textContent).toBe( + "custom Apple" + ); + expect(render(<>{selector.caption.render(null, "label")}).container.textContent).toBe("Select..."); + }); + }); + + describe("options and client-side filtering", () => { + it("lists options in datasource order", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps()); + + expect(selector.options.getAll()).toEqual(allItems.map(i => i.id)); + expect(selector.options.status).toBe("available"); + expect(selector.options.hasMore).toBe(false); + expect(selector.options.isLoading).toBe(false); + }); + + it.each([ + ["contains", "an", [itemB.id]], + ["containsExact", "AN", [itemB.id]], + ["startsWith", "ch", [itemC.id]], + ["none", "zzz", allItems.map(i => i.id)] + ] as const)("filters by caption with filterType '%s'", (filterType, term, expected) => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ filterType })); + + selector.options.setSearchTerm(term); + + expect(selector.options.getAll()).toEqual(expected); + }); + + it("notifies after a search-term change", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps()); + const callback = jest.fn(); + selector.options.onAfterSearchTermChange(callback); + + selector.options.setSearchTerm("a"); + + expect(callback).toHaveBeenCalledTimes(1); + expect(selector.options.searchTerm).toBe("a"); + }); + + it("reports the datasource sort order", () => { + const ds = new ListValueBuilder() + .withItems(allItems) + .withSortOrder([[captionAttributeId, "desc"]]) + .build(); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds })); + + expect(selector.options.sortOrder).toBe("desc"); + }); + }); + + describe("datasource limit", () => { + it("removes the datasource limit when lazy loading is off", () => { + const ds = list(allItems); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: false })); + + expect(ds.setLimit).toHaveBeenCalledWith(undefined); + expect(selector.lazyLoading).toBe(false); + }); + + it("limits the datasource to zero items for a read-only control", () => { + const ds = list(allItems); + const selector = createSelector(); + selector.updateProps( + associationSingleProps({ + ds, + attr: new ReferenceValueBuilder().withValue(itemA).isReadOnly().build() + }) + ); + + expect(ds.setLimit).toHaveBeenCalledWith(0); + }); + + it("keeps the datasource limit when lazy loading is on and the limit is already right", () => { + const ds = list(allItems); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + + expect(selector.lazyLoading).toBe(true); + expect(ds.setLimit).not.toHaveBeenCalled(); + }); + + it("disables lazy loading when captions come from an expression", () => { + const selector = createSelector(); + selector.updateProps(associationSingleProps({ lazyLoading: true, captionType: "expression" })); + + expect(selector.lazyLoading).toBe(false); + }); + }); + + describe("lazy loading", () => { + function lazyList(limit: number, hasMore: boolean, loading = false): ListValue { + const builder = new ListValueBuilder().withItems(allItems).withLimit(limit).withHasMore(hasMore); + return (loading ? builder.isLoading() : builder).build(); + } + + it("loads the next page of 100 and reports loading until the datasource settles", () => { + const ds = lazyList(100, true); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + + expect(selector.options.hasMore).toBe(true); + selector.options.loadMore!(); + + expect(ds.setLimit).toHaveBeenCalledWith(200); + expect(selector.options.isLoading).toBe(true); + + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + expect(selector.options.isLoading).toBe(false); + }); + + it("does nothing on loadMore when there are no more items", () => { + const ds = lazyList(100, false); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + + selector.options.loadMore!(); + + expect(ds.setLimit).not.toHaveBeenCalled(); + }); + + it("reports loading while a lazy datasource is loading", () => { + const ds = lazyList(100, true, true); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + + expect(selector.options.isLoading).toBe(true); + }); + + it("moves the search term to a debounced datasource filter instead of filtering client-side", () => { + jest.useFakeTimers(); + try { + const ds = lazyList(100, true); + const selector = createSelector(); + selector.updateProps(associationSingleProps({ ds, lazyLoading: true })); + + selector.options.setSearchTerm("an"); + expect(selector.options.getAll()).toEqual(allItems.map(i => i.id)); + expect(ds.setFilter).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + expect(ds.setFilter).toHaveBeenCalledTimes(1); + + selector.options.setSearchTerm(""); + selector.options.getAll(); + jest.advanceTimersByTime(200); + expect(ds.setFilter).toHaveBeenCalledTimes(2); + expect(ds.setFilter).toHaveBeenLastCalledWith(undefined); + } finally { + jest.useRealTimers(); + } + }); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseMultiSelectionSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseMultiSelectionSelector.spec.ts new file mode 100644 index 0000000000..3f0f2e1cef --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseMultiSelectionSelector.spec.ts @@ -0,0 +1,170 @@ +import { ListValue } from "mendix"; +import { dynamic, list, ListValueBuilder, SelectionMultiValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { allItems, captionAttributeId, databaseMultiProps, itemA, itemB, itemC, itemD } from "../../__tests__/fixtures"; +import { DatabaseMultiSelectionSelector } from "../DatabaseMultiSelectionSelector"; + +function createSelector(): DatabaseMultiSelectionSelector { + return new DatabaseMultiSelectionSelector({ filterInputDebounceInterval: 200 }); +} + +function sortedList(direction: "asc" | "desc"): ListValue { + return new ListValueBuilder() + .withItems(allItems) + .withSortOrder([[captionAttributeId, direction]]) + .build(); +} + +describe("DatabaseMultiSelectionSelector", () => { + it("exposes the selected ids from the selection", () => { + const selector = createSelector(); + selector.updateProps( + databaseMultiProps({ selection: new SelectionMultiValueBuilder().withSelected([itemB, itemA]).build() }) + ); + + expect(selector.type).toBe("multi"); + expect(selector.status).toBe("available"); + expect(selector.currentId).toEqual([itemB.id, itemA.id]); + expect(selector.caption.get(itemB.id)).toBe("Banana"); + }); + + it("sorts the selected ids by caption following the datasource sort order", () => { + const selection = new SelectionMultiValueBuilder().withSelected([itemB, itemA]).build(); + + const asc = createSelector(); + asc.updateProps(databaseMultiProps({ selection, ds: sortedList("asc"), selectedItemsSorting: "caption" })); + expect(asc.currentId).toEqual([itemA.id, itemB.id]); + + const desc = createSelector(); + desc.updateProps(databaseMultiProps({ selection, ds: sortedList("desc"), selectedItemsSorting: "caption" })); + expect(desc.currentId).toEqual([itemB.id, itemA.id]); + }); + + it("writes known ids as objects to the selection", () => { + const selection = new SelectionMultiValueBuilder().withSelected([itemA]).build(); + const setSelection = jest.spyOn(selection, "setSelection"); + const selector = createSelector(); + selector.updateProps(databaseMultiProps({ selection })); + + selector.setValue([itemA.id, itemB.id]); + + expect(setSelection).toHaveBeenLastCalledWith([itemA, itemB]); + }); + + it("resolves an id that is in the selection but not in the loaded page", () => { + const selection = new SelectionMultiValueBuilder().withSelected([itemC]).build(); + const setSelection = jest.spyOn(selection, "setSelection"); + const selector = createSelector(); + selector.updateProps(databaseMultiProps({ selection, ds: list([itemA, itemB]) })); + + selector.setValue([itemC.id, itemA.id]); + + expect(setSelection).toHaveBeenLastCalledWith([itemC, itemA]); + }); + + it("drops ids that are known nowhere and warns", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + try { + const selection = new SelectionMultiValueBuilder().withSelected([]).build(); + const setSelection = jest.spyOn(selection, "setSelection"); + const selector = createSelector(); + selector.updateProps(databaseMultiProps({ selection })); + + selector.setValue(["nope", itemA.id]); + + expect(setSelection).toHaveBeenLastCalledWith([itemA]); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + it("hides selected options in row-click mode only", () => { + const selection = new SelectionMultiValueBuilder().withSelected([itemA]).build(); + + const rowClick = createSelector(); + rowClick.updateProps(databaseMultiProps({ selection, selectionMethod: "rowclick" })); + expect(rowClick.getOptions()).toEqual([itemB.id, itemC.id, itemD.id]); + + const checkbox = createSelector(); + checkbox.updateProps(databaseMultiProps({ selection, selectionMethod: "checkbox" })); + expect(checkbox.getOptions()).toEqual(allItems.map(i => i.id)); + }); + + it.each([ + ["none", [], "none"], + ["some", [itemA], "some"], + ["all", allItems, "all"] + ] as const)("reports '%s' selected for the select-all checkbox", (_label, selected, expected) => { + const selector = createSelector(); + selector.updateProps( + databaseMultiProps({ selection: new SelectionMultiValueBuilder().withSelected([...selected]).build() }) + ); + + expect(selector.isOptionsSelected()).toBe(expected); + }); + + it("does not force the boxes style for row click or custom content", () => { + // Quirk: unlike the association selector, the props value wins over the forcing rule here, + // because the rule runs before the props are copied. + const rowClick = createSelector(); + rowClick.updateProps(databaseMultiProps({ selectedItemsStyle: "text", selectionMethod: "rowclick" })); + rowClick.updateProps(databaseMultiProps({ selectedItemsStyle: "text", selectionMethod: "rowclick" })); + expect(rowClick.selectedItemsStyle).toBe("text"); + + const customContent = createSelector(); + customContent.updateProps( + databaseMultiProps({ selectedItemsStyle: "text", optionsSourceDatabaseCustomContentType: "yes" }) + ); + customContent.updateProps( + databaseMultiProps({ selectedItemsStyle: "text", optionsSourceDatabaseCustomContentType: "yes" }) + ); + expect(customContent.selectedItemsStyle).toBe("text"); + }); + + it("passes configuration through and keeps the selection across reloads", () => { + const selection = new SelectionMultiValueBuilder().withSelected([itemA]).build(); + const keep = jest.spyOn(selection, "setKeepSelection"); + const selector = createSelector(); + selector.updateProps( + databaseMultiProps({ + selection, + selectAllButton: true, + selectedItemsSorting: "caption", + clearable: true, + optionsSourceDatabaseCustomContentType: "listItem", + lazyLoading: true, + loadingType: "skeleton" + }) + ); + + expect(keep).toHaveBeenCalledTimes(1); + expect(selector.selectAllButton).toBe(true); + expect(selector.selectedItemsSorting).toBe("caption"); + expect(selector.clearable).toBe(true); + expect(selector.customContentType).toBe("listItem"); + expect(selector.lazyLoading).toBe(true); + expect(selector.loadingType).toBe("skeleton"); + expect(selector.readOnly).toBe(false); + }); + + it("removes the datasource limit when lazy loading is off", () => { + const ds = list(allItems); + const selector = createSelector(); + selector.updateProps(databaseMultiProps({ ds, lazyLoading: false })); + + expect(ds.setLimit).toHaveBeenCalledWith(undefined); + }); + + it("is unavailable while the datasource or the empty-option text is unavailable", () => { + const ds = new ListValueBuilder().withItems(allItems).isUnavailable().build(); + const noDatasource = createSelector(); + noDatasource.updateProps(databaseMultiProps({ ds })); + expect(noDatasource.status).toBe("unavailable"); + expect(noDatasource.currentId).toBeNull(); + expect(noDatasource.clearable).toBe(false); + + const noEmptyText = createSelector(); + noEmptyText.updateProps(databaseMultiProps({ emptyOptionText: dynamic.unavailable() })); + expect(noEmptyText.status).toBe("unavailable"); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts index 1b9763fec8..2f792f71cd 100644 --- a/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts +++ b/packages/pluggableWidgets/combobox-web/src/helpers/Database/__tests__/DatabaseSingleSelectionSelector.spec.ts @@ -1,5 +1,6 @@ -import { ListAttributeValue, ObjectItem } from "mendix"; +import { ListAttributeValue, ListValue, ObjectItem } from "mendix"; import { + attrId, dynamic, EditableValueBuilder, list, @@ -14,27 +15,25 @@ type PropsOverrides = { items: ObjectItem[]; values: Map; targetValue?: string; + validation?: string; selection?: ReturnType; + ds?: ListValue; }; -function buildProps({ items, values, targetValue, selection }: PropsOverrides): ComboboxContainerProps { - const valueAttr = listAttribute(item => values.get(item.id) ?? "") as ListAttributeValue; - (valueAttr as unknown as { id: string }).id = "valueAttrId" as any; +function buildProps({ items, values, targetValue, validation, selection, ds }: PropsOverrides): ComboboxContainerProps { + const valueAttr = listAttribute(item => values.get(item.id) ?? "", attrId("value")) as ListAttributeValue< + string | Big + >; + const captionAttr = listAttribute(item => `caption_${item.id}`, attrId("caption")); - const captionAttr = listAttribute(item => `caption_${item.id}`); - (captionAttr as unknown as { id: string }).id = "captionAttrId" as any; - - const targetAttr = - targetValue === undefined - ? new EditableValueBuilder().build() - : new EditableValueBuilder().withValue(targetValue).build(); + const targetAttr = new EditableValueBuilder().withValue(targetValue).withValidation(validation).build(); return { name: "comboBox", id: "comboBox1", source: "database", optionsSourceType: "association", - optionsSourceDatabaseDataSource: list(items), + optionsSourceDatabaseDataSource: ds ?? list(items), optionsSourceDatabaseItemSelection: selection ?? new SelectionSingleValueBuilder().build(), optionsSourceDatabaseCaptionType: "attribute", optionsSourceDatabaseCaptionAttribute: captionAttr, @@ -138,3 +137,74 @@ describe("DatabaseSingleSelectionSelector.updateProps — external target-attrib expect(selector.currentId).toBe(optionA.id); }); }); + +describe("DatabaseSingleSelectionSelector — writing and read-only", () => { + const optionA = obj("A"); + const optionB = obj("B"); + const items = [optionA, optionB]; + const values = new Map([ + [optionA.id, "v1"], + [optionB.id, "v2"] + ]); + + it("writes the value attribute and the selection", () => { + const selection = new SelectionSingleValueBuilder().build(); + const setSelection = jest.spyOn(selection, "setSelection"); + const props = buildProps({ items, values, selection }); + const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 }); + selector.updateProps(props); + + selector.setValue(optionA.id); + + expect(props.databaseAttributeString!.setValue).toHaveBeenLastCalledWith("v1"); + expect(setSelection).toHaveBeenLastCalledWith(optionA); + expect(selector.currentId).toBe(optionA.id); + }); + + it("clears the value attribute and the selection", () => { + const selection = new SelectionSingleValueBuilder().withSelected(optionA).build(); + const setSelection = jest.spyOn(selection, "setSelection"); + const props = buildProps({ items, values, targetValue: "v1", selection }); + const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 }); + selector.updateProps(props); + expect(selector.currentId).toBe(optionA.id); + + selector.setValue(null); + + expect(props.databaseAttributeString!.setValue).toHaveBeenLastCalledWith(undefined); + expect(setSelection).toHaveBeenLastCalledWith(undefined); + expect(selector.currentId).toBeNull(); + }); + + it("ignores updates while a non-lazy datasource is loading", () => { + const props = buildProps({ items, values, targetValue: "v1", ds: list.loading() }); + const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 }); + + selector.updateProps(props); + + expect(selector.status).toBe("unavailable"); + expect(selector.currentId).toBeNull(); + }); + + it("is read-only and loads no options when custom editability is 'never'", () => { + const props = { ...buildProps({ items, values, targetValue: "v1" }), customEditability: "never" as const }; + const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 }); + + selector.updateProps(props); + + expect(selector.readOnly).toBe(true); + expect(props.optionsSourceDatabaseDataSource!.setLimit).toHaveBeenCalledWith(0); + }); + + it("resolves captions and passes validation and clearable through", () => { + const props = buildProps({ items, values, targetValue: "v1", validation: "Required" }); + const selector = new DatabaseSingleSelectionSelector({ filterInputDebounceInterval: 200 }); + + selector.updateProps(props); + + expect(selector.caption.get(optionA.id)).toBe(`caption_${optionA.id}`); + expect(selector.caption.get(null)).toBe("Formatted v1"); + expect(selector.validation).toBe("Required"); + expect(selector.clearable).toBe(true); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/EnumBool/__tests__/EnumBoolSingleSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/EnumBool/__tests__/EnumBoolSingleSelector.spec.ts new file mode 100644 index 0000000000..69dab47077 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/EnumBool/__tests__/EnumBoolSingleSelector.spec.ts @@ -0,0 +1,113 @@ +import { dynamic, EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { booleanProps, enumerationProps } from "../../__tests__/fixtures"; +import { EnumBooleanSingleSelector } from "../EnumBoolSingleSelector"; + +function enumAttribute(): EditableValueBuilder { + return new EditableValueBuilder().withUniverse("red", "green", "blue").withValue("green"); +} + +describe("EnumBooleanSingleSelector with an enumeration", () => { + it("builds options from the attribute universe", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(enumAttribute().build(), { clearable: true })); + + expect(selector.type).toBe("single"); + expect(selector.status).toBe("available"); + expect(selector.options.getAll()).toEqual(["red", "green", "blue"]); + expect(selector.currentId).toBe("green"); + expect(selector.clearable).toBe(true); + expect(selector.readOnly).toBe(false); + }); + + it("takes captions from the attribute formatter", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps()); + + expect(selector.caption.get("red")).toBe("Formatted red"); + expect(selector.caption.get(null)).toBe("Select..."); + }); + + it("writes enumeration values", () => { + const attr = enumAttribute().build(); + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(attr)); + + selector.setValue("blue"); + expect(attr.setValue).toHaveBeenLastCalledWith("blue"); + + selector.setValue(null); + expect(attr.setValue).toHaveBeenLastCalledWith(undefined); + }); + + it("prefers the enumeration attribute when both attributes are defined", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps( + enumerationProps(enumAttribute().build(), { + attributeBoolean: new EditableValueBuilder().withUniverse(true, false).build() + }) + ); + + expect(selector.options.getAll()).toEqual(["red", "green", "blue"]); + }); + + it("is unavailable while the attribute is unavailable", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(enumAttribute().isUnavailable().build(), { clearable: false })); + + expect(selector.status).toBe("unavailable"); + expect(selector.currentId).toBeNull(); + // Quirk: clearable resets to true, not false, while unavailable. + expect(selector.clearable).toBe(true); + }); + + it("is unavailable when the empty-option text is unavailable", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(enumAttribute().build(), { emptyOptionText: dynamic.unavailable() })); + + expect(selector.status).toBe("unavailable"); + }); + + it("passes read-only and validation through", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(enumAttribute().isReadOnly().withValidation("Pick one").build())); + + expect(selector.readOnly).toBe(true); + expect(selector.validation).toBe("Pick one"); + }); + + it("filters by formatted caption", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(enumerationProps(enumAttribute().build(), { filterType: "contains" })); + + selector.options.setSearchTerm("gr"); + + expect(selector.options.getAll()).toEqual(["green"]); + }); +}); + +describe("EnumBooleanSingleSelector with a boolean", () => { + function booleanAttribute(value: boolean): EditableValueBuilder { + return new EditableValueBuilder().withUniverse(true, false).withValue(value); + } + + it("maps the universe to string options and is never clearable", () => { + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(booleanProps(booleanAttribute(true).build(), { clearable: true })); + + expect(selector.options.getAll()).toEqual(["true", "false"]); + expect(selector.currentId).toBe("true"); + expect(selector.clearable).toBe(false); + }); + + it("writes boolean values", () => { + const attr = booleanAttribute(true).build(); + const selector = new EnumBooleanSingleSelector(); + selector.updateProps(booleanProps(attr)); + + selector.setValue("false"); + expect(attr.setValue).toHaveBeenLastCalledWith(false); + + selector.setValue("true"); + expect(attr.setValue).toHaveBeenLastCalledWith(true); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/Static/__tests__/StaticSingleSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/Static/__tests__/StaticSingleSelector.spec.ts new file mode 100644 index 0000000000..8eb626e31a --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/Static/__tests__/StaticSingleSelector.spec.ts @@ -0,0 +1,162 @@ +import { Big } from "big.js"; +import { EditableValue } from "mendix"; +import { dynamic, EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { staticEntries, staticEntry, staticProps } from "../../__tests__/fixtures"; +import { StaticSingleSelector } from "../StaticSingleSelector"; + +type StaticValue = string | Big | boolean | Date; + +function attribute(value?: StaticValue): EditableValueBuilder { + const builder = new EditableValueBuilder().withFormatter({ + format: (v?: StaticValue) => (v === undefined ? "" : String(v)), + parse: jest.fn(), + type: "string" + } as unknown as EditableValue["formatter"]); + return value === undefined ? builder : builder.withValue(value); +} + +describe("StaticSingleSelector", () => { + it("uses the datasource indices as option ids", () => { + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attribute().build())); + + expect(selector.type).toBe("single"); + expect(selector.selectorType).toBe("static"); + expect(selector.status).toBe("available"); + expect(selector.options.getAll()).toEqual(["0", "1", "2"]); + expect(selector.caption.get("1")).toBe("Two"); + expect(selector.caption.get("9")).toBe("<...>"); + expect(selector.currentId).toBeNull(); + }); + + it.each([ + ["string", "two", staticEntries], + ["Big", new Big(2), [staticEntry(new Big(1), "1"), staticEntry(new Big(2), "2")]], + [ + "Date", + new Date(2000, 1, 2), + [staticEntry(new Date(2000, 1, 1), "1st"), staticEntry(new Date(2000, 1, 2), "2nd")] + ], + ["boolean", false, [staticEntry(true, "Yes"), staticEntry(false, "No")]] + ] as const)("resolves currentId by value equality for %s", (_type, value, entries) => { + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attribute(value).build(), { optionsSourceStaticDataSource: [...entries] })); + + expect(selector.currentId).toBe("1"); + }); + + it("writes the entry value and updates currentId synchronously", () => { + const attr = attribute("one").build(); + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attr)); + expect(selector.currentId).toBe("0"); + + selector.setValue("2"); + expect(attr.setValue).toHaveBeenLastCalledWith("three"); + expect(selector.currentId).toBe("2"); + + selector.setValue(null); + expect(attr.setValue).toHaveBeenLastCalledWith(undefined); + expect(selector.currentId).toBeNull(); + }); + + it("keeps the previous currentId when the attribute value is cleared externally", () => { + // Quirk: only setValue clears currentId; an external clear leaves the old selection visible. + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attribute("two").build())); + expect(selector.currentId).toBe("1"); + + selector.updateProps(staticProps(attribute(undefined).build())); + + expect(selector.currentId).toBe("1"); + }); + + it("uses the attribute display value as the empty caption when there is one", () => { + // Quirk: the display value of the bound attribute overrides the empty-option text. + const withValue = new StaticSingleSelector(); + withValue.updateProps(staticProps(attribute("two").build())); + expect(withValue.caption.get(null)).toBe("two"); + + const withoutValue = new StaticSingleSelector(); + withoutValue.updateProps(staticProps(attribute().build())); + expect(withoutValue.caption.get(null)).toBe("Select..."); + }); + + it("is never clearable for a boolean attribute", () => { + const selector = new StaticSingleSelector(); + selector.updateProps( + staticProps(attribute(true).build(), { + clearable: true, + optionsSourceStaticDataSource: [staticEntry(true, "Yes"), staticEntry(false, "No")] + }) + ); + + expect(selector.clearable).toBe(false); + }); + + it("reports the attribute type", () => { + const stringSelector = new StaticSingleSelector(); + stringSelector.updateProps(staticProps(attribute("one").build())); + expect(stringSelector.attributeType).toBe("string"); + + const booleanSelector = new StaticSingleSelector(); + booleanSelector.updateProps( + staticProps(new EditableValueBuilder().withUniverse(true, false).withValue(true).build()) + ); + expect(booleanSelector.attributeType).toBe("boolean"); + + const dateSelector = new StaticSingleSelector(); + dateSelector.updateProps(staticProps(new EditableValueBuilder().withValue(new Date()).build())); + expect(dateSelector.attributeType).toBe("date"); + }); + + it("passes read-only and validation through", () => { + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attribute("one").isReadOnly().withValidation("Required").build())); + + expect(selector.readOnly).toBe(true); + expect(selector.validation).toBe("Required"); + }); + + it.each([ + ["attribute", staticProps(attribute("one").isUnavailable().build())], + ["empty-option text", staticProps(attribute("one").build(), { emptyOptionText: dynamic.unavailable() })], + [ + "entry value", + staticProps(attribute("one").build(), { + optionsSourceStaticDataSource: [ + { ...staticEntry("one", "One"), staticDataSourceValue: dynamic.unavailable() } + ] + }) + ], + [ + "entry caption", + staticProps(attribute("one").build(), { + optionsSourceStaticDataSource: [ + { ...staticEntry("one", "One"), staticDataSourceCaption: dynamic.unavailable() } + ] + }) + ] + ])("is unavailable while the %s is unavailable", (_label, props) => { + const selector = new StaticSingleSelector(); + selector.updateProps(props); + + expect(selector.status).toBe("unavailable"); + expect(selector.currentId).toBeNull(); + expect(selector.clearable).toBe(false); + }); + + it.each([ + ["contains", "w", ["1"]], + ["containsExact", "T", ["1", "2"]], + ["startsWith", "th", ["2"]], + ["none", "zzz", ["0", "1", "2"]] + ] as const)("filters by entry caption with filterType '%s'", (filterType, term, expected) => { + const selector = new StaticSingleSelector(); + selector.updateProps(staticProps(attribute().build(), { filterType })); + + selector.options.setSearchTerm(term); + + expect(selector.options.getAll()).toEqual(expected); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/BaseOptionsProvider.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/BaseOptionsProvider.spec.ts new file mode 100644 index 0000000000..14242e6165 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/BaseOptionsProvider.spec.ts @@ -0,0 +1,108 @@ +import { FilterTypeEnum } from "../../../typings/ComboboxProps"; +import { BaseOptionsProvider } from "../BaseOptionsProvider"; +import { CaptionsProvider } from "../types"; + +const captions: Record = { + a: "Apple pie", + b: "Banana", + c: "Pineapple", + d: "Cherry" +}; + +const captionsProvider: CaptionsProvider = { + get: id => (id === null ? "" : captions[id]), + render: () => null, + emptyCaption: "" +}; + +class TestOptionsProvider extends BaseOptionsProvider { + constructor(filterType: FilterTypeEnum) { + super(captionsProvider); + this.filterType = filterType; + this.options = ["a", "b", "c", "d"]; + } +} + +describe("BaseOptionsProvider filtering", () => { + it.each(["contains", "containsExact", "startsWith", "none"] as const)( + "returns options in datasource order when the search term is empty (%s)", + filterType => { + expect(new TestOptionsProvider(filterType).getAll()).toEqual(["a", "b", "c", "d"]); + } + ); + + it("'contains' matches any substring and ranks closer matches first", () => { + const provider = new TestOptionsProvider("contains"); + provider.setSearchTerm("apple"); + + expect(provider.getAll()).toEqual(["a", "c"]); + }); + + it("'containsExact' matches substrings only, case-insensitive", () => { + const provider = new TestOptionsProvider("containsExact"); + provider.setSearchTerm("APPLE"); + + expect(provider.getAll()).toEqual(["a", "c"]); + }); + + it("'startsWith' matches word starts only", () => { + const provider = new TestOptionsProvider("startsWith"); + provider.setSearchTerm("pi"); + + // "Pineapple" starts with the term, "Apple pie" only has a word that does, so it ranks lower + expect(provider.getAll()).toEqual(["c", "a"]); + + provider.setSearchTerm("apple"); + expect(provider.getAll()).toEqual(["a"]); + }); + + it("'none' ignores the search term", () => { + const provider = new TestOptionsProvider("none"); + provider.setSearchTerm("zzz"); + + expect(provider.getAll()).toEqual(["a", "b", "c", "d"]); + }); + + it("returns nothing when nothing matches", () => { + const provider = new TestOptionsProvider("contains"); + provider.setSearchTerm("zzz"); + + expect(provider.getAll()).toEqual([]); + }); +}); + +describe("BaseOptionsProvider search-term side channel", () => { + it("calls the registered callback after every search-term change", () => { + const provider = new TestOptionsProvider("contains"); + const callback = jest.fn(); + provider.onAfterSearchTermChange(callback); + + provider.setSearchTerm("a"); + provider.setSearchTerm(""); + + expect(callback).toHaveBeenCalledTimes(2); + expect(provider.searchTerm).toBe(""); + }); + + it("keeps only the last registered callback", () => { + const provider = new TestOptionsProvider("contains"); + const first = jest.fn(); + const second = jest.fn(); + provider.onAfterSearchTermChange(first); + provider.onAfterSearchTermChange(second); + + provider.setSearchTerm("a"); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("has no lazy-loading state by default", () => { + const provider = new TestOptionsProvider("contains"); + + expect(provider.status).toBe("available"); + expect(provider.hasMore).toBe(false); + expect(provider.isLoading).toBe(false); + expect(() => provider.loadMore()).not.toThrow(); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/LazyLoadProvider.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/LazyLoadProvider.spec.ts new file mode 100644 index 0000000000..e6e7a32d6b --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/LazyLoadProvider.spec.ts @@ -0,0 +1,68 @@ +import { ValueStatus } from "mendix"; +import { ListValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { LazyLoadProvider } from "../LazyLoadProvider"; + +function providerWithLimit(limit: number): LazyLoadProvider { + const provider = new LazyLoadProvider(); + provider.updateProps(new ListValueBuilder().withSize(2).withLimit(limit).build()); + return provider; +} + +describe("LazyLoadProvider.getLimit", () => { + it("returns 0 while the datasource is unavailable", () => { + expect(providerWithLimit(100).getLimit(100, false, ValueStatus.Unavailable, true)).toBe(0); + expect(providerWithLimit(100).getLimit(100, false, ValueStatus.Unavailable, false)).toBe(0); + }); + + it("returns 0 while loading without lazy loading", () => { + expect(providerWithLimit(100).getLimit(100, false, ValueStatus.Loading, false)).toBe(0); + }); + + it("keeps the remembered limit while loading with lazy loading", () => { + expect(providerWithLimit(50).getLimit(100, false, ValueStatus.Loading, true)).toBe(50); + }); + + it("treats a remembered limit of 0 or Infinity as 0 while loading with lazy loading", () => { + expect(providerWithLimit(0).getLimit(100, false, ValueStatus.Loading, true)).toBe(0); + expect(providerWithLimit(Infinity).getLimit(100, false, ValueStatus.Loading, true)).toBe(0); + }); + + it("returns 0 for a read-only control, so no options are loaded", () => { + expect(providerWithLimit(100).getLimit(100, true, ValueStatus.Available, true)).toBe(0); + expect(providerWithLimit(100).getLimit(100, true, ValueStatus.Available, false)).toBe(0); + }); + + it("removes the limit (undefined) when available without lazy loading", () => { + expect(providerWithLimit(100).getLimit(100, false, ValueStatus.Available, false)).toBeUndefined(); + }); + + it("never shrinks the limit with lazy loading", () => { + const provider = providerWithLimit(100); + + expect(provider.getLimit(50, false, ValueStatus.Available, true)).toBe(100); + }); + + it("grows and remembers the limit with lazy loading", () => { + const provider = providerWithLimit(100); + + expect(provider.getLimit(200, false, ValueStatus.Available, true)).toBe(200); + expect(provider.getLimit(150, false, ValueStatus.Available, true)).toBe(200); + }); +}); + +describe("LazyLoadProvider.setLimit", () => { + it("writes the limit only when it differs from the datasource limit", () => { + const provider = new LazyLoadProvider(); + const ds = new ListValueBuilder().withSize(2).withLimit(100).build(); + provider.updateProps(ds); + + provider.setLimit(100); + expect(ds.setLimit).not.toHaveBeenCalled(); + + provider.setLimit(200); + expect(ds.setLimit).toHaveBeenCalledWith(200); + + provider.setLimit(undefined); + expect(ds.setLimit).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/datasourceFilter.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/datasourceFilter.spec.ts new file mode 100644 index 0000000000..c267fbafb5 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/datasourceFilter.spec.ts @@ -0,0 +1,30 @@ +import { attribute, contains, literal, startsWith } from "mendix/filters/builders"; +import { attrId } from "@mendix/widget-plugin-test-utils"; +import { datasourceFilter } from "../datasourceFilter"; + +const id = attrId("caption"); + +describe("datasourceFilter", () => { + beforeEach(() => { + (attribute as jest.Mock).mockImplementation(a => ({ attr: a })); + (literal as jest.Mock).mockImplementation(v => ({ lit: v })); + (contains as jest.Mock).mockImplementation((a, b) => ({ contains: [a, b] })); + (startsWith as jest.Mock).mockImplementation((a, b) => ({ startsWith: [a, b] })); + }); + + it("returns undefined for filter type 'none'", () => { + expect(datasourceFilter("none", "ab", id)).toBeUndefined(); + }); + + it("returns undefined for an empty search", () => { + expect(datasourceFilter("contains", "", id)).toBeUndefined(); + }); + + it.each(["contains", "containsExact"] as const)("builds a contains condition for '%s'", type => { + expect(datasourceFilter(type, "ab", id)).toEqual({ contains: [{ attr: id }, { lit: "ab" }] }); + }); + + it("builds a startsWith condition for 'startsWith'", () => { + expect(datasourceFilter("startsWith", "ab", id)).toEqual({ startsWith: [{ attr: id }, { lit: "ab" }] }); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/fixtures.ts b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/fixtures.ts new file mode 100644 index 0000000000..5d62e6503e --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/fixtures.ts @@ -0,0 +1,236 @@ +import { Big } from "big.js"; +import { + ListAttributeValue, + ListExpressionValue, + ListValue, + ObjectItem, + ReferenceSetValue, + ReferenceValue, + SelectionMultiValue, + SelectionSingleValue +} from "mendix"; +import { + attrId, + dynamic, + EditableValueBuilder, + list, + listAttribute, + listExpression, + ReferenceSetValueBuilder, + ReferenceValueBuilder, + SelectionMultiValueBuilder, + SelectionSingleValueBuilder +} from "@mendix/widget-plugin-test-utils"; +import { ComboboxContainerProps, OptionsSourceStaticDataSourceType } from "../../../typings/ComboboxProps"; + +/** + * Complete, valid ComboboxContainerProps. Every static prop has a value, so a test only + * overrides what it is about. Static props never change between re-renders, so tests + * build a new prop object per scenario, never mutate one between updateProps calls. + */ +export function baseProps(overrides: Partial = {}): ComboboxContainerProps { + return { + name: "comboBox", + id: "comboBox1", + tabIndex: 0, + source: "context", + optionsSourceType: "enumeration", + attributeEnumeration: new EditableValueBuilder().build(), + attributeBoolean: new EditableValueBuilder().build(), + optionsSourceDatabaseDataSource: undefined, + optionsSourceDatabaseItemSelection: undefined, + optionsSourceAssociationCaptionType: "attribute", + optionsSourceDatabaseCaptionType: "attribute", + optionsSourceAssociationCaptionAttribute: undefined, + optionsSourceDatabaseCaptionAttribute: undefined, + optionsSourceAssociationCaptionExpression: undefined, + optionsSourceDatabaseCaptionExpression: undefined, + databaseAttributeString: undefined, + optionsSourceDatabaseValueAttribute: undefined, + attributeAssociation: new ReferenceValueBuilder().build(), + optionsSourceAssociationDataSource: undefined, + staticAttribute: new EditableValueBuilder().build(), + optionsSourceStaticDataSource: [], + emptyOptionText: dynamic.available("Select..."), + noOptionsText: dynamic.available("No options"), + clearable: true, + optionsSourceAssociationCustomContentType: "no", + optionsSourceAssociationCustomContent: undefined, + optionsSourceDatabaseCustomContentType: "no", + optionsSourceDatabaseCustomContent: undefined, + staticDataSourceCustomContentType: "no", + showFooter: false, + menuFooterContent: undefined, + selectionMethod: "checkbox", + selectedItemsStyle: "text", + selectAllButton: false, + selectAllButtonCaption: dynamic.available("Select all"), + customEditability: "default", + customEditabilityExpression: dynamic.available(true), + readOnlyStyle: "bordered", + onEnterEvent: undefined, + onLeaveEvent: undefined, + onChangeFilterInputEvent: undefined, + ariaRequired: dynamic.available(false), + ariaLabel: undefined, + clearButtonAriaLabel: dynamic.available("Clear"), + removeValueAriaLabel: dynamic.available("Remove"), + a11ySelectedValue: dynamic.available("Selected value:"), + a11yOptionsAvailable: dynamic.available("Options available:"), + a11yInstructions: dynamic.available("Use arrows"), + lazyLoading: false, + loadingType: "spinner", + selectedItemsSorting: "none", + filterType: "contains", + filterInputDebounceInterval: 200, + ...overrides + }; +} + +/** Items with deterministic ids and captions: obj_a → "Apple", obj_b → "Banana", ... */ +export const captions: Record = { + obj_a: "Apple", + obj_b: "Banana", + obj_c: "Cherry", + obj_d: "Date" +}; + +export function item(key: "a" | "b" | "c" | "d"): ObjectItem { + return { id: `obj_${key}` } as ObjectItem; +} + +export const itemA = item("a"); +export const itemB = item("b"); +export const itemC = item("c"); +export const itemD = item("d"); +export const allItems = [itemA, itemB, itemC, itemD]; + +export const captionAttributeId = attrId("captionAttr"); + +export function captionAttribute(): ListAttributeValue { + return listAttribute(i => captions[i.id] ?? "", captionAttributeId); +} + +export function captionExpression(): ListExpressionValue { + return listExpression(i => `expr:${captions[i.id] ?? ""}`); +} + +export function datasource(items: ObjectItem[] = allItems): ListValue { + return list(items); +} + +interface AssociationOverrides extends Partial { + attr?: ReferenceValue | ReferenceSetValue; + ds?: ListValue; + captionType?: "attribute" | "expression"; +} + +/** Context + association with a caption attribute (so lazy loading is possible). */ +export function associationSingleProps(overrides: AssociationOverrides = {}): ComboboxContainerProps { + const { attr, ds, captionType = "attribute", ...rest } = overrides; + return baseProps({ + source: "context", + optionsSourceType: "association", + attributeAssociation: attr ?? new ReferenceValueBuilder().withValue(itemA).build(), + optionsSourceAssociationDataSource: ds ?? datasource(), + optionsSourceAssociationCaptionType: captionType, + optionsSourceAssociationCaptionAttribute: captionAttribute(), + optionsSourceAssociationCaptionExpression: captionExpression(), + ...rest + }); +} + +export function associationMultiProps(overrides: AssociationOverrides = {}): ComboboxContainerProps { + const { attr, ...rest } = overrides; + return associationSingleProps({ + attr: attr ?? new ReferenceSetValueBuilder().withValue([itemA]).build(), + ...rest + }); +} + +export function enumerationProps( + attribute = new EditableValueBuilder().withUniverse("red", "green", "blue").withValue("green").build(), + overrides: Partial = {} +): ComboboxContainerProps { + return baseProps({ + source: "context", + optionsSourceType: "enumeration", + attributeEnumeration: attribute, + attributeBoolean: undefined as unknown as ComboboxContainerProps["attributeBoolean"], + ...overrides + }); +} + +export function booleanProps( + attribute = new EditableValueBuilder().withUniverse(true, false).withValue(true).build(), + overrides: Partial = {} +): ComboboxContainerProps { + return baseProps({ + source: "context", + optionsSourceType: "boolean", + attributeEnumeration: undefined as unknown as ComboboxContainerProps["attributeEnumeration"], + attributeBoolean: attribute, + ...overrides + }); +} + +export function staticEntry( + value: string | Big | boolean | Date, + caption: string, + customContent?: OptionsSourceStaticDataSourceType["staticDataSourceCustomContent"] +): OptionsSourceStaticDataSourceType { + return { + staticDataSourceValue: dynamic.available(value), + staticDataSourceCaption: dynamic.available(caption), + staticDataSourceCustomContent: customContent + }; +} + +export const staticEntries = [staticEntry("one", "One"), staticEntry("two", "Two"), staticEntry("three", "Three")]; + +export function staticProps( + attribute = new EditableValueBuilder().build(), + overrides: Partial = {} +): ComboboxContainerProps { + return baseProps({ + source: "static", + staticAttribute: attribute, + optionsSourceStaticDataSource: staticEntries, + ...overrides + }); +} + +/** Value attribute: obj_a → "v_a", obj_b → "v_b", ... */ +export function valueAttribute(): ListAttributeValue { + return listAttribute(i => `v_${i.id.slice(-1)}`, attrId("valueAttr")) as ListAttributeValue; +} + +interface DatabaseOverrides extends Partial { + ds?: ListValue; + selection?: SelectionSingleValue | SelectionMultiValue; + captionType?: "attribute" | "expression"; +} + +export function databaseSingleProps(overrides: DatabaseOverrides = {}): ComboboxContainerProps { + const { ds, selection, captionType = "attribute", ...rest } = overrides; + return baseProps({ + source: "database", + optionsSourceDatabaseDataSource: ds ?? datasource(), + optionsSourceDatabaseItemSelection: selection ?? new SelectionSingleValueBuilder().build(), + optionsSourceDatabaseCaptionType: captionType, + optionsSourceDatabaseCaptionAttribute: captionAttribute(), + optionsSourceDatabaseCaptionExpression: captionExpression(), + optionsSourceDatabaseValueAttribute: valueAttribute(), + databaseAttributeString: + new EditableValueBuilder().build() as ComboboxContainerProps["databaseAttributeString"], + ...rest + }); +} + +export function databaseMultiProps(overrides: DatabaseOverrides = {}): ComboboxContainerProps { + const { selection, ...rest } = overrides; + return databaseSingleProps({ + selection: selection ?? new SelectionMultiValueBuilder().withSelected([itemA]).build(), + ...rest + }); +} diff --git a/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/getSelector.spec.ts b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/getSelector.spec.ts new file mode 100644 index 0000000000..5391ccc655 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/helpers/__tests__/getSelector.spec.ts @@ -0,0 +1,73 @@ +import { + ReferenceSetValueBuilder, + SelectionMultiValueBuilder, + SelectionSingleValueBuilder +} from "@mendix/widget-plugin-test-utils"; +import { + associationMultiProps, + associationSingleProps, + baseProps, + booleanProps, + databaseMultiProps, + databaseSingleProps, + enumerationProps, + itemA, + staticProps +} from "./fixtures"; +import { AssociationMultiSelector } from "../Association/AssociationMultiSelector"; +import { AssociationSingleSelector } from "../Association/AssociationSingleSelector"; +import { DatabaseMultiSelectionSelector } from "../Database/DatabaseMultiSelectionSelector"; +import { DatabaseSingleSelectionSelector } from "../Database/DatabaseSingleSelectionSelector"; +import { EnumBooleanSingleSelector } from "../EnumBool/EnumBoolSingleSelector"; +import { getSelector } from "../getSelector"; +import { StaticSingleSelector } from "../Static/StaticSingleSelector"; + +describe("getSelector", () => { + it.each([ + ["context + enumeration", enumerationProps(), EnumBooleanSingleSelector, "single"], + ["context + boolean", booleanProps(), EnumBooleanSingleSelector, "single"], + ["context + association (Reference)", associationSingleProps(), AssociationSingleSelector, "single"], + [ + "context + association (ReferenceSet)", + associationMultiProps({ attr: new ReferenceSetValueBuilder().withValue([itemA]).build() }), + AssociationMultiSelector, + "multi" + ], + [ + "database + Single selection", + databaseSingleProps({ selection: new SelectionSingleValueBuilder().build() }), + DatabaseSingleSelectionSelector, + "single" + ], + [ + "database + Multi selection", + databaseMultiProps({ selection: new SelectionMultiValueBuilder().build() }), + DatabaseMultiSelectionSelector, + "multi" + ], + [ + "database without selection", + databaseSingleProps({ selection: undefined }), + DatabaseSingleSelectionSelector, + "single" + ], + ["static", staticProps(), StaticSingleSelector, "single"] + ])("routes %s to its selector", (_label, props, SelectorClass, type) => { + const selector = getSelector(props); + + expect(selector).toBeInstanceOf(SelectorClass); + expect(selector.type).toBe(type); + }); + + it("throws for an unsupported options source type", () => { + const props = baseProps({ source: "context", optionsSourceType: "unknown" as never }); + + expect(() => getSelector(props)).toThrow("'optionsSourceType' of type 'unknown' is not supported"); + }); + + it("throws for an unsupported source", () => { + const props = baseProps({ source: "unknown" as never }); + + expect(() => getSelector(props)).toThrow("Source of type 'unknown' is not supported"); + }); +}); diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/__tests__/useGetSelector.spec.tsx b/packages/pluggableWidgets/combobox-web/src/hooks/__tests__/useGetSelector.spec.tsx new file mode 100644 index 0000000000..08c1c9de1e --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/hooks/__tests__/useGetSelector.spec.tsx @@ -0,0 +1,133 @@ +import { act, renderHook } from "@testing-library/react"; +import { actionValue } from "@mendix/widget-plugin-test-utils"; +import { ComboboxContainerProps } from "../../../typings/ComboboxProps"; +import { enumerationProps } from "../../helpers/__tests__/fixtures"; +import { Selector } from "../../helpers/types"; +import { useGetSelector } from "../useGetSelector"; + +type FilterAction = NonNullable; + +function propsWithFilterAction(action: FilterAction, interval = 200): ComboboxContainerProps { + return enumerationProps(undefined, { + onChangeFilterInputEvent: action, + filterInputDebounceInterval: interval + }); +} + +describe("useGetSelector", () => { + it("returns the same selector instance across re-renders", () => { + const { result, rerender } = renderHook((props: ComboboxContainerProps) => useGetSelector(props), { + initialProps: enumerationProps() + }); + const first = result.current; + + rerender(enumerationProps()); + + expect(result.current).toBe(first); + }); + + it("calls updateProps with the latest props on every render", () => { + const { result, rerender } = renderHook((props: ComboboxContainerProps) => useGetSelector(props), { + initialProps: enumerationProps() + }); + const updateProps = jest.spyOn(result.current, "updateProps"); + const next = enumerationProps(); + + rerender(next); + + expect(updateProps).toHaveBeenCalledTimes(1); + expect(updateProps).toHaveBeenCalledWith(next); + }); + + it("re-renders the host when the search term changes", () => { + let renders = 0; + const { result } = renderHook( + (props: ComboboxContainerProps) => { + renders++; + return useGetSelector(props); + }, + { initialProps: enumerationProps() } + ); + const before = renders; + + act(() => { + result.current.options.setSearchTerm("gree"); + }); + + expect(renders).toBe(before + 1); + expect(result.current.options.getAll()).toEqual(["green"]); + }); + + it("attaches onFilterInputChange from the second render on", () => { + // Quirk: the first render creates the selector, the next render wires the debounced callback. + const { result, rerender } = renderHook((props: ComboboxContainerProps) => useGetSelector(props), { + initialProps: enumerationProps() + }); + expect(result.current.onFilterInputChange).toBeUndefined(); + + rerender(enumerationProps()); + + expect(result.current.onFilterInputChange).toEqual(expect.any(Function)); + }); + + describe("filter-input action", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function renderAttached(action: FilterAction, interval?: number): Selector { + const { result, rerender } = renderHook((props: ComboboxContainerProps) => useGetSelector(props), { + initialProps: propsWithFilterAction(action, interval) + }); + rerender(propsWithFilterAction(action, interval)); + return result.current; + } + + it("debounces the action and sends the last term", () => { + const action = actionValue(); + const selector = renderAttached(action); + + selector.onFilterInputChange!("a"); + selector.onFilterInputChange!("ab"); + expect(action.execute).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + + expect(action.execute).toHaveBeenCalledTimes(1); + expect(action.execute).toHaveBeenCalledWith({ filterInput: "ab" }); + }); + + it("respects the configured interval", () => { + const action = actionValue(); + const selector = renderAttached(action, 500); + + selector.onFilterInputChange!("a"); + jest.advanceTimersByTime(200); + expect(action.execute).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(300); + expect(action.execute).toHaveBeenCalledTimes(1); + }); + + it("sends undefined for an empty term", () => { + const action = actionValue(); + const selector = renderAttached(action); + + selector.onFilterInputChange!(""); + jest.advanceTimersByTime(200); + + expect(action.execute).toHaveBeenCalledWith({ filterInput: undefined }); + }); + + it("skips the action when it cannot execute or is already executing", () => { + const cannot = actionValue(false); + renderAttached(cannot).onFilterInputChange!("a"); + jest.advanceTimersByTime(200); + expect(cannot.execute).not.toHaveBeenCalled(); + + const executing = actionValue(true, true); + renderAttached(executing).onFilterInputChange!("a"); + jest.advanceTimersByTime(200); + expect(executing.execute).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shared/widget-plugin-test-utils/__tests__/functions.test.ts b/packages/shared/widget-plugin-test-utils/__tests__/functions.test.ts index aa6a122bc4..1b1ced8ce8 100644 --- a/packages/shared/widget-plugin-test-utils/__tests__/functions.test.ts +++ b/packages/shared/widget-plugin-test-utils/__tests__/functions.test.ts @@ -1,4 +1,6 @@ import { + attrId, + ListValueBuilder, listAttribute, obj, reference, @@ -29,6 +31,25 @@ describe("prop mocking functions", () => { }); }); + describe("ListValueBuilder", () => { + it("sets the limit after the items", () => { + const prop = new ListValueBuilder().withSize(2).withLimit(100).build(); + expect(prop.items).toHaveLength(2); + expect(prop.limit).toBe(100); + }); + + it("sets the sort order", () => { + const id = attrId("caption"); + expect(new ListValueBuilder().withSortOrder([[id, "desc"]]).build().sortOrder).toEqual([[id, "desc"]]); + }); + + it("returns an unavailable ListValue without items", () => { + const prop = new ListValueBuilder().isUnavailable().build(); + expect(prop.status).toBe("unavailable"); + expect(prop.items).toBeUndefined(); + }); + }); + describe("listAction", () => { it("returns ListActionValue mock with get method", () => { const prop = listAction(); @@ -43,6 +64,11 @@ describe("prop mocking functions", () => { const attr = listAttribute(() => new Date(t)); expect(attr.get(obj()).value!).toEqual(t); }); + + it("uses the given id", () => { + const id = attrId("caption"); + expect(listAttribute(() => "x", id).id).toBe(id); + }); }); describe("listExpression", () => { diff --git a/packages/shared/widget-plugin-test-utils/src/builders/ListValueBuilder.ts b/packages/shared/widget-plugin-test-utils/src/builders/ListValueBuilder.ts index b0f845e017..55676fc025 100644 --- a/packages/shared/widget-plugin-test-utils/src/builders/ListValueBuilder.ts +++ b/packages/shared/widget-plugin-test-utils/src/builders/ListValueBuilder.ts @@ -42,6 +42,17 @@ export class ListValueBuilder { return this; } + /** Call after `withItems` or `withSize`, which reset the limit to the item count. */ + withLimit(limit: number): this { + this.mock.limit = limit; + return this; + } + + withSortOrder(sortOrder: ListValue["sortOrder"]): this { + this.mock.sortOrder = sortOrder; + return this; + } + isLoading(): this { this.mock.status = Status.Loading; this.mock.items = undefined; @@ -49,7 +60,7 @@ export class ListValueBuilder { } isUnavailable(): this { - this.mock.status = Status.Loading; + this.mock.status = Status.Unavailable; this.mock.items = undefined; return this; } diff --git a/packages/shared/widget-plugin-test-utils/src/functions/listAttribute.ts b/packages/shared/widget-plugin-test-utils/src/functions/listAttribute.ts index 2abbe0425e..36432e2c05 100644 --- a/packages/shared/widget-plugin-test-utils/src/functions/listAttribute.ts +++ b/packages/shared/widget-plugin-test-utils/src/functions/listAttribute.ts @@ -5,11 +5,14 @@ import { editable } from "../primitives/editable"; /** * Returns `ListAttributeValue` mock. * @param get - function to use as `.get`. Should map item to value. + * @param id - optional attribute id, see `attrId`. */ export function listAttribute( - get: (item: ObjectItem) => T + get: (item: ObjectItem) => T, + id?: ListAttributeValue["id"] ): ListAttributeValue { - const attr = new ListAttributeValueBuilder().build(); + const builder = new ListAttributeValueBuilder(); + const attr = (id === undefined ? builder : builder.withId(id)).build(); attr.get = jest.fn((item: ObjectItem) => editable(b => b.withValue(get(item)).isReadOnly().build())); return attr; } diff --git a/packages/shared/widget-plugin-test-utils/src/primitives/action.ts b/packages/shared/widget-plugin-test-utils/src/primitives/action.ts index 03bbce0f2b..c5ee15d058 100644 --- a/packages/shared/widget-plugin-test-utils/src/primitives/action.ts +++ b/packages/shared/widget-plugin-test-utils/src/primitives/action.ts @@ -1,5 +1,10 @@ import { ActionValue } from "mendix"; -export function actionValue(canExecute = true, isExecuting = false): ActionValue { - return { canExecute, isExecuting, execute: jest.fn() }; +/** + * Returns `ActionValue` mock. + * @typeParam T - the action type to mock, for actions with parameters: + * `actionValue>()`. + */ +export function actionValue = ActionValue>(canExecute = true, isExecuting = false): T { + return { canExecute, isExecuting, execute: jest.fn() } as unknown as T; }