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}}
{{or filter.filterLabel filter.label}}
{{/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}}
+
+
+
+ {{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}}
+
+ {{/if}}
+{{/each}}
diff --git a/addon/components/layout/resource/bulk-actions.hbs b/addon/components/layout/resource/bulk-actions.hbs
new file mode 100644
index 00000000..7f2041ac
--- /dev/null
+++ b/addon/components/layout/resource/bulk-actions.hbs
@@ -0,0 +1,40 @@
+
+
+
diff --git a/addon/components/layout/resource/panel.hbs b/addon/components/layout/resource/panel.hbs
index c712bb0e..152aec32 100644
--- a/addon/components/layout/resource/panel.hbs
+++ b/addon/components/layout/resource/panel.hbs
@@ -24,7 +24,7 @@
@saveDisabled={{@saveDisabled}}
@pojoResource={{@pojoResource}}
@onPressCancel={{@onPressCancel}}
- @actionButtons={{@actionButtons}}
+ @actionButtons={{this.actionButtons}}
@headerClass={{@headerClass}}
@modelName={{@modelName}}
@saveOptions={{@saveOptions}}
@@ -46,7 +46,7 @@
@pojoResource={{@pojoResource}}
@saveOptions={{@saveOptions}}
@onPressCancel={{this.onPressCancel}}
- @actionButtons={{@actionButtons}}
+ @actionButtons={{this.actionButtons}}
@modelName={{@modelName}}
@headerLeftClass={{@headerLeftClass}}
@headerLeftInnerClass={{@headerLeftInnerClass}}
diff --git a/addon/components/layout/resource/panel.js b/addon/components/layout/resource/panel.js
index abe05840..666b823c 100644
--- a/addon/components/layout/resource/panel.js
+++ b/addon/components/layout/resource/panel.js
@@ -2,8 +2,10 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback';
import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments';
+import lookupResourceView, { mergeHeaderButtons } from '../../../utils/resource-view';
export default class LayoutResourcePanelComponent extends Component {
@service store;
@@ -32,6 +34,23 @@ export default class LayoutResourcePanelComponent extends Component {
return this.args.authSchema ?? 'fleet-ops';
}
+ /**
+ * `@actionButtons` with what extensions registered under `@registry`
+ * (a details prefix such as `ledger:details:invoice`) merged in: header
+ * buttons from its `actions` slot and dropdown items from its `menu` slot.
+ */
+ get actionButtons() {
+ const component = this;
+ const context = {
+ resource: this.resource,
+ get panel() {
+ return component.context;
+ },
+ };
+
+ return mergeHeaderButtons(lookupResourceView(getOwner(this)), this.args.registry, this.args.actionButtons, context, { withMenu: true });
+ }
+
constructor() {
super(...arguments);
applyContextComponentArguments(this);
diff --git a/addon/components/layout/resource/panel/header-actions.hbs b/addon/components/layout/resource/panel/header-actions.hbs
index de38b532..c23f73dc 100644
--- a/addon/components/layout/resource/panel/header-actions.hbs
+++ b/addon/components/layout/resource/panel/header-actions.hbs
@@ -21,90 +21,7 @@
/>
{{/if}}
{{/if}}
- {{#each @actionButtons as |actionButton|}}
- {{#if actionButton.items}}
-
-
-
- {{else if actionButton.component}}
- {{component
- actionButton.component
- 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
- }}
- {{else}}
-
- {{/if}}
- {{/each}}
+
{{#if @onPressCancel}}
-
-
+
{{/if}}
-{{#each @actionButtons as |actionButton|}}
- {{#if actionButton.items}}
-
-
-
- {{else if actionButton.component}}
- {{component
- actionButton.component
- 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
- }}
- {{else}}
-
- {{/if}}
-{{/each}}
+
{{#if @onPressNew}}
{{/if}}
diff --git a/addon/components/layout/resource/tabular-actions.js b/addon/components/layout/resource/tabular-actions.js
index 38d0e11d..1b0712b7 100644
--- a/addon/components/layout/resource/tabular-actions.js
+++ b/addon/components/layout/resource/tabular-actions.js
@@ -1,10 +1,42 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import lookupResourceView, { mergeHeaderButtons } from '../../../utils/resource-view';
export default class LayoutResourceTabularActionsComponent extends Component {
@service filters;
+ get resourceView() {
+ return lookupResourceView(getOwner(this));
+ }
+
+ /**
+ * What registered handlers receive as their context. This component only
+ * renders the header, so registered columns and row actions are merged by
+ * the caller that renders the table (`ResourceActionService.mergeRegisteredColumns`).
+ */
+ get viewContext() {
+ const { args } = this;
+ return {
+ controller: args.controller,
+ get table() {
+ return args.table;
+ },
+ getSelectedRows: () => args.table?.selectedRows ?? [],
+ };
+ }
+
+ get actionButtons() {
+ return mergeHeaderButtons(this.resourceView, this.args.registry, this.args.actionButtons, this.viewContext);
+ }
+
+ get bulkActions() {
+ const { resourceView } = this;
+ const { registry, bulkActions } = this.args;
+ return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions, this.viewContext) : bulkActions;
+ }
+
/**
* The column picker toggles `hidden` on the column objects themselves;
* the list is handed back only to a caller that asked for it, never
diff --git a/addon/components/layout/resource/tabular.hbs b/addon/components/layout/resource/tabular.hbs
index be9280e4..d8f7676b 100644
--- a/addon/components/layout/resource/tabular.hbs
+++ b/addon/components/layout/resource/tabular.hbs
@@ -3,10 +3,13 @@
@title={{@title}}
@searchQuery={{@searchQuery}}
@onSearch={{@onSearch}}
+ @searchDisabled={{@searchDisabled}}
@actionsWrapperClass="space-x-1"
@actionsRenderDestination={{@actionsRenderDestination}}
class={{@headerClass}}
>
+ {{! Extra controls a view needs in its header, before the standard ones. }}
+ {{yield to="header"}}
{{#if @onSubmitBulkSearch}}
{{/if}}
@@ -28,114 +31,9 @@
{{/unless}}
{{#if (safe-has this.table "selectedRows")}}
-
-
-
+
{{/if}}
- {{#each @actionButtons as |actionButton|}}
- {{#if actionButton.items}}
-
-
-
- {{else if actionButton.component}}
- {{component
- actionButton.component
- 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
- }}
- {{else}}
-
- {{/if}}
- {{/each}}
+
{{#if @onPressNew}}
{{/if}}
diff --git a/addon/components/layout/resource/tabular.js b/addon/components/layout/resource/tabular.js
index 1864898b..17621dd1 100644
--- a/addon/components/layout/resource/tabular.js
+++ b/addon/components/layout/resource/tabular.js
@@ -1,30 +1,96 @@
import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
+import { tracked, cached } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isNone } from '@ember/utils';
+import { getOwner } from '@ember/application';
+import lookupResourceView, { mergeHeaderButtons } from '../../../utils/resource-view';
+
+/**
+ * A key that survives the column objects being rebuilt, so a column the
+ * user hid stays hidden when the columns are recomputed.
+ */
+export function columnKey(column) {
+ return column?.id ?? column?.valuePath ?? column?.label;
+}
export default class LayoutResourceTabularComponent extends Component {
@service filters;
@tracked table;
- /* istanbul ignore next -- @tracked initializer: the value is assigned before it is ever
- read, so this lazy initializer is never invoked. */
- @tracked columns = [];
- get checkboxSticky() {
- if (!isNone(this.args.checkboxSticky)) return this.args.checkboxSticky;
+ /**
+ * What the column picker set, by column key. Applied over the columns on
+ * every recompute rather than kept on the column objects, which a
+ * controller getter may rebuild.
+ */
+ @tracked columnVisibility = {};
+
+ get resourceView() {
+ return lookupResourceView(getOwner(this));
+ }
- const columns = this.args.columns ?? this.columns;
- return columns.some((c) => !isNone(c?.sticky));
+ /**
+ * What registered handlers receive as their context. `table` is a getter:
+ * the table is only set up after the first render.
+ */
+ get viewContext() {
+ const component = this;
+ return {
+ controller: this.args.controller,
+ get table() {
+ return component.table;
+ },
+ getSelectedRows: () => component.table?.selectedRows ?? [],
+ };
}
- constructor(owner, { columns = [] }) {
- super(...arguments);
- this.columns = columns;
+ /**
+ * `@columns` with anything registered under `@registry` merged in, and the
+ * column picker's choices applied.
+ */
+ @cached get columns() {
+ let columns = this.args.columns ?? [];
+ const { resourceView } = this;
+ const { registry } = this.args;
+
+ if (resourceView && registry) {
+ columns = resourceView.mergeSlot(registry, 'columns', columns, this.viewContext);
+ columns = resourceView.mergeRowActions(registry, columns, this.viewContext);
+ }
+
+ const visibility = this.columnVisibility;
+ for (const column of columns) {
+ const key = columnKey(column);
+ if (key in visibility && column.hidden !== visibility[key]) {
+ column.hidden = visibility[key];
+ }
+ }
+
+ return columns;
+ }
+
+ get actionButtons() {
+ return mergeHeaderButtons(this.resourceView, this.args.registry, this.args.actionButtons, this.viewContext);
+ }
+
+ get bulkActions() {
+ const { resourceView } = this;
+ const { registry, bulkActions } = this.args;
+ return resourceView && registry ? resourceView.mergeSlot(registry, 'bulk-actions', bulkActions, this.viewContext) : bulkActions;
+ }
+
+ get checkboxSticky() {
+ if (!isNone(this.args.checkboxSticky)) return this.args.checkboxSticky;
+
+ return this.columns.some((c) => !isNone(c?.sticky));
}
@action setColumns(columns) {
- this.columns = columns;
+ const visibility = { ...this.columnVisibility };
+ for (const column of columns) {
+ visibility[columnKey(column)] = Boolean(column.hidden);
+ }
+ this.columnVisibility = visibility;
}
@action setupTable(table) {
diff --git a/addon/components/resource-context-panel.hbs b/addon/components/resource-context-panel.hbs
index b9a3e4c8..7479b291 100644
--- a/addon/components/resource-context-panel.hbs
+++ b/addon/components/resource-context-panel.hbs
@@ -16,6 +16,7 @@
""
}}
@actionButtons={{overlay.actionButtons}}
+ @registry={{overlay.registry}}
@width={{overlay.computedWidth}}
@isResizable={{overlay.computedResizable}}
@onPressCancel={{fn this.closeOverlay overlay.id}}
diff --git a/addon/components/table/td.hbs b/addon/components/table/td.hbs
index addc89e8..b1630e62 100644
--- a/addon/components/table/td.hbs
+++ b/addon/components/table/td.hbs
@@ -2,7 +2,14 @@
{{#if (has-block)}}
{{yield}}
{{else}}
- {{#if @column.cellComponent}}
+ {{#if (is-object @column.cellComponent)}}
+ {{! An ExtensionComponent: a cell an extension registered from its own engine. }}
+ {{#let (lazy-engine-component @column.cellComponent) as |cellComponent|}}
+ {{#if cellComponent}}
+ {{component cellComponent column=@column row=@row value=@value}}
+ {{/if}}
+ {{/let}}
+ {{else if @column.cellComponent}}
{{component @column.cellComponent column=@column row=@row value=@value}}
{{else}}
diff --git a/addon/utils/resource-view.js b/addon/utils/resource-view.js
new file mode 100644
index 00000000..335de462
--- /dev/null
+++ b/addon/utils/resource-view.js
@@ -0,0 +1,55 @@
+/**
+ * The shared resource view registry service from `@fleetbase/ember-core`, or
+ * `null` when this build has none.
+ *
+ * Looked up rather than injected: an engine built against an older ember-core
+ * does not provide it, and a missing registry must leave every view exactly as
+ * it was.
+ *
+ * @param {Object} owner
+ * @returns {Object|null}
+ */
+export default function lookupResourceView(owner) {
+ try {
+ return owner?.lookup('service:universe/resource-view-service') ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Merge what extensions registered into a view's header buttons.
+ *
+ * `actions` buttons are merged in place. `menu` items go into the first
+ * button that already has a dropdown (`items`), or into a new "…" dropdown
+ * when there is none.
+ *
+ * @param {Object|null} resourceView
+ * @param {String} registry A surface prefix, e.g. `ledger:details:invoice`
+ * @param {Array} buttons
+ * @param {Object} context
+ * @param {Object} options
+ * @param {Boolean} options.withMenu Whether to merge the `menu` slot (details views)
+ * @returns {Array}
+ */
+export function mergeHeaderButtons(resourceView, registry, buttons, context = {}, { withMenu = false } = {}) {
+ const base = buttons ?? [];
+ if (!resourceView || !registry) {
+ return base;
+ }
+
+ const merged = resourceView.mergeSlot(registry, 'actions', base, context);
+ if (!withMenu) {
+ return merged;
+ }
+
+ const dropdownIndex = merged.findIndex((button) => Array.isArray(button?.items));
+ if (dropdownIndex !== -1) {
+ const dropdown = merged[dropdownIndex];
+ const items = resourceView.mergeSlot(registry, 'menu', dropdown.items, context);
+ return [...merged.slice(0, dropdownIndex), { ...dropdown, items }, ...merged.slice(dropdownIndex + 1)];
+ }
+
+ const items = resourceView.mergeSlot(registry, 'menu', [], context);
+ return items.length ? [...merged, { id: 'registered-menu', icon: 'ellipsis-h', iconPrefix: 'fas', prefix: 'fas', renderInPlace: true, items }] : merged;
+}
diff --git a/app/components/layout/resource/action-buttons.js b/app/components/layout/resource/action-buttons.js
new file mode 100644
index 00000000..22aa0af2
--- /dev/null
+++ b/app/components/layout/resource/action-buttons.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/layout/resource/action-buttons';
diff --git a/app/components/layout/resource/bulk-actions.js b/app/components/layout/resource/bulk-actions.js
new file mode 100644
index 00000000..c280c4e4
--- /dev/null
+++ b/app/components/layout/resource/bulk-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/layout/resource/bulk-actions';
diff --git a/app/utils/resource-view.js b/app/utils/resource-view.js
new file mode 100644
index 00000000..5dbd6000
--- /dev/null
+++ b/app/utils/resource-view.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/utils/resource-view';
diff --git a/tests/helpers/resource-view-stubs.js b/tests/helpers/resource-view-stubs.js
new file mode 100644
index 00000000..92d93b68
--- /dev/null
+++ b/tests/helpers/resource-view-stubs.js
@@ -0,0 +1,72 @@
+import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * A stand-in for ember-core's `universe/resource-view-service`, which the
+ * ember-core release this addon tests against may not have yet.
+ *
+ * It keeps the contract the layout components rely on — `mergeSlot` and
+ * `mergeRowActions` — and appends registered items without placement logic
+ * (that is ember-core's to test). Every call is recorded so a test can check
+ * which registry and slot a view merged, and with what context.
+ */
+export class ResourceViewStub extends Service {
+ @tracked items = {};
+ calls = [];
+
+ add(name, ...items) {
+ this.items = { ...this.items, [name]: [...(this.items[name] ?? []), ...items] };
+ }
+
+ mergeSlot(prefix, slot, base, context) {
+ this.calls.push({ prefix, slot, context });
+ return [...(base ?? []), ...(this.items[`${prefix}:${slot}`] ?? [])];
+ }
+
+ mergeRowActions(prefix, columns, context) {
+ this.calls.push({ prefix, slot: 'row-actions', context });
+ const extra = this.items[`${prefix}:row-actions`];
+ if (!extra) {
+ return columns;
+ }
+
+ return columns.map((column) => (column.cellComponent === 'table/cell/dropdown' ? { ...column, actions: [...(column.actions ?? []), ...extra] } : column));
+ }
+}
+
+/**
+ * Register the stub and return it.
+ */
+export function setupResourceView(owner) {
+ owner.register('service:universe/resource-view-service', ResourceViewStub);
+ return owner.lookup('service:universe/resource-view-service');
+}
+
+/**
+ * Serve components from a fake, already-loaded extension engine, so an
+ * `ExtensionComponent`-shaped definition resolves through `lazy-engine-component`.
+ *
+ * @param {Object} owner
+ * @param {Object} components `{ 'cell/score': ComponentClass }`
+ */
+export function setupExtensionEngine(owner, components = {}) {
+ const registry = Object.fromEntries(Object.entries(components).map(([path, cls]) => [`component:${path}`, cls]));
+ const engine = {
+ hasRegistration: (key) => key in registry,
+ register: (key, value) => (registry[key] = value),
+ factoryFor: (key) => (registry[key] ? { class: registry[key] } : undefined),
+ };
+
+ owner.register(
+ 'service:universe/extension-manager',
+ class extends Service {
+ getEngineInstance() {
+ return engine;
+ }
+
+ ensureEngineLoaded() {
+ return Promise.resolve(engine);
+ }
+ }
+ );
+}
diff --git a/tests/integration/components/layout/resource/registry-test.js b/tests/integration/components/layout/resource/registry-test.js
new file mode 100644
index 00000000..2cdf47e5
--- /dev/null
+++ b/tests/integration/components/layout/resource/registry-test.js
@@ -0,0 +1,356 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click, find, findAll, settled } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import Component from '@glimmer/component';
+import { setComponentTemplate } from '@ember/component';
+import { setupResourceView, setupExtensionEngine } from 'dummy/tests/helpers/resource-view-stubs';
+
+const headerTexts = () =>
+ findAll('thead th')
+ .map((th) => th.textContent.trim())
+ .filter(Boolean);
+const buttonWithIcon = (icon) => findAll('button').find((button) => button.querySelector(`svg.fa-${icon}`));
+const buttonWithText = (text) => findAll('button').find((button) => button.textContent.includes(text));
+
+async function clickFound(assert, element, what) {
+ assert.ok(element, `${what} is rendered`);
+ await click(element);
+}
+const menuItems = () => findAll('.next-dd-menu .next-dd-item');
+
+function paginated(rows) {
+ const data = [...rows];
+ data.meta = { current_page: 1, last_page: 1, from: 1, to: rows.length, total: rows.length };
+ return data;
+}
+
+module('Integration | Component | layout/resource registries', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.set('rows', paginated([{ id: 1, name: 'Ada', status: 'active' }]));
+ this.set('columns', [
+ { id: 'name', label: 'Name', valuePath: 'name' },
+ { id: 'status', label: 'Status', valuePath: 'status' },
+ { label: '', cellComponent: 'table/cell/dropdown', ddButtonText: false, ddButtonIcon: 'ellipsis-h', actions: [{ id: 'view', label: 'View' }] },
+ ]);
+ });
+
+ 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' });
+
+ await render(hbs` `);
+
+ assert.deepEqual(headerTexts(), ['Name', 'Status']);
+ 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` `);
+
+ assert.deepEqual(headerTexts(), ['Name', 'Status']);
+ });
+
+ 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']) });
+ this.set('bulkActions', [{ label: 'Delete', fn: () => {} }]);
+ this.set('controller', { sort: null });
+
+ await render(hbs`
+
+ `);
+
+ assert.deepEqual(headerTexts(), ['Name', 'Status', 'Score'], 'the registered column is rendered');
+
+ await clickFound(assert, find('.cell-dropdown-button button'), 'the row menu trigger');
+ assert.deepEqual(
+ menuItems().map((item) => item.textContent.trim()),
+ ['View', 'Sync to Acme'],
+ 'the registered row action is in the row menu'
+ );
+ await click(menuItems()[1]);
+
+ await clickFound(assert, buttonWithText('Import from Acme'), 'the registered toolbar button');
+
+ // The bulk menu appears once a row is selected.
+ 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', 'Export to Acme'],
+ 'the registered bulk action is in the bulk menu'
+ );
+ await click(menuItems()[1]);
+
+ assert.deepEqual(clicked, [['sync', 'Ada'], ['import'], ['export']]);
+
+ const slots = [...new Set(views.calls.map((call) => `${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']);
+
+ const { context } = views.calls.find((call) => call.slot === 'bulk-actions');
+ assert.strictEqual(context.controller, this.controller, 'handlers get the controller');
+ assert.ok(context.table, 'and the table, once it is set up');
+ assert.ok(Array.isArray(context.getSelectedRows()), 'and the current selection');
+ });
+
+ test('the context works before the table is set up', async function (assert) {
+ const views = setupResourceView(this.owner);
+
+ await render(
+ hbs`{{data.length}} `
+ );
+
+ const { context } = views.calls[0];
+ assert.strictEqual(context.table, undefined, 'a block replaces the table, so none is ever set up');
+ assert.deepEqual(context.getSelectedRows(), []);
+ });
+
+ test('a column registered after the first render appears', async function (assert) {
+ const views = setupResourceView(this.owner);
+
+ await render(hbs` `);
+ assert.deepEqual(headerTexts(), ['Name', 'Status']);
+
+ views.add('fleet-ops:table:driver:columns', { id: 'score', label: 'Score' });
+ await settled();
+
+ assert.deepEqual(headerTexts(), ['Name', 'Status', 'Score']);
+ });
+
+ 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' });
+
+ 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' });
+ await settled();
+
+ assert.deepEqual(headerTexts(), ['Name', 'Status', 'Rank'], 'the registered column stays hidden while a new one appears');
+ });
+
+ test('a column picked while the controller rebuilds its columns stays picked', async function (assert) {
+ await render(hbs` `);
+
+ await click(buttonWithIcon('sliders'));
+ await click(findAll('.customize-columns-dropdown-body input[type="checkbox"]')[1]);
+ assert.deepEqual(headerTexts(), ['Name']);
+
+ // A controller getter hands over fresh column objects.
+ this.set('columns', [
+ { id: 'name', label: 'Name', valuePath: 'name' },
+ { id: 'status', label: 'Status', valuePath: 'status' },
+ ]);
+
+ assert.deepEqual(headerTexts(), ['Name'], 'the choice is kept by column key');
+ });
+ });
+
+ 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: () => {} });
+ this.set('table', { selectedRows: [{ id: 1 }] });
+
+ await render(hbs` `);
+
+ assert.ok(
+ findAll('button').some((button) => button.textContent.includes('Import from Acme')),
+ 'the registered toolbar button renders'
+ );
+ await click(buttonWithIcon('layer-group'));
+ assert.deepEqual(
+ menuItems().map((item) => item.textContent.trim()),
+ ['Export to Acme']
+ );
+
+ const { context } = views.calls.find((call) => call.slot === 'bulk-actions');
+ assert.strictEqual(context.table, this.table);
+ assert.deepEqual(context.getSelectedRows(), [{ id: 1 }]);
+ });
+
+ 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: () => {} });
+
+ await render(hbs` `);
+
+ assert.notOk(buttonWithIcon('layer-group'), 'there is nothing to act on');
+ const { context } = views.calls.find((call) => call.slot === 'actions');
+ assert.strictEqual(context.table, undefined);
+ assert.deepEqual(context.getSelectedRows(), []);
+ });
+
+ test('without a registry the given buttons are used as they are', async function (assert) {
+ const views = setupResourceView(this.owner);
+ this.set('actionButtons', [{ text: 'Refresh' }]);
+
+ await render(hbs` `);
+
+ assert.ok(findAll('button').some((button) => button.textContent.includes('Refresh')));
+ assert.deepEqual(views.calls, []);
+ });
+ });
+
+ 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' });
+ this.set('resource', { id: 'inv_1' });
+ this.set('actionButtons', [{ id: 'more', icon: 'ellipsis-h', items: [{ id: 'void', label: 'Void' }] }]);
+
+ await render(hbs` `);
+
+ assert.ok(
+ findAll('button').some((button) => button.textContent.includes('Print label')),
+ 'the registered header button renders'
+ );
+ await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger');
+ assert.deepEqual(
+ menuItems().map((item) => item.textContent.trim()),
+ ['Void', 'Send to Acme']
+ );
+
+ const { context } = views.calls.find((call) => call.slot === 'menu');
+ assert.strictEqual(context.resource, this.resource);
+ assert.ok('panel' in context, 'the overlay is available to handlers');
+ assert.ok(context.panel, 'once the overlay has loaded');
+ });
+
+ 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' });
+
+ await render(hbs` `);
+
+ await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger');
+ assert.deepEqual(
+ menuItems().map((item) => item.textContent.trim()),
+ ['Send to Acme']
+ );
+ });
+
+ test('no dropdown is added when nothing is registered for the menu', async function (assert) {
+ setupResourceView(this.owner);
+
+ 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' });
+ this.owner.register(
+ 'component:test-panel-header',
+ setComponentTemplate(hbs``, class extends Component {})
+ );
+
+ await render(hbs` `);
+
+ assert.dom('.custom-header .custom-button').hasText('Print label');
+ });
+ });
+
+ module('extension components', function (nestedHooks) {
+ nestedHooks.beforeEach(function () {
+ setupExtensionEngine(this.owner, {
+ 'cell/score': setComponentTemplate(hbs`{{@row.name}}: {{@value}} `, class extends Component {}),
+ 'filter/score': setComponentTemplate(hbs` `, class extends Component {}),
+ 'button/sync': setComponentTemplate(hbs`{{@text}} `, 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');
+ });
+});