From 429ce50a047d67fb075b702326c1813f32f7dbf0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 30 Sep 2026 20:30:56 +0800 Subject: [PATCH 01/11] feat: render resource view registries in tables and panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout components merge what extensions register into a view's slots when given @registry, a surface prefix such as `ledger:table:invoice`: - Tabular: columns, row actions, bulk actions and toolbar buttons - TabularActions: toolbar buttons and bulk actions - Panel (and resource-context-panel's `registry` option): header buttons, and menu items in the existing "…" dropdown or a new one Also: - extract the three copies of the header-button and bulk-action markup into Layout::Resource::ActionButtons and Layout::Resource::BulkActions; bulk actions honour `permission` everywhere, and dropdown items without a handler no longer throw - Tabular derives its columns instead of copying @columns once, so later changes (and late registrations) reach the table; the column picker's choices are kept by column key across recomputes - Tabular forwards @searchDisabled and takes a <:header> block - Table cells, filters and action-button components may be ExtensionComponent definitions, rendered from the extension's engine - utils/resource-view: an optional lookup of ember-core's service (older ember-core builds leave views unchanged) and mergeHeaderButtons --- addon/components/filters-picker.hbs | 3 +- .../layout/resource/action-buttons.hbs | 95 +++++ .../layout/resource/bulk-actions.hbs | 40 +++ addon/components/layout/resource/panel.hbs | 4 +- addon/components/layout/resource/panel.js | 19 + .../layout/resource/panel/header-actions.hbs | 85 +---- .../layout/resource/tabular-actions.hbs | 128 +------ .../layout/resource/tabular-actions.js | 32 ++ addon/components/layout/resource/tabular.hbs | 112 +----- addon/components/layout/resource/tabular.js | 90 ++++- addon/components/resource-context-panel.hbs | 1 + addon/components/table/td.hbs | 9 +- addon/utils/resource-view.js | 55 +++ .../layout/resource/action-buttons.js | 1 + .../layout/resource/bulk-actions.js | 1 + app/utils/resource-view.js | 1 + tests/helpers/resource-view-stubs.js | 72 ++++ .../layout/resource/registry-test.js | 340 ++++++++++++++++++ tests/unit/utils/resource-view-test.js | 68 ++++ 19 files changed, 823 insertions(+), 333 deletions(-) create mode 100644 addon/components/layout/resource/action-buttons.hbs create mode 100644 addon/components/layout/resource/bulk-actions.hbs create mode 100644 addon/utils/resource-view.js create mode 100644 app/components/layout/resource/action-buttons.js create mode 100644 app/components/layout/resource/bulk-actions.js create mode 100644 app/utils/resource-view.js create mode 100644 tests/helpers/resource-view-stubs.js create mode 100644 tests/integration/components/layout/resource/registry-test.js create mode 100644 tests/unit/utils/resource-view-test.js diff --git a/addon/components/filters-picker.hbs b/addon/components/filters-picker.hbs index c2f1491e..caa43b5d 100644 --- a/addon/components/filters-picker.hbs +++ b/addon/components/filters-picker.hbs @@ -33,8 +33,9 @@ {{#unless filter.noFilterLabel}} {{/unless}} + {{! An ExtensionComponent filter comes from an extension's own engine. }} {{component - filter.filterComponent + (if (is-object filter.filterComponent) (lazy-engine-component filter.filterComponent) filter.filterComponent) value=filter.filterValue filter=filter param=(or filter.filterParam filter.valuePath) diff --git a/addon/components/layout/resource/action-buttons.hbs b/addon/components/layout/resource/action-buttons.hbs new file mode 100644 index 00000000..1b2e7dcb --- /dev/null +++ b/addon/components/layout/resource/action-buttons.hbs @@ -0,0 +1,95 @@ +{{#each @buttons as |actionButton|}} + {{#if actionButton.items}} + +
+ {{#each actionButton.items as |item|}} + {{#if item.separator}} +
+ {{else}} + + {{/if}} + {{/each}} +
+
+ {{else if actionButton.component}} + {{#let (if (is-object actionButton.component) (lazy-engine-component actionButton.component) actionButton.component) as |buttonComponent|}} + {{#if buttonComponent}} + {{component + buttonComponent + icon=actionButton.icon + iconPrefix=actionButton.iconPrefix + iconComponent=actionButton.iconComponent + size=actionButton.size + text=actionButton.text + options=actionButton.options + items=actionButton.items + permission=actionButton.permission + isLoading=actionButton.isLoading + renderInPlace=actionButton.renderInPlace + triggerClass=actionButton.triggerClass + disabled=actionButton.disabled + onClick=actionButton.onClick + fn=actionButton.fn + perform=actionButton.perform + onSelect=actionButton.onSelect + onChange=actionButton.onChange + }} + {{/if}} + {{/let}} + {{else}} + `, class extends Component {}), + }); + }); + + test('a cell component from an extension engine renders in the table', async function (assert) { + this.set('columns', [{ id: 'score', label: 'Score', valuePath: 'status', cellComponent: { engine: '@acme/engine', path: 'cell/score' } }]); + + await render(hbs``); + + assert.dom('.acme-cell').hasText('Ada: active'); + }); + + test('a filter component from an extension engine renders in the filters picker', async function (assert) { + this.set('columns', [ + { id: 'score', label: 'Score', valuePath: 'status', filterable: true, filterParam: 'score', filterComponent: { engine: '@acme/engine', path: 'filter/score' } }, + ]); + + await render(hbs``); + await click('.ember-basic-dropdown-trigger'); + + assert.dom('.acme-filter').hasAttribute('placeholder', 'Score'); + }); + + test('a button component from an extension engine renders among the action buttons', async function (assert) { + this.set('buttons', [ + { id: 'sync', text: 'Sync', component: { engine: '@acme/engine', path: 'button/sync' } }, + { id: 'plain', text: 'Plain', component: 'button' }, + ]); + + await render(hbs``); + + assert.dom('.acme-button').hasText('Sync'); + assert.ok( + findAll('button').some((button) => button.textContent.includes('Plain')), + 'a string component still renders directly' + ); + }); + }); + + module('Layout::Resource::ActionButtons', function () { + test('a dropdown item without a handler renders and does nothing', async function (assert) { + this.set('buttons', [{ id: 'more', icon: 'ellipsis-h', items: [{ label: 'Nothing yet' }] }]); + + await render(hbs``); + await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger'); + await click('.next-dd-item'); + + assert.ok(true, 'clicking it does not throw'); + }); + + test('disabled dropdown items are marked disabled', async function (assert) { + this.set('buttons', [{ id: 'more', icon: 'ellipsis-h', items: [{ label: 'Void', disabled: true }] }]); + + await render(hbs``); + await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger'); + + assert.dom('.next-dd-item').hasClass('disabled'); + }); + }); +}); diff --git a/tests/unit/utils/resource-view-test.js b/tests/unit/utils/resource-view-test.js new file mode 100644 index 00000000..acba9064 --- /dev/null +++ b/tests/unit/utils/resource-view-test.js @@ -0,0 +1,68 @@ +import { module, test } from 'qunit'; +import lookupResourceView, { mergeHeaderButtons } from '@fleetbase/ember-ui/utils/resource-view'; +import { ResourceViewStub } from 'dummy/tests/helpers/resource-view-stubs'; + +module('Unit | Utility | resource-view', function () { + test('lookupResourceView returns the service, or null when there is none', function (assert) { + const service = {}; + assert.strictEqual(lookupResourceView({ lookup: () => service }), service); + assert.strictEqual(lookupResourceView({ lookup: () => undefined }), null); + assert.strictEqual(lookupResourceView(null), null); + assert.strictEqual( + lookupResourceView({ + lookup() { + throw new Error('module missing'); + }, + }), + null, + 'a lookup that throws is treated as no service' + ); + }); + + test('mergeHeaderButtons leaves the buttons alone without a service or registry', function (assert) { + const buttons = [{ id: 'edit' }]; + assert.strictEqual(mergeHeaderButtons(null, 'ledger:details:invoice', buttons), buttons); + assert.strictEqual(mergeHeaderButtons(new ResourceViewStub(), null, buttons), buttons); + assert.deepEqual(mergeHeaderButtons(null, null, undefined), []); + }); + + test('mergeHeaderButtons merges actions, and menu items only when asked', function (assert) { + const views = new ResourceViewStub(); + views.add('ledger:details:invoice:actions', { id: 'print' }); + views.add('ledger:details:invoice:menu', { id: 'acme' }); + + assert.deepEqual( + mergeHeaderButtons(views, 'ledger:details:invoice', [{ id: 'edit' }]).map((b) => b.id), + ['edit', 'print'], + 'table toolbars take no menu' + ); + + const dropdown = { id: 'more', items: [{ id: 'void' }] }; + const merged = mergeHeaderButtons(views, 'ledger:details:invoice', [{ id: 'edit' }, dropdown], {}, { withMenu: true }); + assert.deepEqual( + merged.map((b) => b.id), + ['edit', 'more', 'print'] + ); + assert.deepEqual( + merged[1].items.map((i) => i.id), + ['void', 'acme'] + ); + assert.deepEqual( + dropdown.items.map((i) => i.id), + ['void'], + 'the given dropdown is not modified' + ); + + const appended = mergeHeaderButtons(views, 'ledger:details:invoice', [], {}, { withMenu: true }); + assert.deepEqual( + appended.map((b) => b.id), + ['print', 'registered-menu'] + ); + assert.deepEqual( + appended[1].items.map((i) => i.id), + ['acme'] + ); + + assert.deepEqual(mergeHeaderButtons(new ResourceViewStub(), 'ledger:details:invoice', [], {}, { withMenu: true }), [], 'no dropdown is added for an empty menu'); + }); +}); From 40a91428844cb8eb55fba0688b6ec466e86293b1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 10:16:14 +0800 Subject: [PATCH 02/11] fix: drop unreachable fallbacks so the branch coverage gate passes CI's per-file gate flagged three branches in Tabular that no input can reach: the column picker always hands back an array of columns, a missing column can never match a visibility key, and ResourceViewService.mergeSlot already defaults a missing list. TabularActions loses the same `?? []`. --- addon/components/layout/resource/tabular-actions.js | 2 +- addon/components/layout/resource/tabular.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/addon/components/layout/resource/tabular-actions.js b/addon/components/layout/resource/tabular-actions.js index e3883703..1b0712b7 100644 --- a/addon/components/layout/resource/tabular-actions.js +++ b/addon/components/layout/resource/tabular-actions.js @@ -34,7 +34,7 @@ export default class LayoutResourceTabularActionsComponent extends Component { get bulkActions() { const { resourceView } = this; const { registry, bulkActions } = this.args; - return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions ?? [], this.viewContext) : bulkActions; + return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions, this.viewContext) : bulkActions; } /** diff --git a/addon/components/layout/resource/tabular.js b/addon/components/layout/resource/tabular.js index 5dbc6681..17621dd1 100644 --- a/addon/components/layout/resource/tabular.js +++ b/addon/components/layout/resource/tabular.js @@ -61,7 +61,7 @@ export default class LayoutResourceTabularComponent extends Component { const visibility = this.columnVisibility; for (const column of columns) { const key = columnKey(column); - if (column && key in visibility && column.hidden !== visibility[key]) { + if (key in visibility && column.hidden !== visibility[key]) { column.hidden = visibility[key]; } } @@ -76,7 +76,7 @@ export default class LayoutResourceTabularComponent extends Component { get bulkActions() { const { resourceView } = this; const { registry, bulkActions } = this.args; - return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions ?? [], this.viewContext) : bulkActions; + return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions, this.viewContext) : bulkActions; } get checkboxSticky() { @@ -87,8 +87,8 @@ export default class LayoutResourceTabularComponent extends Component { @action setColumns(columns) { const visibility = { ...this.columnVisibility }; - for (const column of columns ?? []) { - visibility[columnKey(column)] = Boolean(column?.hidden); + for (const column of columns) { + visibility[columnKey(column)] = Boolean(column.hidden); } this.columnVisibility = visibility; } From fe8fee2cdf73b29b6a695c2fba5fdfb32b3335ca Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 10:17:31 +0800 Subject: [PATCH 03/11] chore(release): v0.4.5 --- RELEASE.md | 46 ++++++++++++---------------------------------- package.json | 2 +- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index aae03327..80a0bb39 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,41 +1,19 @@ -> v0.4.4 ~ "An interactive component playground, signature pad, verified 100% test coverage, and a report builder for order reporting" +> v0.4.5 ~ "Tables and panels render extension-registered columns, actions and buttons" --- ## Highlights -- **Interactive component playground** — every component documented at - [fleetbase.io/docs/ui](https://fleetbase.io/docs/ui) now has a page where you can change its - arguments and watch the real component react, plus a minimal view the documentation site embeds - in an iframe. Argument state travels in the URL, so a configured example is a shareable link. - Built from the addon's own dummy application, so the previews are the real components with the - real styles — see [PLAYGROUND.md](PLAYGROUND.md). -- **Signature pad** — a new `` component and a matching custom field - type for capturing signatures. -- **Verified 100% test coverage** — 5,400 tests now cover every statement, branch, - function and line in the addon, enforced by a coverage gate in CI with Codecov - upload. Every remaining `istanbul ignore` carries a reason naming the specific - thing that makes its code unreachable. -- **Defects fixed along the way** — the coverage work surfaced and fixed real bugs, - including: popover arrows are now actually positioned against their target, - popover's `hide()` no longer spins requestAnimationFrame forever, the conditions - panel in the query builder opens only when there are selected columns to filter - on, sidebar search results from a `@searchProvider` can open nested sections via - `result.path`, report widgets can change their report after the first pick, - kanban card update/delete actions fire, array inputs no longer write the - KeyboardEvent into the array, and `@openDelay={{0}}` means zero. -- **Documentation refreshed** — the README now points at the official documentation site and the - playground instead of 79 links to files that no longer existed, and reports the correct - AGPL-3.0-or-later licence. -- **Report builder for order reporting** — goes with fleetbase/core-api v1.6.64 and fleetbase/fleetops v0.6.70: - - Group By, Sort and Conditions offer computed columns, so a report can group by a month bucket or sort by a calculated value. - - A grouped report can be sorted by its aggregates, such as "Sum of Quantity", and Count Distinct is a new aggregate. - - Summary columns such as Total Orders get their own "Summaries" section in the column picker, with a note on what they return. - - The computed column editor lists the JSON, `DATE` and `DECIMAL` helpers, with examples for reading a value from `meta`, bucketing by month and referencing a related column. The column name defaults to the label. -- **Default dashboard order** — widgets on the default dashboard are laid out by a numeric `order` on the widget definition (lowest first), so extensions can place their widgets together without depending on registration order. -- **Fix: report builder condition values lost focus after every keystroke**, including both ends of a range. -- **Fix: a field removed through a "Selected Fields" chip stayed checked**, because `` did not follow `@checked`. A field loaded from a saved report was also added twice when toggled. -- **Fix: the computed column editor added an empty column** when the name was blank, and renaming a column while editing added a copy. Saved reports now keep their computed columns when reopened. -- **Fix: tall modals** can grow and the overlay scrolls with its margins intact, and the modals manager forwards its size, position and scrollable options to the modal. +- **Resource view registries.** Layout components merge what extensions register when given `@registry`, a surface prefix such as `ledger:table:invoice`. This pairs with fleetbase/ember-core v0.3.25. + - `Layout::Resource::Tabular`: columns, row actions, bulk actions and toolbar buttons. + - `Layout::Resource::TabularActions`: toolbar buttons and bulk actions. + - `Layout::Resource::Panel` and `resourceContextPanel.open({ registry })`: header buttons, plus "…" menu items. + - Table cells, filters and action-button components may be `ExtensionComponent`s, rendered from the extension's own engine. + - With an older ember-core, views render exactly as before. +- **Shared header components.** The three copies of the header-button and bulk-action markup are now `Layout::Resource::ActionButtons` and `Layout::Resource::BulkActions`. +- **Tabular arguments.** Tabular forwards `@searchDisabled` and takes a `<:header>` block for extra header controls. +- **Fix: Tabular ignored column changes after first render.** It copied `@columns` once. It now derives them, and the column picker's choices survive recomputes. +- **Fix: Tabular's bulk actions ignored `permission`.** They now honour it. +- **Fix: a dropdown item without a handler threw from `dropdown-fn`.** It now renders and does nothing. --- ## Need help? diff --git a/package.json b/package.json index f7c5efed..81ca09c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.4.4", + "version": "0.4.5", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", From c62db6e01f0f3eed3f995b8a1871136255265357 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 11:07:27 +0800 Subject: [PATCH 04/11] test: bulk actions are used as given when no registry is passed Covers Tabular's bulkActions getter with the registry service present but no @registry, the last branch the coverage gate flagged. --- .../components/layout/resource/registry-test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/integration/components/layout/resource/registry-test.js b/tests/integration/components/layout/resource/registry-test.js index 01b1da7a..2cdf47e5 100644 --- a/tests/integration/components/layout/resource/registry-test.js +++ b/tests/integration/components/layout/resource/registry-test.js @@ -48,6 +48,22 @@ module('Integration | Component | layout/resource registries', function (hooks) assert.deepEqual(views.calls, []); }); + test('without a registry the bulk actions are used as given, even with the service present', async function (assert) { + const views = setupResourceView(this.owner); + views.add('fleet-ops:table:driver:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); + this.set('bulkActions', [{ label: 'Delete', fn: () => {} }]); + + await render(hbs``); + await clickFound(assert, find('tbody input[type="checkbox"]'), 'the row checkbox'); + await clickFound(assert, buttonWithIcon('layer-group'), 'the bulk menu trigger'); + + assert.deepEqual( + menuItems().map((item) => item.textContent.trim()), + ['Delete'] + ); + assert.deepEqual(views.calls, [], 'nothing is merged without a registry'); + }); + test('without the registry service the view renders as given', async function (assert) { await render(hbs``); From 82207f01f82c8b52315fd879b1007c5f972e22dc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 12:13:06 +0800 Subject: [PATCH 05/11] refactor: resource view registries are named ::: Follows ember-core 0.3.25: layouts take @registry="fleet-ops:driver:table" and @registry="ledger:invoice:details". The components only append the slot, so this changes documentation and test fixtures. --- addon/components/layout/resource/panel.js | 2 +- addon/utils/resource-view.js | 2 +- .../layout/resource/registry-test.js | 56 +++++++++---------- tests/unit/utils/resource-view-test.js | 14 ++--- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/addon/components/layout/resource/panel.js b/addon/components/layout/resource/panel.js index 666b823c..22ea8a42 100644 --- a/addon/components/layout/resource/panel.js +++ b/addon/components/layout/resource/panel.js @@ -36,7 +36,7 @@ export default class LayoutResourcePanelComponent extends Component { /** * `@actionButtons` with what extensions registered under `@registry` - * (a details prefix such as `ledger:details:invoice`) merged in: header + * (a details prefix such as `ledger:invoice:details`) merged in: header * buttons from its `actions` slot and dropdown items from its `menu` slot. */ get actionButtons() { diff --git a/addon/utils/resource-view.js b/addon/utils/resource-view.js index 335de462..24b553c7 100644 --- a/addon/utils/resource-view.js +++ b/addon/utils/resource-view.js @@ -25,7 +25,7 @@ export default function lookupResourceView(owner) { * when there is none. * * @param {Object|null} resourceView - * @param {String} registry A surface prefix, e.g. `ledger:details:invoice` + * @param {String} registry A surface prefix, e.g. `ledger:invoice:details` * @param {Array} buttons * @param {Object} context * @param {Object} options diff --git a/tests/integration/components/layout/resource/registry-test.js b/tests/integration/components/layout/resource/registry-test.js index 2cdf47e5..6ea4bc81 100644 --- a/tests/integration/components/layout/resource/registry-test.js +++ b/tests/integration/components/layout/resource/registry-test.js @@ -40,7 +40,7 @@ module('Integration | Component | layout/resource registries', function (hooks) module('Layout::Resource::Tabular', function () { test('without a registry nothing is looked up or merged', async function (assert) { const views = setupResourceView(this.owner); - views.add('fleet-ops:table:driver:columns', { id: 'score', label: 'Score' }); + views.add('fleet-ops:driver:table:columns', { id: 'score', label: 'Score' }); await render(hbs``); @@ -50,7 +50,7 @@ module('Integration | Component | layout/resource registries', function (hooks) test('without a registry the bulk actions are used as given, even with the service present', async function (assert) { const views = setupResourceView(this.owner); - views.add('fleet-ops:table:driver:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); + views.add('fleet-ops:driver:table:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); this.set('bulkActions', [{ label: 'Delete', fn: () => {} }]); await render(hbs``); @@ -65,7 +65,7 @@ module('Integration | Component | layout/resource registries', function (hooks) }); test('without the registry service the view renders as given', async function (assert) { - await render(hbs``); + await render(hbs``); assert.deepEqual(headerTexts(), ['Name', 'Status']); }); @@ -73,16 +73,16 @@ module('Integration | Component | layout/resource registries', function (hooks) test('registered columns, row actions, toolbar buttons and bulk actions are merged', async function (assert) { const views = setupResourceView(this.owner); const clicked = []; - views.add('fleet-ops:table:driver:columns', { id: 'score', label: 'Score', valuePath: 'status' }); - views.add('fleet-ops:table:driver:row-actions', { id: 'sync', label: 'Sync to Acme', fn: (row) => clicked.push(['sync', row.name]) }); - views.add('fleet-ops:table:driver:actions', { id: 'import', text: 'Import from Acme', onClick: () => clicked.push(['import']) }); - views.add('fleet-ops:table:driver:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => clicked.push(['export']) }); + views.add('fleet-ops:driver:table:columns', { id: 'score', label: 'Score', valuePath: 'status' }); + views.add('fleet-ops:driver:table:row-actions', { id: 'sync', label: 'Sync to Acme', fn: (row) => clicked.push(['sync', row.name]) }); + views.add('fleet-ops:driver:table:actions', { id: 'import', text: 'Import from Acme', onClick: () => clicked.push(['import']) }); + views.add('fleet-ops:driver:table:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => clicked.push(['export']) }); this.set('bulkActions', [{ label: 'Delete', fn: () => {} }]); this.set('controller', { sort: null }); await render(hbs` `${call.prefix}:${call.slot}`))].sort(); - assert.deepEqual(slots, ['fleet-ops:table:driver:actions', 'fleet-ops:table:driver:bulk-actions', 'fleet-ops:table:driver:columns', 'fleet-ops:table:driver:row-actions']); + assert.deepEqual(slots, ['fleet-ops:driver:table:actions', 'fleet-ops:driver:table:bulk-actions', 'fleet-ops:driver:table:columns', 'fleet-ops:driver:table:row-actions']); const { context } = views.calls.find((call) => call.slot === 'bulk-actions'); assert.strictEqual(context.controller, this.controller, 'handlers get the controller'); @@ -127,7 +127,7 @@ module('Integration | Component | layout/resource registries', function (hooks) const views = setupResourceView(this.owner); await render( - hbs`{{data.length}}` + hbs`{{data.length}}` ); const { context } = views.calls[0]; @@ -138,10 +138,10 @@ module('Integration | Component | layout/resource registries', function (hooks) test('a column registered after the first render appears', async function (assert) { const views = setupResourceView(this.owner); - await render(hbs``); + await render(hbs``); assert.deepEqual(headerTexts(), ['Name', 'Status']); - views.add('fleet-ops:table:driver:columns', { id: 'score', label: 'Score' }); + views.add('fleet-ops:driver:table:columns', { id: 'score', label: 'Score' }); await settled(); assert.deepEqual(headerTexts(), ['Name', 'Status', 'Score']); @@ -149,16 +149,16 @@ module('Integration | Component | layout/resource registries', function (hooks) test('a column the user hid stays hidden when the columns are recomputed', async function (assert) { const views = setupResourceView(this.owner); - views.add('fleet-ops:table:driver:columns', { id: 'score', label: 'Score' }); + views.add('fleet-ops:driver:table:columns', { id: 'score', label: 'Score' }); - await render(hbs``); + await render(hbs``); await click(buttonWithIcon('sliders')); const toggles = findAll('.customize-columns-dropdown-body input[type="checkbox"]'); await click(toggles[2]); assert.deepEqual(headerTexts(), ['Name', 'Status'], 'the registered column is hidden'); - views.add('fleet-ops:table:driver:columns', { id: 'rank', label: 'Rank' }); + views.add('fleet-ops:driver:table:columns', { id: 'rank', label: 'Rank' }); await settled(); assert.deepEqual(headerTexts(), ['Name', 'Status', 'Rank'], 'the registered column stays hidden while a new one appears'); @@ -184,11 +184,11 @@ module('Integration | Component | layout/resource registries', function (hooks) module('Layout::Resource::TabularActions', function () { test('registered toolbar buttons and bulk actions are merged', async function (assert) { const views = setupResourceView(this.owner); - views.add('iam:table:user:actions', { id: 'import', text: 'Import from Acme' }); - views.add('iam:table:user:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); + views.add('iam:user:table:actions', { id: 'import', text: 'Import from Acme' }); + views.add('iam:user:table:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); this.set('table', { selectedRows: [{ id: 1 }] }); - await render(hbs``); + await render(hbs``); assert.ok( findAll('button').some((button) => button.textContent.includes('Import from Acme')), @@ -207,9 +207,9 @@ module('Integration | Component | layout/resource registries', function (hooks) test('without a table the selection is empty and no bulk menu is offered', async function (assert) { const views = setupResourceView(this.owner); - views.add('iam:table:user:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); + views.add('iam:user:table:bulk-actions', { id: 'export', label: 'Export to Acme', fn: () => {} }); - await render(hbs``); + await render(hbs``); assert.notOk(buttonWithIcon('layer-group'), 'there is nothing to act on'); const { context } = views.calls.find((call) => call.slot === 'actions'); @@ -231,12 +231,12 @@ module('Integration | Component | layout/resource registries', function (hooks) module('Layout::Resource::Panel', function () { test('registered header buttons and menu items join the existing dropdown', async function (assert) { const views = setupResourceView(this.owner); - views.add('ledger:details:invoice:actions', { id: 'print', text: 'Print label' }); - views.add('ledger:details:invoice:menu', { id: 'acme', label: 'Send to Acme' }); + views.add('ledger:invoice:details:actions', { id: 'print', text: 'Print label' }); + views.add('ledger:invoice:details:menu', { id: 'acme', label: 'Send to Acme' }); this.set('resource', { id: 'inv_1' }); this.set('actionButtons', [{ id: 'more', icon: 'ellipsis-h', items: [{ id: 'void', label: 'Void' }] }]); - await render(hbs``); + await render(hbs``); assert.ok( findAll('button').some((button) => button.textContent.includes('Print label')), @@ -256,9 +256,9 @@ module('Integration | Component | layout/resource registries', function (hooks) test('registered menu items get a dropdown of their own when there is none', async function (assert) { const views = setupResourceView(this.owner); - views.add('ledger:details:invoice:menu', { id: 'acme', label: 'Send to Acme' }); + views.add('ledger:invoice:details:menu', { id: 'acme', label: 'Send to Acme' }); - await render(hbs``); + await render(hbs``); await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger'); assert.deepEqual( @@ -270,20 +270,20 @@ module('Integration | Component | layout/resource registries', function (hooks) test('no dropdown is added when nothing is registered for the menu', async function (assert) { setupResourceView(this.owner); - await render(hbs``); + await render(hbs``); assert.notOk(buttonWithIcon('ellipsis')); }); test('a custom header component receives the merged buttons', async function (assert) { const views = setupResourceView(this.owner); - views.add('ledger:details:invoice:actions', { id: 'print', text: 'Print label' }); + views.add('ledger:invoice:details:actions', { id: 'print', text: 'Print label' }); this.owner.register( 'component:test-panel-header', setComponentTemplate(hbs`
{{#each @actionButtons as |b|}}{{b.text}}{{/each}}
`, class extends Component {}) ); - await render(hbs``); + await render(hbs``); assert.dom('.custom-header .custom-button').hasText('Print label'); }); diff --git a/tests/unit/utils/resource-view-test.js b/tests/unit/utils/resource-view-test.js index acba9064..0cd29fc5 100644 --- a/tests/unit/utils/resource-view-test.js +++ b/tests/unit/utils/resource-view-test.js @@ -21,24 +21,24 @@ module('Unit | Utility | resource-view', function () { test('mergeHeaderButtons leaves the buttons alone without a service or registry', function (assert) { const buttons = [{ id: 'edit' }]; - assert.strictEqual(mergeHeaderButtons(null, 'ledger:details:invoice', buttons), buttons); + assert.strictEqual(mergeHeaderButtons(null, 'ledger:invoice:details', buttons), buttons); assert.strictEqual(mergeHeaderButtons(new ResourceViewStub(), null, buttons), buttons); assert.deepEqual(mergeHeaderButtons(null, null, undefined), []); }); test('mergeHeaderButtons merges actions, and menu items only when asked', function (assert) { const views = new ResourceViewStub(); - views.add('ledger:details:invoice:actions', { id: 'print' }); - views.add('ledger:details:invoice:menu', { id: 'acme' }); + views.add('ledger:invoice:details:actions', { id: 'print' }); + views.add('ledger:invoice:details:menu', { id: 'acme' }); assert.deepEqual( - mergeHeaderButtons(views, 'ledger:details:invoice', [{ id: 'edit' }]).map((b) => b.id), + mergeHeaderButtons(views, 'ledger:invoice:details', [{ id: 'edit' }]).map((b) => b.id), ['edit', 'print'], 'table toolbars take no menu' ); const dropdown = { id: 'more', items: [{ id: 'void' }] }; - const merged = mergeHeaderButtons(views, 'ledger:details:invoice', [{ id: 'edit' }, dropdown], {}, { withMenu: true }); + const merged = mergeHeaderButtons(views, 'ledger:invoice:details', [{ id: 'edit' }, dropdown], {}, { withMenu: true }); assert.deepEqual( merged.map((b) => b.id), ['edit', 'more', 'print'] @@ -53,7 +53,7 @@ module('Unit | Utility | resource-view', function () { 'the given dropdown is not modified' ); - const appended = mergeHeaderButtons(views, 'ledger:details:invoice', [], {}, { withMenu: true }); + const appended = mergeHeaderButtons(views, 'ledger:invoice:details', [], {}, { withMenu: true }); assert.deepEqual( appended.map((b) => b.id), ['print', 'registered-menu'] @@ -63,6 +63,6 @@ module('Unit | Utility | resource-view', function () { ['acme'] ); - assert.deepEqual(mergeHeaderButtons(new ResourceViewStub(), 'ledger:details:invoice', [], {}, { withMenu: true }), [], 'no dropdown is added for an empty menu'); + assert.deepEqual(mergeHeaderButtons(new ResourceViewStub(), 'ledger:invoice:details', [], {}, { withMenu: true }), [], 'no dropdown is added for an empty menu'); }); }); From 4cce93d3347465a40e6e494c4ccb28deb1eb58cc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 11:12:36 +0800 Subject: [PATCH 06/11] fix: the playground build runs ember-cli from the package's own dependencies CI invokes the script with node rather than pnpm run, so node_modules/.bin is not on PATH and spawning a bare `ember` failed with ENOENT. --- scripts/build-playground.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/build-playground.js b/scripts/build-playground.js index e1c8306a..ae8e276b 100755 --- a/scripts/build-playground.js +++ b/scripts/build-playground.js @@ -55,7 +55,12 @@ function main(argv) { console.log(`Building the playground for ${basePath} into ${output}/`); - execFileSync('ember', ['build', '--environment=production', `--output-path=${outputPath}`], { + // Run ember-cli from this package's own dependencies with the current Node. CI invokes this + // script with `node` rather than `pnpm run`, so `node_modules/.bin` is not on PATH and a bare + // `ember` would only resolve where ember-cli happens to be installed globally. + const emberBin = require.resolve('ember-cli/bin/ember', { paths: [projectRoot] }); + + execFileSync(process.execPath, [emberBin, 'build', '--environment=production', `--output-path=${outputPath}`], { stdio: 'inherit', env: { ...process.env, PLAYGROUND: 'true', PLAYGROUND_ROOT_URL: basePath }, }); From d5e546090d3c5e23e7e68e6ced039ff1baa68072 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 11:30:02 +0800 Subject: [PATCH 07/11] Style badges for configured order lifecycle statuses: requested, handed-over, extended, returned --- addon/styles/components/badge.css | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css index 8a0a9dc9..e20ee5c7 100644 --- a/addon/styles/components/badge.css +++ b/addon/styles/components/badge.css @@ -596,6 +596,43 @@ @apply text-gray-300; } +/* ── Configured order lifecycle statuses ── + Activity codes used by order configs with their own lifecycle (meta.lifecycle), + such as loans and rentals, that the families above don't cover. `assigned`, + `active`, `completed` and `cancelled` are already styled above. */ + +.status-badge.requested-status-badge > span { + @apply bg-slate-800 border-slate-700 text-slate-100; +} + +.status-badge.requested-status-badge > span svg { + @apply text-slate-300; +} + +.status-badge.handed-over-status-badge > span { + @apply bg-violet-800 border-violet-700 text-violet-100; +} + +.status-badge.handed-over-status-badge > span svg { + @apply text-violet-300; +} + +.status-badge.extended-status-badge > span { + @apply bg-orange-700 border-orange-700 text-orange-100; +} + +.status-badge.extended-status-badge > span svg { + @apply text-orange-300; +} + +.status-badge.returned-status-badge > span { + @apply bg-cyan-800 border-cyan-700 text-cyan-100; +} + +.status-badge.returned-status-badge > span svg { + @apply text-cyan-300; +} + body[data-theme='dark'] .status-badge:not(.shadow-none) { @apply shadow; } From acb782ba6b68da34892007069a33925303738717 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 11:39:22 +0800 Subject: [PATCH 08/11] lint: allow the playground build script to resolve ember-cli --- .eslintrc.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index 22db5fe1..aa4d87bb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,6 +44,8 @@ module.exports = { '@tailwindcss/forms', // Build-time only: required from index.js when COVERAGE=true. 'ember-cli-code-coverage', + // Dev-only: scripts/build-playground.js resolves ember-cli's bin from this package. + 'ember-cli', ], }, ], From 5d42ba195d8a0c20fdc458b1dba26d3dc185fdb8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 11:55:54 +0800 Subject: [PATCH 09/11] ci: a Codecov outage no longer fails the test job The 100% coverage gate is what blocks a merge; the Codecov upload only reports. cli.codecov.io refusing the TLS handshake failed two runs whose tests and coverage gate had passed. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 930174f2..4625a019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,10 @@ jobs: - name: Verify LCOV report exists run: test -s coverage/lcov.info || (echo 'coverage/lcov.info missing or empty' && exit 1) + # The coverage gate above is what blocks a merge; Codecov only reports. A Codecov outage (its + # CLI download host refusing TLS, say) shows up as a failed step without failing the job. - name: Upload coverage to Codecov + continue-on-error: true uses: codecov/codecov-action@v5 with: files: coverage/lcov.info From 23dc54972371425f9b3dd2ebd805630e5c6011ff Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 13:36:33 +0800 Subject: [PATCH 10/11] DatePicker: accept typed dates and follow later value changes The field only reacted to clicks in the calendar: typed text never reached onChange/onSelect/onDateChanged, and a value arriving after the field was set up (a record that finished loading) never updated the calendar or the field. - A complete, valid date typed in the picker's format is selected and reported like a click; an empty field clears the selection and reports it; anything else is reverted. - A changed @value re-selects the date silently; the incoming value may be a Date, a timestamp, a string in the picker's format or an ISO string. - Integration tests for typing and for later value changes. --- addon/components/date-picker.hbs | 11 +- addon/components/date-picker.js | 141 +++++++++++++++--- .../components/date-picker-test.js | 111 +++++++++++++- 3 files changed, 244 insertions(+), 19 deletions(-) diff --git a/addon/components/date-picker.hbs b/addon/components/date-picker.hbs index b1536622..8ddf1a58 100644 --- a/addon/components/date-picker.hbs +++ b/addon/components/date-picker.hbs @@ -1,3 +1,12 @@
- +
\ No newline at end of file diff --git a/addon/components/date-picker.js b/addon/components/date-picker.js index d11a2bc6..5702d5b5 100644 --- a/addon/components/date-picker.js +++ b/addon/components/date-picker.js @@ -4,6 +4,7 @@ import localeEn from 'air-datepicker/locale/en'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { isArray } from '@ember/array'; +import { parse, isValid } from 'date-fns'; export default class DatePickerComponent extends Component { @tracked nodeRef; @@ -15,11 +16,78 @@ export default class DatePickerComponent extends Component { dateFormat: 'yyyy-MM-dd', }; + get dateFormat() { + return this.args.dateFormat || this.defaultOptions.dateFormat; + } + @action setupComponent(node) { this.nodeRef = node; this.airDatePickerRef = new AirDatepicker(node, this.getOptions()); } + /** + * The field accepts typing as well as picking. A complete, valid date in the picker's format + * is selected and reported exactly like a click in the calendar; an empty field clears the + * selection; anything else is reverted so the field never shows text the picker does not hold. + */ + @action handleTypedValue(event) { + const picker = this.airDatePickerRef; + /* istanbul ignore if -- the listener is attached to the element the picker was created on */ + if (!picker) { + return; + } + + const text = (event.target.value ?? '').trim(); + if (text === '') { + if (picker.selectedDates.length) { + picker.clear({ silent: true }); + this.report({ date: undefined, formattedDate: '', datepicker: picker }); + } + return; + } + + const date = this.parseText(text); + if (date) { + picker.setViewDate(date); + picker.selectDate(date); + return; + } + + event.target.value = picker.selectedDates.length ? this.format(picker.selectedDates[0]) : ''; + } + + /** + * Keeps the picker in step with a value that changes after the field was set up (a record that + * finishes loading, a reset). Silent: the change came from the consumer, so it is not reported. + */ + @action syncValue(element, [value]) { + const picker = this.airDatePickerRef; + /* istanbul ignore if -- `did-update` only runs after `did-insert` created the picker */ + if (!picker) { + return; + } + + // `` echoes typed text back through `@value` on every keystroke; while the field has + // focus the text is still being typed and `handleTypedValue` settles it on change. + if (document.activeElement === element) { + return; + } + + const dates = this.toDates(value); + const current = picker.selectedDates; + const same = dates.length === current.length && dates.every((date, index) => date.getTime() === current[index].getTime()); + if (same) { + return; + } + + picker.clear({ silent: true }); + if (dates.length) { + picker.setViewDate(dates[0]); + picker.selectDate(dates, { silent: true }); + } + element.value = dates.map((date) => this.format(date)).join(', '); + } + getOptions() { const options = this.defaultOptions; const { value, dateFormat, inline } = this.args; @@ -33,7 +101,7 @@ export default class DatePickerComponent extends Component { } if (value) { - options.selectedDates = this.parseValue(value); + options.selectedDates = this.toDates(value); } /* istanbul ignore next -- `nodeRef` is assigned by the insert hook before the picker is @@ -42,20 +110,6 @@ export default class DatePickerComponent extends Component { options.container = this.nodeRef.parentNode; } - const onSelect = function (selection) { - if (typeof this.args.onSelect === 'function') { - this.args.onSelect(...arguments); - } - - if (typeof this.args.onChange === 'function') { - this.args.onChange(selection.date, ...arguments); - } - - if (typeof this.args.onDateChanged === 'function') { - this.args.onDateChanged(selection.formattedDate, ...arguments); - } - }; - Object.keys(this.args).forEach((key) => { if (key === 'value' || key === 'onSelect') { return; @@ -66,20 +120,73 @@ export default class DatePickerComponent extends Component { } }); - options.onSelect = onSelect.bind(this); + options.onSelect = (selection) => this.report(selection); return options; } + /** Reports a selection through every handler the consumer gave, as the calendar does. */ + report(selection, ...rest) { + if (typeof this.args.onSelect === 'function') { + this.args.onSelect(selection, ...rest); + } + + if (typeof this.args.onChange === 'function') { + this.args.onChange(selection.date, selection, ...rest); + } + + if (typeof this.args.onDateChanged === 'function') { + this.args.onDateChanged(selection.formattedDate, selection, ...rest); + } + } + parseValue(value) { if (isArray(value)) { return value; } if (typeof value === 'string' && value.includes(',')) { - value = value.split(',').map((date) => new Date(date)); + return value.split(','); } return [value]; } + + /** The incoming value as Date objects: Dates, timestamps, strings in the picker's format or ISO. */ + toDates(value) { + return this.parseValue(value) + .map((item) => this.toDate(item)) + .filter(Boolean); + } + + toDate(value) { + if (value instanceof Date) { + return isValid(value) ? value : null; + } + + if (typeof value === 'number') { + return new Date(value); + } + + if (typeof value === 'string' && value.trim() !== '') { + return this.parseText(value.trim()) ?? this.nativeDate(value); + } + + return null; + } + + nativeDate(value) { + const date = new Date(value); + return isValid(date) ? date : null; + } + + /** Text in the picker's format -> Date, or null when it is not a complete valid date. */ + parseText(text) { + const date = parse(text, this.dateFormat, new Date()); + return isValid(date) ? date : null; + } + + format(date) { + return this.airDatePickerRef.formatDate(date, this.dateFormat); + } } diff --git a/tests/integration/components/date-picker-test.js b/tests/integration/components/date-picker-test.js index d13d4047..deb20ff9 100644 --- a/tests/integration/components/date-picker-test.js +++ b/tests/integration/components/date-picker-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, find, findAll } from '@ember/test-helpers'; +import { render, click, find, findAll, fillIn, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; const INPUT = '.fleetbase-date-picker'; @@ -170,4 +170,113 @@ module('Integration | Component | date-picker', function (hooks) { assert.deepEqual(dateChanges, []); }); }); + + module('typing a date', function () { + test('a complete date in the picker format is selected and reported like a click', async function (assert) { + this.set('dateFormat', 'dd/MM/yyyy'); + + await render(TEMPLATE); + await fillIn(INPUT, '28/12/1990'); + + assert.strictEqual(changes.length, 1, 'onChange receives the typed date'); + assert.strictEqual(changes[0].getFullYear(), 1990); + assert.strictEqual(changes[0].getMonth(), 11); + assert.strictEqual(changes[0].getDate(), 28); + assert.deepEqual(dateChanges, ['28/12/1990'], 'onDateChanged receives it formatted'); + assert.strictEqual(selections.length, 1); + assert.dom(INPUT).hasValue('28/12/1990'); + + await click(INPUT); + assert.dom('.air-datepicker-cell.-selected-').hasText('28', 'the calendar opens on the typed day'); + }); + + test('text that is not a date is reverted and not reported', async function (assert) { + await render(TEMPLATE); + await fillIn(INPUT, 'yesterday'); + + assert.deepEqual(changes, []); + assert.dom(INPUT).hasValue('', 'nothing was selected, so the field is emptied'); + }); + + test('an invalid entry falls back to the current selection', async function (assert) { + this.set('value', '2026-03-12'); + + await render(TEMPLATE); + await fillIn(INPUT, '2026-13-45'); + + assert.deepEqual(changes, []); + assert.dom(INPUT).hasValue('2026-03-12'); + }); + + test('emptying the field clears the selection and reports it', async function (assert) { + this.set('value', '2026-03-12'); + + await render(TEMPLATE); + await fillIn(INPUT, ''); + + assert.strictEqual(changes.length, 1, 'the clearing is reported'); + assert.strictEqual(changes[0], undefined, 'with no date'); + assert.deepEqual(dateChanges, ['']); + + await click(INPUT); + assert.strictEqual(find('.air-datepicker-cell.-selected-'), null, 'no day is marked any more'); + }); + + test('emptying an already empty field reports nothing', async function (assert) { + await render(TEMPLATE); + await fillIn(INPUT, ''); + + assert.deepEqual(changes, []); + }); + }); + + module('a value that changes later', function () { + test('the new date is shown and selected without being reported', async function (assert) { + this.set('value', '2026-03-12'); + + await render(TEMPLATE); + this.set('value', '2026-03-20'); + await settled(); + + assert.dom(INPUT).hasValue('2026-03-20'); + assert.deepEqual(changes, [], 'a change made by the consumer is not echoed back'); + + await click(INPUT); + assert.dom('.air-datepicker-cell.-selected-').hasText('20'); + }); + + test('a Date object is accepted', async function (assert) { + this.set('value', null); + + await render(TEMPLATE); + this.set('value', new Date(2026, 2, 20)); + await settled(); + + assert.dom(INPUT).hasValue('2026-03-20'); + }); + + test('clearing the value clears the field', async function (assert) { + this.set('value', '2026-03-12'); + + await render(TEMPLATE); + this.set('value', null); + await settled(); + + assert.dom(INPUT).hasValue(''); + + await click(INPUT); + assert.strictEqual(find('.air-datepicker-cell.-selected-'), null); + }); + + test('the same date again changes nothing', async function (assert) { + this.set('value', '2026-03-12'); + + await render(TEMPLATE); + this.set('value', new Date(2026, 2, 12)); + await settled(); + + assert.dom(INPUT).hasValue('2026-03-12'); + assert.deepEqual(changes, []); + }); + }); }); From 8af2b0a13c7e1a0da2102986bea8c15c0a82e073 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 15:10:58 +0800 Subject: [PATCH 11/11] fix: DatePicker no longer reports a calendar pick twice when the field loses focus The picker writes its selection into the field, so blurring it after a pick fired `change` with that text and handleTypedValue selected the date again, reporting it a second time (filter/date range tests saw 2 reports for 1 pick). Text that matches the current selection is now ignored, and invalid input reverts to the whole selection rather than its first date. Tests cover the conversions the previous commit added but never exercised (timestamps, ISO strings, invalid Dates, blank strings), restoring the 100% coverage gate. --- addon/components/date-picker.js | 23 +++++++--- .../components/date-picker-test.js | 44 ++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/addon/components/date-picker.js b/addon/components/date-picker.js index 5702d5b5..355a24b5 100644 --- a/addon/components/date-picker.js +++ b/addon/components/date-picker.js @@ -37,12 +37,18 @@ export default class DatePickerComponent extends Component { return; } - const text = (event.target.value ?? '').trim(); + const text = event.target.value.trim(); + + // The picker writes its own selection into the field, so leaving the field after picking in + // the calendar fires `change` with text the picker already holds. That is not a new choice. + if (text === this.selectionText(picker)) { + return; + } + + // Reaching here with an empty field means there was a selection to clear. if (text === '') { - if (picker.selectedDates.length) { - picker.clear({ silent: true }); - this.report({ date: undefined, formattedDate: '', datepicker: picker }); - } + picker.clear({ silent: true }); + this.report({ date: undefined, formattedDate: '', datepicker: picker }); return; } @@ -53,7 +59,7 @@ export default class DatePickerComponent extends Component { return; } - event.target.value = picker.selectedDates.length ? this.format(picker.selectedDates[0]) : ''; + event.target.value = this.selectionText(picker); } /** @@ -186,6 +192,11 @@ export default class DatePickerComponent extends Component { return isValid(date) ? date : null; } + /** The selection as the picker writes it into the field. */ + selectionText(picker) { + return picker.selectedDates.map((date) => this.format(date)).join(picker.opts.multipleDatesSeparator); + } + format(date) { return this.airDatePickerRef.formatDate(date, this.dateFormat); } diff --git a/tests/integration/components/date-picker-test.js b/tests/integration/components/date-picker-test.js index deb20ff9..a8b3b073 100644 --- a/tests/integration/components/date-picker-test.js +++ b/tests/integration/components/date-picker-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, find, findAll, fillIn, settled } from '@ember/test-helpers'; +import { render, click, find, findAll, fillIn, settled, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; const INPUT = '.fleetbase-date-picker'; @@ -222,6 +222,15 @@ module('Integration | Component | date-picker', function (hooks) { assert.strictEqual(find('.air-datepicker-cell.-selected-'), null, 'no day is marked any more'); }); + test('leaving the field after picking in the calendar does not report the pick again', async function (assert) { + await render(TEMPLATE); + await click(INPUT); + await click(days()[9]); + await triggerEvent(INPUT, 'change'); + + assert.strictEqual(changes.length, 1, 'the text the picker wrote is not a second choice'); + }); + test('emptying an already empty field reports nothing', async function (assert) { await render(TEMPLATE); await fillIn(INPUT, ''); @@ -278,5 +287,38 @@ module('Integration | Component | date-picker', function (hooks) { assert.dom(INPUT).hasValue('2026-03-12'); assert.deepEqual(changes, []); }); + + test('a timestamp is accepted', async function (assert) { + this.set('value', null); + + await render(TEMPLATE); + this.set('value', new Date(2026, 2, 20).getTime()); + await settled(); + + assert.dom(INPUT).hasValue('2026-03-20'); + }); + + test('an ISO string is accepted', async function (assert) { + this.set('value', null); + + await render(TEMPLATE); + this.set('value', new Date(2026, 2, 20, 9, 30).toISOString()); + await settled(); + + assert.dom(INPUT).hasValue('2026-03-20'); + }); + + test('an invalid Date, a blank string and unparseable text all clear the field', async function (assert) { + for (const value of [new Date('not a date'), ' ', 'yesterday']) { + this.set('value', '2026-03-12'); + await render(TEMPLATE); + this.set('value', value); + await settled(); + + assert.dom(INPUT).hasValue('', `${String(value)} is not a date`); + } + + assert.deepEqual(changes, [], 'none of it is reported'); + }); }); });