From 429ce50a047d67fb075b702326c1813f32f7dbf0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 30 Sep 2026 20:30:56 +0800 Subject: [PATCH 1/3] 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 2/3] 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 c62db6e01f0f3eed3f995b8a1871136255265357 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 11:07:27 +0800 Subject: [PATCH 3/3] 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``);