diff --git a/RELEASE.md b/RELEASE.md
index bcde344d..92188238 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,10 +1,15 @@
-> v0.4.0 ~ "Tab navigation overflow fix"
+> v0.4.1 ~ "Resource identity: pills, one-line identity cells, hover summaries and select options"
---
## Highlights
-- Route-backed tabs without explicit IDs remain accessible through the TabNavigation More menu when the tab bar overflows (#171).
-- Tab identity falls back from `id` to `route` to `key` for active state, overflow calculations, and keyboard navigation.
+- **Resource descriptor registry** — `utils/resource-registry` stores one descriptor per kind of record (title, identifier, image, status, badges, facts, how it opens) in the shared `universe/registry-service`, so the host and every engine read one set. `resolveResourceKey` accepts a record, an identity stub, a model name or alias, a `prefix:key` string, a PHP class name or a polymorphic type; `openResource` unwraps proxies and stubs, swaps a subtype for its canonical record and never throws. The `resource-type`, `resource-component` and `resource-relation` helpers and the `resource-registry` service expose it to templates and engines.
+- **Resource::Pill, Resource::Summary, Resource::HoverCard** — a descriptor-driven pill that opens its record and shows a compact summary card on hover (photo or icon, name and identifier, status and online state, a handful of facts and one View action). The card is lazy: nothing renders until the pointer has rested on the target, and it hydrates only then.
+- **Table::Cell::Identity** — a one-line identity cell: a small image with the status dot, the name and up to two inline badges, no status badge. It opens the record on click, disables on `column.permission`, and never fires the row action as well.
+- **Resource::SelectOption** — the option row for record pickers, with a compact variant for the closed trigger. `filter/model` renders options through it and the new `filter/model-multiple` filter selects several records.
+- **Core resource families** — pills, summaries, select options and identity cells for user, company, group, role, file and category, registered by an instance-initializer in the host.
+- **Fixes** — `Attach::Popover` applies `@class`, keeps an interactive popover enterable while shown, follows later changes to `@isShown` and removes its listeners on destroy; `Attach::Tooltip` forwards `hideDelay`, `hideDuration`, `@class`, `@style`, `floatingTarget`, `floatingContainer` and `onChange`; `Pill` renders a static span when it has nothing to do and hands its tooltip component the resource; `MultiSelect` forwards `@selectedItemComponent`, `@searchField` and `@searchPlaceholder`; `table/cell/resource-identity` no longer renders an empty meta row.
+- **Dummy app** — `@fleetbase/ember-core` is declared as a dependency so the components that import it can render in tests.
---
## Need help?
diff --git a/addon/components/attach/popover.hbs b/addon/components/attach/popover.hbs
index 24d66698..f431dc16 100644
--- a/addon/components/attach/popover.hbs
+++ b/addon/components/attach/popover.hbs
@@ -1,8 +1,9 @@
-
+
{{#if (and this.currentTarget (or (not this.lazyRender) this.mustRender))}}
{
this.animationTimeout = this.hide();
@@ -191,13 +227,19 @@ export default class AttachPopoverComponent extends Component {
return;
}
- // If cursor is not on the attachment or target, hide the popover
- if (!target.contains(event.target) && !(this.isOffset && this.isCursorBetweenTargetAndAttachment(event)) && this.floatingElement && !this.floatingElement.contains(event.target)) {
- // Remove this listener before hiding the attachment
- delete this.hideListenersOnDocumentByEvent.mousemove;
- document.removeEventListener('mousemove', this.hideIfMouseOutsideTargetOrAttachment, this.useCapture);
+ const outside =
+ !target.contains(event.target) && !(this.isOffset && this.isCursorBetweenTargetAndAttachment(event)) && this.floatingElement && !this.floatingElement.contains(event.target);
- this.hideAfterDelay();
+ if (outside) {
+ // Start the delayed hide once and keep listening, so re-entering the
+ // target or the attachment within the delay cancels it.
+ if (!this.hidePending) {
+ this.hidePending = true;
+ this.hideAfterDelay();
+ }
+ } else if (this.hidePending) {
+ cancel(this.delayedVisibilityToggle);
+ this.hidePending = false;
}
}
@@ -326,6 +368,7 @@ export default class AttachPopoverComponent extends Component {
@action showAfterDelay() {
cancel(this.delayedVisibilityToggle);
+ this.hidePending = false;
this.mustRender = true;
this.addListenersForHideEvents();
@@ -343,10 +386,25 @@ export default class AttachPopoverComponent extends Component {
}
this.mustRender = true;
+ this.isShown = true;
// Make the attachment visible immediately so transition animations can take place
this.setIsVisibleAfterDelay(true, 0);
this.startShowAnimation();
+
+ // An interactive attachment that was opened programmatically (or whose
+ // target the pointer left before the listeners were bound) must still
+ // close once the pointer is away from both target and attachment.
+ if (this.interactive && this.hideEvents.includes('mouseleave')) {
+ this.hideOnMouseLeaveTarget();
+ }
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ cancel(this.delayedVisibilityToggle);
+ cancelAnimationFrame(this.animationTimeout);
+ this.removeEventListeners();
}
@action startShowAnimation() {
@@ -416,6 +474,7 @@ export default class AttachPopoverComponent extends Component {
this.animationTimeout = requestAnimationFrame(() => {
if (!this.isDestroyed && !this.isDestroying) {
this.floatingElement.style.display = isVisible ? '' : 'none';
+ this.floatingElement.style.pointerEvents = this.floatingPointerEvents;
// Prevent jank by making the attachment invisible until positioned.
// The visibility style will be toggled by this.startShowAnimation()
@@ -431,6 +490,7 @@ export default class AttachPopoverComponent extends Component {
);
} else {
this.floatingElement.style.display = isVisible ? '' : 'none';
+ this.floatingElement.style.pointerEvents = this.floatingPointerEvents;
// Prevent jank by making the attachment invisible until positioned.
// The visibility style will be toggled by this.startShowAnimation()
diff --git a/addon/components/attach/tooltip.hbs b/addon/components/attach/tooltip.hbs
index 023791af..43710de0 100644
--- a/addon/components/attach/tooltip.hbs
+++ b/addon/components/attach/tooltip.hbs
@@ -7,6 +7,12 @@
@offset={{@offset}}
@shiftOptions={{@shiftOptions}}
@animation={{@animation}}
+ @class={{@class}}
+ @style={{@style}}
+ @floatingTarget={{@floatingTarget}}
+ @floatingContainer={{@floatingContainer}}
+ @hideDelay={{@hideDelay}}
+ @hideDuration={{@hideDuration}}
@hideOn={{@hideOn}}
@showOn={{@showOn}}
@interactive={{@interactive}}
@@ -15,5 +21,6 @@
@showDelay={{@showDelay}}
@showDuration={{@showDuration}}
@transitionDuration={{@transitionDuration}}
+ @onChange={{@onChange}}
...attributes
>{{yield}}
\ No newline at end of file
diff --git a/addon/components/category/pill.hbs b/addon/components/category/pill.hbs
new file mode 100644
index 00000000..f5dae44a
--- /dev/null
+++ b/addon/components/category/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/category/summary.hbs b/addon/components/category/summary.hbs
new file mode 100644
index 00000000..0f4e39a3
--- /dev/null
+++ b/addon/components/category/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/company/pill.hbs b/addon/components/company/pill.hbs
new file mode 100644
index 00000000..dd2b815b
--- /dev/null
+++ b/addon/components/company/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/company/summary.hbs b/addon/components/company/summary.hbs
new file mode 100644
index 00000000..ce995069
--- /dev/null
+++ b/addon/components/company/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/file/pill.hbs b/addon/components/file/pill.hbs
new file mode 100644
index 00000000..9e2bd7d2
--- /dev/null
+++ b/addon/components/file/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/file/summary.hbs b/addon/components/file/summary.hbs
new file mode 100644
index 00000000..ce7371d3
--- /dev/null
+++ b/addon/components/file/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/filter/model-multiple.hbs b/addon/components/filter/model-multiple.hbs
new file mode 100644
index 00000000..05eb430c
--- /dev/null
+++ b/addon/components/filter/model-multiple.hbs
@@ -0,0 +1,24 @@
+
+
+ {{#if this.optionComponent}}
+ {{component this.optionComponent option=model}}
+ {{else}}
+ {{get model this.optionLabel}}
+ {{/if}}
+
+
+
+
+
diff --git a/addon/components/filter/model-multiple.js b/addon/components/filter/model-multiple.js
new file mode 100644
index 00000000..e14a67af
--- /dev/null
+++ b/addon/components/filter/model-multiple.js
@@ -0,0 +1,106 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { getOwner } from '@ember/application';
+import { isArray } from '@ember/array';
+import { task } from 'ember-concurrency';
+import { resourceComponentName } from '../../utils/resource-registry';
+
+/**
+ * Table filter that selects several records of one model. The filter value
+ * travels as a comma-separated list of ids so it survives the URL round trip;
+ * the matching records are re-fetched on init so the chips show names rather
+ * than ids after a reload.
+ */
+export default class FilterModelMultipleComponent extends Component {
+ @service store;
+ @tracked selectedModels = [];
+
+ constructor() {
+ super(...arguments);
+ this.restoreSelection.perform(this.args.value);
+ }
+
+ get modelName() {
+ return this.args.filter?.model;
+ }
+
+ get optionLabel() {
+ return this.args.filter?.modelNamePath ?? 'name';
+ }
+
+ get optionComponent() {
+ const { filter } = this.args;
+
+ if (filter?.filterOptionComponent) {
+ return filter.filterOptionComponent;
+ }
+
+ return resourceComponentName(getOwner(this), 'select-option', this.modelName);
+ }
+
+ parseIds(value) {
+ if (isArray(value)) {
+ return value.filter(Boolean);
+ }
+
+ if (typeof value === 'string' && value.trim()) {
+ return value
+ .split(',')
+ .map((id) => id.trim())
+ .filter(Boolean);
+ }
+
+ return [];
+ }
+
+ @task *restoreSelection(value) {
+ const ids = this.parseIds(value);
+
+ if (!ids.length || !this.modelName) {
+ this.selectedModels = [];
+ return;
+ }
+
+ const records = [];
+
+ for (const id of ids) {
+ try {
+ records.push(this.store.peekRecord(this.modelName, id) ?? (yield this.store.findRecord(this.modelName, id)));
+ } catch {
+ // An id that no longer resolves simply drops out of the chips.
+ }
+ }
+
+ this.selectedModels = records.filter(Boolean);
+ }
+
+ @action onChange(selection) {
+ const { onChange, onClear, filter } = this.args;
+ const models = isArray(selection) ? selection : [];
+
+ this.selectedModels = models;
+
+ if (!models.length) {
+ if (typeof onClear === 'function') {
+ onClear(filter);
+ }
+ return;
+ }
+
+ if (typeof onChange === 'function') {
+ onChange(filter, models.map((model) => model.id).join(','));
+ }
+ }
+
+ @action clear() {
+ const { onClear, filter } = this.args;
+
+ this.selectedModels = [];
+
+ if (typeof onClear === 'function') {
+ onClear(filter);
+ }
+ }
+}
diff --git a/addon/components/filter/model.hbs b/addon/components/filter/model.hbs
index 0c74b170..ad04d3da 100644
--- a/addon/components/filter/model.hbs
+++ b/addon/components/filter/model.hbs
@@ -1,7 +1,7 @@
\ No newline at end of file
+ @selectedItemComponent={{if this.optionComponent (component this.optionComponent compact=true)}}
+ as |model|
+>
+ {{#if this.optionComponent}}
+ {{component this.optionComponent option=model}}
+ {{else}}
+ {{get model this.optionLabel}}
+ {{/if}}
+
diff --git a/addon/components/filter/model.js b/addon/components/filter/model.js
index 345684d9..962d4897 100644
--- a/addon/components/filter/model.js
+++ b/addon/components/filter/model.js
@@ -1,6 +1,8 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { resourceComponentName } from '../../utils/resource-registry';
export default class FilterModelComponent extends Component {
@tracked selectedModel;
@@ -10,13 +12,38 @@ export default class FilterModelComponent extends Component {
this.selectedModel = this.args.value;
}
+ get optionLabel() {
+ return this.args.filter?.modelNamePath ?? 'name';
+ }
+
+ /**
+ * The component that renders each option and the selected item: the
+ * column's own `filterOptionComponent`, else the select-option registered
+ * for the filtered model, else nothing (the label path is shown as text).
+ */
+ get optionComponent() {
+ const { filter } = this.args;
+
+ if (filter?.filterOptionComponent) {
+ return filter.filterOptionComponent;
+ }
+
+ return resourceComponentName(getOwner(this), 'select-option', filter?.model);
+ }
+
@action onChange(selectedModel) {
const { onChange, filter } = this.args;
this.selectedModel = selectedModel;
+ // The select's own clear control selects null; report that as a clear
+ // so the filter is removed rather than set to an empty value.
+ if (!selectedModel) {
+ return this.clear();
+ }
+
if (typeof onChange === 'function') {
- onChange(filter, selectedModel?.id);
+ onChange(filter, selectedModel.id);
}
}
diff --git a/addon/components/filter/radio.hbs b/addon/components/filter/radio.hbs
new file mode 100644
index 00000000..3c40d881
--- /dev/null
+++ b/addon/components/filter/radio.hbs
@@ -0,0 +1,9 @@
+{{! A small set of exclusive choices, with "any" as the first one, drawn as radios so the state reads at a glance. }}
+
+ {{#each this.choices as |choice|}}
+
+
+ {{choice.label}}
+
+ {{/each}}
+
diff --git a/addon/components/filter/radio.js b/addon/components/filter/radio.js
new file mode 100644
index 00000000..3717d72f
--- /dev/null
+++ b/addon/components/filter/radio.js
@@ -0,0 +1,44 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { guidFor } from '@ember/object/internals';
+import { isArray } from '@ember/array';
+
+/**
+ * A filter for two or three exclusive states, such as "assigned / not
+ * assigned": every option is visible as a radio, and an "any" choice (the
+ * placeholder text) clears the filter. Options are `{ label, value }` like
+ * the select filter's; values compare as strings so "true" and true match.
+ */
+export default class FilterRadioComponent extends Component {
+ @tracked value = '';
+
+ constructor(owner, { value }) {
+ super(...arguments);
+ this.value = value === undefined || value === null ? '' : String(value);
+ }
+
+ get name() {
+ return `filter-radio-${guidFor(this)}`;
+ }
+
+ get choices() {
+ const optionLabel = this.args.optionLabel ?? this.args.filterOptionLabel ?? 'label';
+ const optionValue = this.args.optionValue ?? this.args.filterOptionValue ?? 'value';
+ const options = (isArray(this.args.options) ? this.args.options : []).map((option) =>
+ typeof option === 'object' && option !== null ? { label: option[optionLabel], value: String(option[optionValue]) } : { label: String(option), value: String(option) }
+ );
+
+ return [{ label: this.args.anyLabel ?? this.args.placeholder ?? 'Any', value: '' }, ...options];
+ }
+
+ @action select(value) {
+ const { onChange, filter } = this.args;
+
+ this.value = value;
+
+ if (typeof onChange === 'function') {
+ onChange(filter, value === '' ? null : value);
+ }
+ }
+}
diff --git a/addon/components/floating.js b/addon/components/floating.js
index 3f0b684d..09d463ed 100644
--- a/addon/components/floating.js
+++ b/addon/components/floating.js
@@ -141,7 +141,7 @@ export default class FloatingComponent extends Component {
}).then(({ x, y }) => {
assign(element.style, {
position: 'absolute',
- pointerEvents: 'none',
+ pointerEvents: this.args.pointerEvents ?? 'none',
willChange: 'transform',
top: '0',
left: '0',
diff --git a/addon/components/group/pill.hbs b/addon/components/group/pill.hbs
new file mode 100644
index 00000000..3d154705
--- /dev/null
+++ b/addon/components/group/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/group/summary.hbs b/addon/components/group/summary.hbs
new file mode 100644
index 00000000..dc48e874
--- /dev/null
+++ b/addon/components/group/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/layout/resource/tabular-actions.hbs b/addon/components/layout/resource/tabular-actions.hbs
index 615c4c5b..19331cd4 100644
--- a/addon/components/layout/resource/tabular-actions.hbs
+++ b/addon/components/layout/resource/tabular-actions.hbs
@@ -29,7 +29,7 @@
@iconOnly={{true}}
/>
{{#unless @hideColumnsPicker}}
-
+
{{/unless}}
{{#if (safe-has @table "selectedRows")}}
{{#unless @hideColumnsPicker}}
-
+
{{/unless}}
{{#if (safe-has this.table "selectedRows")}}
{
diff --git a/addon/components/multi-select.hbs b/addon/components/multi-select.hbs
index 0950a38b..54aedac7 100644
--- a/addon/components/multi-select.hbs
+++ b/addon/components/multi-select.hbs
@@ -2,6 +2,9 @@
class="form-select form-input {{@selectClass}}"
@registerAPI={{@registerAPI}}
@searchEnabled={{@searchEnabled}}
+ @searchField={{@searchField}}
+ @searchFieldPosition={{@searchFieldPosition}}
+ @searchPlaceholder={{@searchPlaceholder}}
@search={{@search}}
@options={{@options}}
@selected={{@selected}}
@@ -9,6 +12,7 @@
@onChange={{@onChange}}
@allowClear={{@allowClear}}
@placeholderComponent={{@placeholderComponent}}
+ @selectedItemComponent={{@selectedItemComponent}}
@preventScroll={{@preventScroll}}
@renderInPlace={{@renderInPlace}}
@scrollTo={{@scrollTo}}
diff --git a/addon/components/pill.hbs b/addon/components/pill.hbs
index 5a18a714..2a35c817 100644
--- a/addon/components/pill.hbs
+++ b/addon/components/pill.hbs
@@ -1,50 +1,73 @@
-
\ No newline at end of file
+
diff --git a/addon/components/pill.js b/addon/components/pill.js
index 94067a47..b749572d 100644
--- a/addon/components/pill.js
+++ b/addon/components/pill.js
@@ -1,9 +1,22 @@
import Component from '@glimmer/component';
import { action, get } from '@ember/object';
-import getModelName from '@fleetbase/ember-core/utils/get-model-name';
export default class PillComponent extends Component {
/* eslint-disable ember/no-get */
+ get isClickable() {
+ return typeof this.args.onClick === 'function';
+ }
+
+ get isOnline() {
+ if (this.args.online !== undefined) {
+ return Boolean(this.args.online);
+ }
+
+ const record = this.args.resource;
+
+ return record ? Boolean(get(record, this.args.onlinePath ?? 'online')) : false;
+ }
+
get resourceName() {
const record = this.args.resource;
if (!record) return null;
@@ -14,7 +27,8 @@ export default class PillComponent extends Component {
get(record, 'displayName') ??
get(record, 'tracking') ??
get(record, 'public_id') ??
- getModelName(record)
+ get(record, 'constructor.modelName') ??
+ null
);
}
diff --git a/addon/components/pill/body.hbs b/addon/components/pill/body.hbs
new file mode 100644
index 00000000..f8154b53
--- /dev/null
+++ b/addon/components/pill/body.hbs
@@ -0,0 +1,43 @@
+{{! The inside of a pill, shared by the clickable anchor and the static span. }}
+
+ {{#if @hasImageBlock}}
+ {{yield @resource to="image"}}
+ {{else}}
+
+ {{#if @showOnlineIndicator}}
+
+ {{/if}}
+ {{yield @resource to="image"}}
+ {{/if}}
+
+
+ {{#if @hasDefaultBlock}}
+ {{yield @resource}}
+ {{else}}
+
{{or @title @resourceName @titleFallback "-"}}
+ {{#if @subtitle}}
+
{{n-a @subtitle}}
+ {{/if}}
+ {{yield @resource}}
+ {{/if}}
+
+{{#unless @noTooltip}}
+ {{#if @hasTooltipBlock}}
+
+
+ {{yield @resource to="tooltip"}}
+
+
+ {{else if @tooltipComponent}}
+
+ {{component @tooltipComponent resource=@resource}}
+
+ {{/if}}
+{{/unless}}
diff --git a/addon/components/resource/hover-card.hbs b/addon/components/resource/hover-card.hbs
new file mode 100644
index 00000000..f39d346d
--- /dev/null
+++ b/addon/components/resource/hover-card.hbs
@@ -0,0 +1,21 @@
+
+ {{#if this.armed}}
+
+ {{component this.summaryComponent resource=@resource resourceType=@resourceType onView=@onView showView=@showView onClose=this.disarm}}
+
+ {{/if}}
+
diff --git a/addon/components/resource/hover-card.js b/addon/components/resource/hover-card.js
new file mode 100644
index 00000000..9f4963fa
--- /dev/null
+++ b/addon/components/resource/hover-card.js
@@ -0,0 +1,167 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { cancel, later } from '@ember/runloop';
+import { resourceComponentName } from '../../utils/resource-registry';
+
+/**
+ * The lazy host for a resource's hover summary.
+ *
+ * Stage 0 renders a hidden anchor and listens on its target (the element
+ * matching `@targetSelector`, else the anchor's parent) for hover and focus.
+ * Only after `@armDelay` of continuous hover does stage 1 render an
+ * interactive Attach::Popover with the resource's summary component, so a
+ * table of a hundred rows costs a hundred listeners and nothing more until
+ * someone actually pauses on a row. When the popover hides, the host drops
+ * back to stage 0.
+ *
+ * Devices without hover (`(hover: none)`) never arm.
+ */
+export default class ResourceHoverCardComponent extends Component {
+ @tracked armed = false;
+ @tracked target = null;
+
+ anchor = null;
+ timer = null;
+ bound = [];
+
+ get armDelay() {
+ return this.args.armDelay ?? 350;
+ }
+
+ get showDelay() {
+ return this.args.showDelay ?? 0;
+ }
+
+ get hideDelay() {
+ return this.args.hideDelay ?? 150;
+ }
+
+ get placement() {
+ return this.args.placement ?? 'top-start';
+ }
+
+ get offset() {
+ return this.args.offset ?? 8;
+ }
+
+ get summaryComponent() {
+ return this.args.summaryComponent ?? resourceComponentName(getOwner(this), 'summary', this.args.resource) ?? resourceComponentName(getOwner(this), 'summary', this.args.resourceType);
+ }
+
+ get isTouchDevice() {
+ try {
+ return window.matchMedia?.('(hover: none)')?.matches === true;
+ } catch {
+ return false;
+ }
+ }
+
+ get isEnabled() {
+ return Boolean(this.args.resource) && !this.args.disabled && Boolean(this.summaryComponent) && !this.isTouchDevice;
+ }
+
+ @action setup(element) {
+ this.anchor = element;
+
+ if (!this.isEnabled) {
+ return;
+ }
+
+ this.target = this.findTarget(element);
+
+ if (this.target) {
+ this.bind();
+ }
+ }
+
+ @action teardown() {
+ this.cancelTimer();
+ this.unbind();
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.teardown();
+ }
+
+ findTarget(element) {
+ const { targetSelector } = this.args;
+
+ if (typeof targetSelector === 'string' && targetSelector !== '') {
+ return element.closest(targetSelector) ?? element.parentElement?.querySelector(targetSelector) ?? null;
+ }
+
+ return element.parentElement ?? null;
+ }
+
+ bind() {
+ if (!this.target || this.bound.length) {
+ return;
+ }
+
+ const pairs = [
+ ['mouseenter', this.onEnter],
+ ['focusin', this.onEnter],
+ ['mouseleave', this.onLeave],
+ ['focusout', this.onLeave],
+ ];
+
+ pairs.forEach(([event, handler]) => this.target.addEventListener(event, handler));
+ this.bound = pairs;
+ }
+
+ unbind() {
+ if (this.target) {
+ this.bound.forEach(([event, handler]) => this.target.removeEventListener(event, handler));
+ }
+
+ this.bound = [];
+ }
+
+ cancelTimer() {
+ if (this.timer) {
+ cancel(this.timer);
+ this.timer = null;
+ }
+ }
+
+ @action onEnter() {
+ this.cancelTimer();
+ this.timer = later(this, this.arm, this.armDelay);
+ }
+
+ @action onLeave() {
+ this.cancelTimer();
+ }
+
+ @action arm() {
+ this.timer = null;
+
+ if (this.isDestroyed || this.isDestroying || this.armed) {
+ return;
+ }
+
+ // The popover binds its own show and hide listeners on the target from
+ // here on; ours would only double up.
+ this.unbind();
+ this.armed = true;
+ }
+
+ /** Back to stage 0 once the popover has hidden. */
+ @action onPopoverChange(visible) {
+ if (!visible) {
+ this.disarm();
+ }
+ }
+
+ @action disarm() {
+ if (this.isDestroyed || this.isDestroying) {
+ return;
+ }
+
+ this.armed = false;
+ this.bind();
+ }
+}
diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs
new file mode 100644
index 00000000..72f9d1b8
--- /dev/null
+++ b/addon/components/resource/pill.hbs
@@ -0,0 +1,53 @@
+
+ <:image>
+ {{#if this.image.component}}
+ {{component this.image.component resource=this.record}}
+ {{else if this.image.icon}}
+
+
+
+ {{else}}
+
+ {{/if}}
+ {{#if this.showOnlineIndicator}}
+
+ {{/if}}
+
+ <:default as |resource|>
+ {{#if (and (has-block) (not-eq @useBlock false))}}
+ {{yield resource}}
+ {{else}}
+ {{this.title}}
+ {{#if this.subtitle}}
+ {{this.subtitle}}
+ {{/if}}
+ {{/if}}
+ {{#if this.showPopover}}
+
+ {{/if}}
+
+
diff --git a/addon/components/resource/pill.js b/addon/components/resource/pill.js
new file mode 100644
index 00000000..2bcd654f
--- /dev/null
+++ b/addon/components/resource/pill.js
@@ -0,0 +1,109 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, resourceComponentName } from '../../utils/resource-registry';
+import { unwrapRecord, resourceTitle, resourceIdentifier, resourceImage, resourceOnline } from '../../utils/resource-identity';
+
+/**
+ * A pill driven by the resource registry: the descriptor supplies the image
+ * (or an icon tile), the title, the identifier subtitle and the online dot,
+ * and the pill opens the resource on click when the descriptor knows how.
+ * With no open path it renders the static, non-link variant. Hovering shows
+ * the resource's summary card unless `@noPopover` is set.
+ */
+export default class ResourcePillComponent extends Component {
+ get owner() {
+ return getOwner(this);
+ }
+
+ get record() {
+ return unwrapRecord(this.args.resource);
+ }
+
+ /**
+ * The record's own type wins; `@resourceType` names the type of a record
+ * that does not resolve by itself (a POJO, or a polymorphic base whose
+ * concrete subtype is unknown here).
+ */
+ get key() {
+ return resolveResourceKey(this.owner, this.record) ?? resolveResourceKey(this.owner, this.args.resourceType);
+ }
+
+ get descriptor() {
+ return getResourceDescriptor(this.owner, this.key);
+ }
+
+ get title() {
+ if (this.args.title !== undefined && this.args.title !== null) {
+ return this.args.title;
+ }
+
+ return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-');
+ }
+
+ get subtitle() {
+ if (this.args.subtitle !== undefined) {
+ return this.args.subtitle;
+ }
+
+ return resourceIdentifier(this.descriptor, this.record);
+ }
+
+ get image() {
+ const image = resourceImage(this.descriptor, this.record, {
+ url: this.args.imageSrc,
+ fallback: this.args.imageFallback,
+ shape: this.args.imageShape,
+ icon: this.args.icon,
+ });
+
+ // Nothing to show means an icon tile, never a broken image.
+ if (!image.url && !image.fallback && !image.icon && !image.component) {
+ image.icon = this.descriptor?.icon ?? 'cube';
+ }
+
+ return image;
+ }
+
+ get online() {
+ if (this.args.online !== undefined) {
+ return this.args.online;
+ }
+
+ return resourceOnline(this.descriptor, this.record);
+ }
+
+ get showOnlineIndicator() {
+ if (this.args.showOnlineIndicator !== undefined) {
+ return Boolean(this.args.showOnlineIndicator);
+ }
+
+ return typeof this.online === 'boolean';
+ }
+
+ get canOpen() {
+ return Boolean(this.record) && canOpenResource(this.owner, this.record, { resourceType: this.args.resourceType });
+ }
+
+ get clickHandler() {
+ if (typeof this.args.onClick === 'function') {
+ return this.args.onClick;
+ }
+
+ if (this.args.static || !this.canOpen) {
+ return undefined;
+ }
+
+ return this.open;
+ }
+
+ get showPopover() {
+ return !this.args.noPopover && Boolean(this.record) && Boolean(resourceComponentName(this.owner, 'summary', this.key));
+ }
+
+ @action open(resource, event) {
+ event?.preventDefault?.();
+
+ return openResource(this.owner, this.args.resource, { event, resourceType: this.args.resourceType });
+ }
+}
diff --git a/addon/components/resource/select-option.hbs b/addon/components/resource/select-option.hbs
new file mode 100644
index 00000000..888cbfff
--- /dev/null
+++ b/addon/components/resource/select-option.hbs
@@ -0,0 +1,17 @@
+
+ {{#if this.image.component}}
+
{{component this.image.component resource=this.record}}
+ {{else if this.icon}}
+
+
+
+ {{else if this.photo}}
+
+ {{/if}}
+
+ {{this.title}}
+ {{#if this.details}}
+ {{this.details}}
+ {{/if}}
+
+
diff --git a/addon/components/resource/select-option.js b/addon/components/resource/select-option.js
new file mode 100644
index 00000000..347c3fe3
--- /dev/null
+++ b/addon/components/resource/select-option.js
@@ -0,0 +1,65 @@
+import Component from '@glimmer/component';
+import { getOwner } from '@ember/application';
+import { getResourceDescriptor, resolveResourceKey } from '../../utils/resource-registry';
+import { unwrapRecord, resourceTitle, resourceImage, resourceSelectDetails } from '../../utils/resource-identity';
+
+/**
+ * One option in a ModelSelect or PowerSelect: a photo or icon, a title, and
+ * a line of detail beneath it. Given `@option` (or `@model`), the descriptor
+ * registered for the record fills these in; the presentational arguments
+ * (`@photo`, `@fallbackPhoto`, `@icon`, `@title`, `@details`, `@shape`)
+ * override it. `@compact` puts everything on one line at a smaller photo,
+ * for a select's closed trigger.
+ */
+export default class ResourceSelectOptionComponent extends Component {
+ get owner() {
+ return getOwner(this);
+ }
+
+ get record() {
+ return unwrapRecord(this.args.option ?? this.args.model);
+ }
+
+ get descriptor() {
+ return getResourceDescriptor(this.owner, resolveResourceKey(this.owner, this.record) ?? this.args.resourceType);
+ }
+
+ get title() {
+ if (this.args.title !== undefined && this.args.title !== null) {
+ return this.args.title;
+ }
+
+ return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-');
+ }
+
+ get details() {
+ const details = this.args.details ?? resourceSelectDetails(this.descriptor, this.record);
+
+ return (Array.isArray(details) ? details : [details]).filter((detail) => detail !== null && detail !== undefined && String(detail).trim() !== '').join(' · ');
+ }
+
+ get image() {
+ return resourceImage(this.descriptor, this.record, {
+ url: this.args.photo,
+ fallback: this.args.fallbackPhoto,
+ shape: this.args.shape,
+ icon: this.args.icon,
+ });
+ }
+
+ get photo() {
+ return this.image.url || this.image.fallback || null;
+ }
+
+ get icon() {
+ if (this.image.icon) {
+ return this.image.icon;
+ }
+
+ if (!this.photo && this.descriptor) {
+ return this.descriptor.icon;
+ }
+
+ return null;
+ }
+}
diff --git a/addon/components/resource/summary.hbs b/addon/components/resource/summary.hbs
new file mode 100644
index 00000000..c0b76de9
--- /dev/null
+++ b/addon/components/resource/summary.hbs
@@ -0,0 +1,57 @@
+
+
+ {{#if this.hydrate.isRunning}}
+
+
+
+
+
+ {{else if this.facts.length}}
+
+ {{#each this.facts as |fact|}}
+
+
{{fact.label}}
+
+ {{#if fact.pillComponent}}
+ {{component fact.pillComponent resource=fact.related resourceType=fact.relatedType noPopover=true}}
+ {{else}}
+ {{fact.value}}
+ {{/if}}
+
+
+ {{/each}}
+
+ {{/if}}
+ {{#if this.showView}}
+
+
+
+ {{this.viewLabel}}
+
+
+ {{/if}}
+
diff --git a/addon/components/resource/summary.js b/addon/components/resource/summary.js
new file mode 100644
index 00000000..b619b558
--- /dev/null
+++ b/addon/components/resource/summary.js
@@ -0,0 +1,137 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { task } from 'ember-concurrency';
+import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, readDescriptor } from '../../utils/resource-registry';
+import { unwrapRecord, resourceTitle, resourceIdentifier, resourceImage, resourceOnline, resourceStatus, resourceFacts, makeTranslator } from '../../utils/resource-identity';
+
+/**
+ * The compact summary card shown when a resource is hovered: image or icon,
+ * title and identifier, status and online state, a few key facts, and one
+ * View action. Hydration runs only when the card is inserted, so a table
+ * costs nothing until a row is actually paused on; while it runs the facts
+ * show as skeleton bars, and a failed hydrate falls back to what the record
+ * already carries.
+ */
+export default class ResourceSummaryComponent extends Component {
+ @tracked hydrated = null;
+ @tracked hydrateFailed = false;
+
+ translate = makeTranslator(getOwner(this));
+
+ get owner() {
+ return getOwner(this);
+ }
+
+ get record() {
+ return this.hydrated ?? unwrapRecord(this.args.resource);
+ }
+
+ get key() {
+ return resolveResourceKey(this.owner, this.record) ?? resolveResourceKey(this.owner, this.args.resourceType);
+ }
+
+ get descriptor() {
+ return getResourceDescriptor(this.owner, this.key);
+ }
+
+ get title() {
+ return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-');
+ }
+
+ get identifier() {
+ return resourceIdentifier(this.descriptor, this.record);
+ }
+
+ get image() {
+ const image = resourceImage(this.descriptor, this.record);
+
+ if (!image.url && !image.icon && !image.component) {
+ image.icon = readDescriptor(this.descriptor, 'icon') ?? 'cube';
+ }
+
+ return image;
+ }
+
+ get online() {
+ return resourceOnline(this.descriptor, this.record);
+ }
+
+ get hasOnline() {
+ return typeof this.online === 'boolean';
+ }
+
+ get status() {
+ const status = resourceStatus(this.descriptor, this.record);
+
+ return status === undefined || status === null || status === '' ? null : status;
+ }
+
+ get facts() {
+ const shown = new Set([this.title, this.identifier, this.status].filter((value) => value !== null && value !== undefined).map((value) => String(value).trim().toLowerCase()));
+
+ // A fact that only repeats the title, identifier or status is noise in a compact card.
+ return resourceFacts(this.owner, this.descriptor, this.record, { translate: this.translate }).filter(
+ (fact) => fact.pillComponent || !shown.has(String(fact.value).trim().toLowerCase())
+ );
+ }
+
+ get canOpen() {
+ return canOpenResource(this.owner, this.record, { resourceType: this.args.resourceType });
+ }
+
+ get showView() {
+ if (this.args.showView === false) {
+ return false;
+ }
+
+ return typeof this.args.onView === 'function' || this.canOpen;
+ }
+
+ get viewLabel() {
+ return this.args.viewLabel ?? this.translate('common.view') ?? 'View';
+ }
+
+ @task *hydrate() {
+ const record = unwrapRecord(this.args.resource);
+
+ if (!record) {
+ return;
+ }
+
+ try {
+ let loaded = null;
+
+ if (typeof this.descriptor?.hydrate === 'function') {
+ loaded = yield this.descriptor.hydrate(record, this.owner);
+ } else if (typeof record.loadResource === 'function') {
+ loaded = yield record.loadResource();
+ }
+
+ if (loaded && typeof loaded === 'object') {
+ this.hydrated = unwrapRecord(loaded);
+ }
+ } catch {
+ this.hydrateFailed = true;
+ }
+ }
+
+ @action view(event) {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+
+ // Closing the card destroys this component, so read everything first
+ // and start the open before letting the host tear the popover down.
+ const { onView, onClose, resource, resourceType } = this.args;
+ const owner = this.owner;
+ const record = this.record;
+ const result = typeof onView === 'function' ? onView(record, event) : openResource(owner, resource, { event, resourceType });
+
+ if (typeof onClose === 'function') {
+ onClose();
+ }
+
+ return result;
+ }
+}
diff --git a/addon/components/role/pill.hbs b/addon/components/role/pill.hbs
new file mode 100644
index 00000000..1f68d198
--- /dev/null
+++ b/addon/components/role/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/role/summary.hbs b/addon/components/role/summary.hbs
new file mode 100644
index 00000000..66da3941
--- /dev/null
+++ b/addon/components/role/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/select-option/category.hbs b/addon/components/select-option/category.hbs
new file mode 100644
index 00000000..d7a5642b
--- /dev/null
+++ b/addon/components/select-option/category.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/select-option/company.hbs b/addon/components/select-option/company.hbs
new file mode 100644
index 00000000..9bb9c1b8
--- /dev/null
+++ b/addon/components/select-option/company.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/select-option/file.hbs b/addon/components/select-option/file.hbs
new file mode 100644
index 00000000..f5e79765
--- /dev/null
+++ b/addon/components/select-option/file.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/select-option/group.hbs b/addon/components/select-option/group.hbs
new file mode 100644
index 00000000..ba7fd12e
--- /dev/null
+++ b/addon/components/select-option/group.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/select-option/role.hbs b/addon/components/select-option/role.hbs
new file mode 100644
index 00000000..2622e9fb
--- /dev/null
+++ b/addon/components/select-option/role.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/select-option/user.hbs b/addon/components/select-option/user.hbs
new file mode 100644
index 00000000..ac91341a
--- /dev/null
+++ b/addon/components/select-option/user.hbs
@@ -0,0 +1,12 @@
+
diff --git a/addon/components/table/cell/category-identity.hbs b/addon/components/table/cell/category-identity.hbs
new file mode 100644
index 00000000..6a94a6fb
--- /dev/null
+++ b/addon/components/table/cell/category-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/table/cell/company-identity.hbs b/addon/components/table/cell/company-identity.hbs
new file mode 100644
index 00000000..95b73c99
--- /dev/null
+++ b/addon/components/table/cell/company-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/table/cell/file-identity.hbs b/addon/components/table/cell/file-identity.hbs
new file mode 100644
index 00000000..92833e34
--- /dev/null
+++ b/addon/components/table/cell/file-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/table/cell/group-identity.hbs b/addon/components/table/cell/group-identity.hbs
new file mode 100644
index 00000000..86440989
--- /dev/null
+++ b/addon/components/table/cell/group-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/table/cell/identity.hbs b/addon/components/table/cell/identity.hbs
new file mode 100644
index 00000000..3323a9db
--- /dev/null
+++ b/addon/components/table/cell/identity.hbs
@@ -0,0 +1,40 @@
+{{#if this.resource}}
+
+ {{#if this.hasOpenPath}}
+
+
+
+ {{else}}
+
+ {{/if}}
+
+{{else}}
+ {{this.emptyText}}
+{{/if}}
diff --git a/addon/components/table/cell/identity.js b/addon/components/table/cell/identity.js
new file mode 100644
index 00000000..9a9e9522
--- /dev/null
+++ b/addon/components/table/cell/identity.js
@@ -0,0 +1,238 @@
+/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */
+import Component from '@glimmer/component';
+import { action, get } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { inject as service } from '@ember/service';
+import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, resourceComponentName } from '../../../utils/resource-registry';
+import { unwrapRecord, resourceTitle, resourceImage, resourceOnline, resourceStatus, resourceBadges, statusToneClass } from '../../../utils/resource-identity';
+
+/**
+ * A one-line identity cell driven by the resource registry: a small image
+ * or icon with the status dot, the name, and up to `column.badgeLimit`
+ * inline badges from the descriptor. Clicking runs `@onClick`, then
+ * `column.onClick`, then `column.action`, and otherwise opens the resource
+ * through its descriptor. Hovering shows the resource's summary card.
+ *
+ * There is never a status badge: the dot carries the status. The old
+ * `compact`, `showStatusBadge` and `showStatus` column keys are accepted and
+ * ignored so existing column definitions keep working.
+ */
+export default class TableCellIdentityComponent extends Component {
+ @service abilities;
+
+ get owner() {
+ return getOwner(this);
+ }
+
+ get column() {
+ return this.args.column ?? {};
+ }
+
+ get row() {
+ return this.args.row;
+ }
+
+ get resource() {
+ const { resourcePath } = this.column;
+ let resource;
+
+ if (typeof resourcePath === 'function') {
+ resource = resourcePath(this.args.row, this.args.value, this.column);
+ } else if (typeof resourcePath === 'string') {
+ resource = get(this.args.row ?? {}, resourcePath);
+ } else if (this.args.value && typeof this.args.value === 'object') {
+ resource = this.args.value;
+ } else {
+ resource = this.args.row;
+ }
+
+ return unwrapRecord(resource) ?? null;
+ }
+
+ get resourceType() {
+ return this.column.resourceType ?? this.args.resourceType;
+ }
+
+ /** The record's own type wins; the column or wrapper type is the fallback. */
+ get key() {
+ return resolveResourceKey(this.owner, this.resource) ?? resolveResourceKey(this.owner, this.resourceType);
+ }
+
+ get descriptor() {
+ return getResourceDescriptor(this.owner, this.key);
+ }
+
+ get emptyText() {
+ return this.column.emptyText ?? '-';
+ }
+
+ get label() {
+ const { labelFormatter, labelValue, labelPath } = this.column;
+
+ if (typeof labelFormatter === 'function') {
+ return labelFormatter(this.resource, this.column, this.row);
+ }
+
+ if (labelValue !== undefined) {
+ return typeof labelValue === 'function' ? labelValue(this.resource, this.column, this.row) : labelValue;
+ }
+
+ if (typeof labelPath === 'function') {
+ return labelPath(this.resource, this.column, this.row);
+ }
+
+ if (typeof labelPath === 'string') {
+ return get(this.resource, labelPath) ?? resourceTitle(this.descriptor, this.resource, null);
+ }
+
+ return resourceTitle(this.descriptor, this.resource, null);
+ }
+
+ get image() {
+ const image = resourceImage(this.descriptor, this.resource, {
+ url: typeof this.column.mediaPath === 'string' ? get(this.resource, this.column.mediaPath) : undefined,
+ fallback: this.column.fallbackImage ?? this.column.fallbackSrc,
+ });
+
+ if (!image.url && !image.icon && !image.component) {
+ image.icon = this.descriptor?.icon ?? 'cube';
+ }
+
+ return image;
+ }
+
+ /**
+ * A dot only where it means something: the column asks for one, names a
+ * path to read it from, or the resource reports an online state. A place
+ * or a contact has none, so it renders without a bulb.
+ */
+ get hasStatusDot() {
+ const explicit = this.column.showStatusDot ?? this.column.showOnlineIndicator;
+
+ if (explicit !== undefined && explicit !== null) {
+ return Boolean(explicit);
+ }
+
+ if (typeof this.column.onlinePath === 'string' || this.column.statusPath) {
+ return true;
+ }
+
+ return typeof resourceOnline(this.descriptor, this.resource) === 'boolean';
+ }
+
+ get statusValue() {
+ if (typeof this.column.onlinePath === 'string') {
+ const online = get(this.resource, this.column.onlinePath);
+
+ if (online !== undefined && online !== null) {
+ return online;
+ }
+ }
+
+ if (typeof this.column.statusPath === 'string') {
+ return get(this.resource, this.column.statusPath);
+ }
+
+ if (typeof this.column.statusPath === 'function') {
+ return this.column.statusPath(this.resource, this.column, this.row);
+ }
+
+ const online = resourceOnline(this.descriptor, this.resource);
+
+ if (online !== undefined && online !== null) {
+ return online;
+ }
+
+ return resourceStatus(this.descriptor, this.resource);
+ }
+
+ get statusDotClass() {
+ const value = this.statusValue;
+
+ if (typeof this.column.statusToneClass === 'function') {
+ return this.column.statusToneClass(value, this.resource, this.column);
+ }
+
+ return statusToneClass(value, { ...(this.descriptor?.statusTones ?? {}), ...(this.column.statusToneMap ?? {}) });
+ }
+
+ get badges() {
+ const { badges, hideBadges, badgeLimit } = this.column;
+
+ if (hideBadges) {
+ return [];
+ }
+
+ const selfId = get(this.row ?? {}, 'id') ?? get(this.row ?? {}, 'uuid') ?? null;
+ let list;
+
+ if (typeof badges === 'function') {
+ list = badges(this.resource, this.row, this.column) ?? [];
+ } else if (Array.isArray(badges)) {
+ list = badges;
+ } else {
+ list = resourceBadges(this.descriptor, this.resource, { row: this.row, column: this.column, selfId });
+ }
+
+ return list.filter(Boolean).slice(0, badgeLimit ?? 2);
+ }
+
+ get isDisabled() {
+ const { permission } = this.column;
+
+ if (!permission) {
+ return false;
+ }
+
+ try {
+ return this.abilities.cannot(permission);
+ } catch {
+ return false;
+ }
+ }
+
+ get hasHandler() {
+ return typeof this.args.onClick === 'function' || typeof this.column.onClick === 'function' || typeof this.column.action === 'function';
+ }
+
+ get hasOpenPath() {
+ return this.hasHandler || canOpenResource(this.owner, this.resource, { resourceType: this.resourceType });
+ }
+
+ get showPopover() {
+ return this.column.popover !== false && Boolean(resourceComponentName(this.owner, 'summary', this.key));
+ }
+
+ get wrapperClass() {
+ return this.column.wrapperClass ?? 'max-w-md';
+ }
+
+ @action onClick(event) {
+ const { column } = this;
+ const { onClick } = this.args;
+ const resource = this.resource;
+ let handled = false;
+
+ if (typeof onClick === 'function') {
+ onClick(resource, event);
+ handled = true;
+ }
+
+ if (typeof column.onClick === 'function') {
+ column.onClick(resource, event);
+ handled = true;
+ }
+
+ if (typeof column.action === 'function') {
+ column.action(resource, event);
+ handled = true;
+ }
+
+ if (!handled) {
+ openResource(this.owner, resource, { event, resourceType: this.resourceType });
+ handled = true;
+ }
+
+ event?.stopPropagation?.();
+ }
+}
diff --git a/addon/components/table/cell/identity/body.hbs b/addon/components/table/cell/identity/body.hbs
new file mode 100644
index 00000000..6e94509d
--- /dev/null
+++ b/addon/components/table/cell/identity/body.hbs
@@ -0,0 +1,31 @@
+{{! One line: image or icon tile with the status dot, the name, then inline badges. }}
+
+ {{#if @image.component}}
+ {{component @image.component resource=@resource}}
+ {{else if @image.icon}}
+
+
+
+ {{else}}
+
+ {{/if}}
+ {{#if @hasStatusDot}}
+
+ {{/if}}
+
+{{n-a @label}}
+{{#each @badges as |badge|}}
+
+ {{#if badge.icon}}
+
+ {{/if}}
+ {{badge.label}}
+
+{{/each}}
+{{#if @showPopover}}
+
+{{/if}}
diff --git a/addon/components/table/cell/resource-identity.hbs b/addon/components/table/cell/resource-identity.hbs
index 82e3ac90..8258d71e 100644
--- a/addon/components/table/cell/resource-identity.hbs
+++ b/addon/components/table/cell/resource-identity.hbs
@@ -30,7 +30,8 @@
{{n-a this.label}}
{{/if}}
-
+ {{#if this.hasMetaRow}}
+
{{#each this.metaItems as |item|}}
{{#if (eq item.style "badge")}}
{{smart-humanize this.statusLabel}}
{{/if}}
+ {{/if}}
diff --git a/addon/components/table/cell/resource-identity.js b/addon/components/table/cell/resource-identity.js
index 825b3f23..a96c195c 100644
--- a/addon/components/table/cell/resource-identity.js
+++ b/addon/components/table/cell/resource-identity.js
@@ -163,6 +163,10 @@ export default class TableCellResourceIdentityComponent extends Component {
return this.column.wrapperClass ?? 'max-w-md';
}
+ get hasMetaRow() {
+ return this.metaItems.length > 0 || Boolean(this.statusLabel);
+ }
+
valueFromConfig(valueKey, pathKey, fallbackPathKey) {
if (this.column[valueKey] !== undefined) {
return typeof this.column[valueKey] === 'function' ? this.column[valueKey](this.row, this.column) : this.column[valueKey];
diff --git a/addon/components/table/cell/role-identity.hbs b/addon/components/table/cell/role-identity.hbs
new file mode 100644
index 00000000..2c3db94f
--- /dev/null
+++ b/addon/components/table/cell/role-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/table/cell/user-identity.hbs b/addon/components/table/cell/user-identity.hbs
new file mode 100644
index 00000000..b8652d56
--- /dev/null
+++ b/addon/components/table/cell/user-identity.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/user/pill.hbs b/addon/components/user/pill.hbs
index 9d2504f0..bb544832 100644
--- a/addon/components/user/pill.hbs
+++ b/addon/components/user/pill.hbs
@@ -1,16 +1,25 @@
-
+ as |resource|
+>
+ {{yield resource}}
+
diff --git a/addon/components/user/pill.js b/addon/components/user/pill.js
index 9855c1b5..68813c0a 100644
--- a/addon/components/user/pill.js
+++ b/addon/components/user/pill.js
@@ -1,7 +1,42 @@
+/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */
import Component from '@glimmer/component';
+import { get } from '@ember/object';
+/**
+ * The user family pill. Keeps the historic `@user` argument and "No user" /
+ * email defaults, and is otherwise a `Resource::Pill` for the `user`
+ * descriptor: it opens the user when an engine has registered an opener and
+ * shows the user's summary on hover.
+ */
export default class UserPillComponent extends Component {
get resource() {
return this.args.user ?? this.args.resource;
}
+
+ get subtitle() {
+ if (this.args.subtitle !== undefined) {
+ return this.args.subtitle;
+ }
+
+ const email = get(this.resource ?? {}, 'email');
+
+ return email ?? (this.resource ? 'No email' : '-');
+ }
+
+ get showOnlineIndicator() {
+ if (this.args.showOnlineIndicator !== undefined) {
+ return Boolean(this.args.showOnlineIndicator);
+ }
+
+ // With an explicit path the dot is wanted; otherwise the descriptor decides.
+ return typeof this.args.onlinePath === 'string' ? true : undefined;
+ }
+
+ get online() {
+ if (typeof this.args.onlinePath === 'string' && this.resource) {
+ return Boolean(get(this.resource, this.args.onlinePath));
+ }
+
+ return undefined;
+ }
}
diff --git a/addon/components/user/summary.hbs b/addon/components/user/summary.hbs
new file mode 100644
index 00000000..438edbf0
--- /dev/null
+++ b/addon/components/user/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/helpers/resource-component.js b/addon/helpers/resource-component.js
new file mode 100644
index 00000000..9b5f9940
--- /dev/null
+++ b/addon/helpers/resource-component.js
@@ -0,0 +1,15 @@
+import Helper from '@ember/component/helper';
+import { getOwner } from '@ember/application';
+import { resourceComponentName } from '../utils/resource-registry';
+
+/**
+ * `{{resource-component "pill" x}}` — the name of the pill, summary,
+ * identity or select-option component registered for a resource, or `null`
+ * when the resource is unknown or the component cannot be rendered here.
+ * Pair it with `{{#if}}` so the caller can fall back to plain text.
+ */
+export default class ResourceComponentHelper extends Helper {
+ compute([kind, input]) {
+ return resourceComponentName(getOwner(this), kind, input);
+ }
+}
diff --git a/addon/helpers/resource-relation.js b/addon/helpers/resource-relation.js
new file mode 100644
index 00000000..c6f4bff3
--- /dev/null
+++ b/addon/helpers/resource-relation.js
@@ -0,0 +1,10 @@
+import { helper } from '@ember/component/helper';
+import { relationValue } from '../utils/resource-registry';
+
+/**
+ * `{{resource-relation record "driver"}}` — the loaded related record, never
+ * a promise proxy, so `{{#if}}` and pills can rely on it.
+ */
+export default helper(function resourceRelation([record, name]) {
+ return relationValue(record, name);
+});
diff --git a/addon/helpers/resource-type.js b/addon/helpers/resource-type.js
new file mode 100644
index 00000000..5db15227
--- /dev/null
+++ b/addon/helpers/resource-type.js
@@ -0,0 +1,13 @@
+import Helper from '@ember/component/helper';
+import { getOwner } from '@ember/application';
+import { resolveResourceKey } from '../utils/resource-registry';
+
+/**
+ * `{{resource-type x}}` — the registered resource key for a record, stub,
+ * model name, alias or polymorphic type string; `null` when unknown.
+ */
+export default class ResourceTypeHelper extends Helper {
+ compute([input]) {
+ return resolveResourceKey(getOwner(this), input);
+ }
+}
diff --git a/addon/instance-initializers/register-core-resource-descriptors.js b/addon/instance-initializers/register-core-resource-descriptors.js
new file mode 100644
index 00000000..d139a5e6
--- /dev/null
+++ b/addon/instance-initializers/register-core-resource-descriptors.js
@@ -0,0 +1,20 @@
+import { registerResourceDescriptors } from '../utils/resource-registry';
+import buildCoreResourceDescriptors from '../utils/resource-descriptors/core';
+
+/**
+ * Registers the descriptors for the console's core resources in the host
+ * application, so pills, summaries, identity cells and select options for
+ * users, companies, groups, roles, files and categories work everywhere.
+ */
+export function initialize(appInstance) {
+ try {
+ registerResourceDescriptors(appInstance, buildCoreResourceDescriptors(appInstance));
+ } catch {
+ // A host without the registry service simply keeps plain text.
+ }
+}
+
+export default {
+ name: 'register-core-resource-descriptors',
+ initialize,
+};
diff --git a/addon/services/resource-registry.js b/addon/services/resource-registry.js
new file mode 100644
index 00000000..94bfa257
--- /dev/null
+++ b/addon/services/resource-registry.js
@@ -0,0 +1,75 @@
+import Service from '@ember/service';
+import { getOwner } from '@ember/application';
+import {
+ registerResourceDescriptor,
+ registerResourceDescriptors,
+ setResourceOpener,
+ getResourceDescriptors,
+ getResourceDescriptor,
+ resolveResourceKey,
+ resourceComponentName,
+ canOpenResource,
+ openResource,
+ relationValue,
+ safeIdentifier,
+ unwrapResource,
+} from '../utils/resource-registry';
+
+/**
+ * A service front for the resource registry util, so an engine can register
+ * its descriptors and open resources through `owner.lookup('service:resource-registry')`
+ * without importing ember-ui modules.
+ */
+export default class ResourceRegistryService extends Service {
+ get owner() {
+ return getOwner(this);
+ }
+
+ get descriptors() {
+ return getResourceDescriptors(this.owner);
+ }
+
+ register(descriptor) {
+ return registerResourceDescriptor(this.owner, descriptor);
+ }
+
+ registerDescriptors(descriptors) {
+ return registerResourceDescriptors(this.owner, descriptors);
+ }
+
+ setOpener(key, open, options = {}) {
+ return setResourceOpener(this.owner, key, open, options);
+ }
+
+ resolveKey(input) {
+ return resolveResourceKey(this.owner, input);
+ }
+
+ getDescriptor(input) {
+ return getResourceDescriptor(this.owner, input);
+ }
+
+ componentName(kind, input) {
+ return resourceComponentName(this.owner, kind, input);
+ }
+
+ canOpen(input) {
+ return canOpenResource(this.owner, input);
+ }
+
+ open(input, options = {}) {
+ return openResource(this.owner, input, options);
+ }
+
+ relationValue(record, name) {
+ return relationValue(record, name);
+ }
+
+ safeIdentifier(value) {
+ return safeIdentifier(value);
+ }
+
+ unwrap(input) {
+ return unwrapResource(input);
+ }
+}
diff --git a/addon/styles/addon.css b/addon/styles/addon.css
index dec85972..087a80d8 100644
--- a/addon/styles/addon.css
+++ b/addon/styles/addon.css
@@ -24,6 +24,8 @@
@import 'components/pagination.css';
@import 'components/overlay.css';
@import 'components/badge.css';
+@import 'components/pill.css';
+@import 'components/resource-identity.css';
@import 'components/dropdown.css';
@import 'components/cards.css';
@import 'components/timeline.css';
diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css
index 59e0e3fe..8a0a9dc9 100644
--- a/addon/styles/components/badge.css
+++ b/addon/styles/components/badge.css
@@ -397,6 +397,205 @@
@apply text-blue-300;
}
+/* ── Fleet-Ops statuses ──
+ Every value the Fleet-Ops option lists can put on a badge (driver, vehicle,
+ vendor, contact, fleet, fuel report, inspection, work order, issue, place,
+ telematic, device, sensor, part, equipment and trailer statuses) has a
+ family here, so none renders as an unstyled chip. Underscored spellings
+ arrive dasherized through safe-dasherize. */
+
+.status-badge.on-duty-status-badge > span,
+.status-badge.healthy-status-badge > span,
+.status-badge.in-stock-status-badge > span,
+.status-badge.processed-status-badge > span,
+.status-badge.passed-status-badge > span,
+.status-badge.primary-status-badge > span,
+.status-badge.preferred-status-badge > span,
+.status-badge.prequalified-status-badge > span,
+.status-badge.configured-status-badge > span,
+.status-badge.fuel-provider-status-badge > span {
+ @apply bg-green-800 border-green-700 text-green-100;
+}
+
+.status-badge.on-duty-status-badge > span svg,
+.status-badge.healthy-status-badge > span svg,
+.status-badge.in-stock-status-badge > span svg,
+.status-badge.processed-status-badge > span svg,
+.status-badge.passed-status-badge > span svg,
+.status-badge.primary-status-badge > span svg,
+.status-badge.preferred-status-badge > span svg,
+.status-badge.prequalified-status-badge > span svg,
+.status-badge.configured-status-badge > span svg,
+.status-badge.fuel-provider-status-badge > span svg {
+ @apply text-green-300;
+}
+
+.status-badge.on-route-status-badge > span,
+.status-badge.in-use-status-badge > span,
+.status-badge.attached-status-badge > span,
+.status-badge.submitted-status-badge > span,
+.status-badge.scheduled-status-badge > span,
+.status-badge.equipped-status-badge > span,
+.status-badge.provisioning-status-badge > span {
+ @apply bg-blue-800 border-blue-700 text-blue-100;
+}
+
+.status-badge.on-route-status-badge > span svg,
+.status-badge.in-use-status-badge > span svg,
+.status-badge.attached-status-badge > span svg,
+.status-badge.submitted-status-badge > span svg,
+.status-badge.scheduled-status-badge > span svg,
+.status-badge.equipped-status-badge > span svg,
+.status-badge.provisioning-status-badge > span svg {
+ @apply text-blue-300;
+}
+
+.status-badge.on-break-status-badge > span,
+.status-badge.pre-shift-status-badge > span,
+.status-badge.training-status-badge > span,
+.status-badge.onboarding-status-badge > span,
+.status-badge.delayed-status-badge > span,
+.status-badge.pending-verification-status-badge > span,
+.status-badge.reserved-status-badge > span,
+.status-badge.staging-status-badge > span,
+.status-badge.cleaning-status-badge > span,
+.status-badge.inspection-due-status-badge > span,
+.status-badge.on-hold-status-badge > span,
+.status-badge.scaling-status-badge > span,
+.status-badge.downsizing-status-badge > span,
+.status-badge.seasonal-status-badge > span,
+.status-badge.pilot-status-badge > span,
+.status-badge.unmatched-status-badge > span,
+.status-badge.low-stock-status-badge > span,
+.status-badge.on-order-status-badge > span,
+.status-badge.needs-review-status-badge > span,
+.status-badge.awaiting-vendor-status-badge > span,
+.status-badge.quality-check-status-badge > span,
+.status-badge.requires-update-status-badge > span,
+.status-badge.new-status-badge > span,
+.status-badge.temp-closed-status-badge > span,
+.status-badge.under-construction-status-badge > span,
+.status-badge.permit-required-status-badge > span,
+.status-badge.approach-caution-status-badge > span,
+.status-badge.connecting-status-badge > span,
+.status-badge.paused-status-badge > span,
+.status-badge.under-review-status-badge > span,
+.status-badge.unverified-status-badge > span,
+.status-badge.sleeping-status-badge > span,
+.status-badge.long-offline-status-badge > span,
+.status-badge.imported-status-badge > span,
+.status-badge.ignored-status-badge > span {
+ @apply bg-yellow-700 border-yellow-700 text-yellow-100;
+}
+
+.status-badge.on-break-status-badge > span svg,
+.status-badge.pre-shift-status-badge > span svg,
+.status-badge.training-status-badge > span svg,
+.status-badge.onboarding-status-badge > span svg,
+.status-badge.delayed-status-badge > span svg,
+.status-badge.pending-verification-status-badge > span svg,
+.status-badge.reserved-status-badge > span svg,
+.status-badge.staging-status-badge > span svg,
+.status-badge.cleaning-status-badge > span svg,
+.status-badge.inspection-due-status-badge > span svg,
+.status-badge.on-hold-status-badge > span svg,
+.status-badge.scaling-status-badge > span svg,
+.status-badge.downsizing-status-badge > span svg,
+.status-badge.seasonal-status-badge > span svg,
+.status-badge.pilot-status-badge > span svg,
+.status-badge.unmatched-status-badge > span svg,
+.status-badge.low-stock-status-badge > span svg,
+.status-badge.on-order-status-badge > span svg,
+.status-badge.needs-review-status-badge > span svg,
+.status-badge.awaiting-vendor-status-badge > span svg,
+.status-badge.quality-check-status-badge > span svg,
+.status-badge.requires-update-status-badge > span svg,
+.status-badge.new-status-badge > span svg,
+.status-badge.temp-closed-status-badge > span svg,
+.status-badge.under-construction-status-badge > span svg,
+.status-badge.permit-required-status-badge > span svg,
+.status-badge.approach-caution-status-badge > span svg,
+.status-badge.connecting-status-badge > span svg,
+.status-badge.paused-status-badge > span svg,
+.status-badge.under-review-status-badge > span svg,
+.status-badge.unverified-status-badge > span svg,
+.status-badge.sleeping-status-badge > span svg,
+.status-badge.long-offline-status-badge > span svg,
+.status-badge.imported-status-badge > span svg,
+.status-badge.ignored-status-badge > span svg {
+ @apply text-yellow-300;
+}
+
+.status-badge.terminated-status-badge > span,
+.status-badge.suspended-status-badge > span,
+.status-badge.blacklisted-status-badge > span,
+.status-badge.blocked-status-badge > span,
+.status-badge.do-not-contact-status-badge > span,
+.status-badge.accident-hold-status-badge > span,
+.status-badge.no-show-status-badge > span,
+.status-badge.out-of-service-status-badge > span,
+.status-badge.inspection-failed-status-badge > span,
+.status-badge.accident-status-badge > span,
+.status-badge.compliance-hold-status-badge > span,
+.status-badge.stolen-status-badge > span,
+.status-badge.integration-error-status-badge > span,
+.status-badge.unsafe-status-badge > span,
+.status-badge.lost-status-badge > span,
+.status-badge.damaged-status-badge > span,
+.status-badge.out-of-stock-status-badge > span,
+.status-badge.failed-inspection-status-badge > span,
+.status-badge.breakdown-status-badge > span {
+ @apply bg-red-800 border-red-700 text-red-100;
+}
+
+.status-badge.terminated-status-badge > span svg,
+.status-badge.suspended-status-badge > span svg,
+.status-badge.blacklisted-status-badge > span svg,
+.status-badge.blocked-status-badge > span svg,
+.status-badge.do-not-contact-status-badge > span svg,
+.status-badge.accident-hold-status-badge > span svg,
+.status-badge.no-show-status-badge > span svg,
+.status-badge.out-of-service-status-badge > span svg,
+.status-badge.inspection-failed-status-badge > span svg,
+.status-badge.accident-status-badge > span svg,
+.status-badge.compliance-hold-status-badge > span svg,
+.status-badge.stolen-status-badge > span svg,
+.status-badge.integration-error-status-badge > span svg,
+.status-badge.unsafe-status-badge > span svg,
+.status-badge.lost-status-badge > span svg,
+.status-badge.damaged-status-badge > span svg,
+.status-badge.out-of-stock-status-badge > span svg,
+.status-badge.failed-inspection-status-badge > span svg,
+.status-badge.breakdown-status-badge > span svg {
+ @apply text-red-300;
+}
+
+.status-badge.off-duty-status-badge > span,
+.status-badge.on-leave-status-badge > span,
+.status-badge.retired-status-badge > span,
+.status-badge.detached-status-badge > span,
+.status-badge.decommissioning-status-badge > span,
+.status-badge.archived-status-badge > span,
+.status-badge.not-equipped-status-badge > span,
+.status-badge.discontinued-status-badge > span,
+.status-badge.unattached-status-badge > span,
+.status-badge.reviewed-status-badge > span {
+ @apply bg-gray-800 border-gray-700 text-gray-100;
+}
+
+.status-badge.off-duty-status-badge > span svg,
+.status-badge.on-leave-status-badge > span svg,
+.status-badge.retired-status-badge > span svg,
+.status-badge.detached-status-badge > span svg,
+.status-badge.decommissioning-status-badge > span svg,
+.status-badge.archived-status-badge > span svg,
+.status-badge.not-equipped-status-badge > span svg,
+.status-badge.discontinued-status-badge > span svg,
+.status-badge.unattached-status-badge > span svg,
+.status-badge.reviewed-status-badge > span svg {
+ @apply text-gray-300;
+}
+
body[data-theme='dark'] .status-badge:not(.shadow-none) {
@apply shadow;
}
diff --git a/addon/styles/components/pill.css b/addon/styles/components/pill.css
new file mode 100644
index 00000000..3dd2fa10
--- /dev/null
+++ b/addon/styles/components/pill.css
@@ -0,0 +1,29 @@
+/*
+ * A pill names a linked record: image, name, identifier. Only a pill that
+ * was given something to do (an anchor) reacts to the pointer; the static
+ * variant reads as text.
+ */
+.fleetbase-pill > a[href] {
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ margin: -0.25rem 0.5rem -0.25rem -0.5rem;
+ transition:
+ background-color 120ms ease,
+ opacity 120ms ease;
+}
+
+.fleetbase-pill > a[href]:hover {
+ background-color: rgb(0 0 0 / 4%);
+}
+
+.fleetbase-pill > a[href]:hover .text-sm {
+ text-decoration: underline;
+}
+
+body[data-theme='dark'] .fleetbase-pill > a[href]:hover {
+ background-color: rgb(255 255 255 / 6%);
+}
+
+.fleetbase-pill--static > .fleetbase-pill-body {
+ cursor: default;
+}
diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css
new file mode 100644
index 00000000..8ef7b428
--- /dev/null
+++ b/addon/styles/components/resource-identity.css
@@ -0,0 +1,236 @@
+/*
+ * Resource identity: hover summaries, select options and identity tiles.
+ * Colours are set for light and dark so a card reads correctly wherever it
+ * floats, including above the resource context panel.
+ */
+
+/* ── Hover card (the floating element hosting a summary) ── */
+.ember-attacher.resource-hover-card {
+ z-index: 10000;
+}
+
+.resource-hover-card .resource-hover-card-inner {
+ @apply rounded-lg border border-gray-200 bg-white text-left text-gray-900 shadow-lg;
+ padding: 0;
+ margin: 0;
+ max-width: min(22rem, calc(100vw - 2rem));
+}
+
+body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner {
+ @apply border-gray-700 bg-gray-900 text-gray-100;
+}
+
+.resource-hover-card-anchor {
+ display: none;
+}
+
+/* ── Summary card ── */
+.resource-summary {
+ @apply flex flex-col gap-2 p-2.5 text-xs;
+ width: 17rem;
+}
+
+.resource-summary-header {
+ @apply flex items-center gap-2.5;
+}
+
+.resource-summary-media {
+ @apply relative flex-shrink-0;
+}
+
+.resource-summary-image,
+.resource-summary-icon {
+ @apply h-9 w-9 border border-gray-200 object-cover dark:border-gray-700;
+ border-radius: 9999px;
+}
+
+.resource-summary-icon {
+ @apply flex items-center justify-center bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400;
+}
+
+/* Same corner as the identity cell and the pill: top-left, just outside the image. */
+.resource-summary-online-dot {
+ @apply absolute left-0 top-0 -ml-1 -mt-1 rounded-full bg-white dark:bg-gray-900;
+}
+
+.resource-summary-heading {
+ @apply flex min-w-0 flex-1 flex-col;
+}
+
+.resource-summary-title {
+ @apply truncate text-sm font-semibold leading-5 text-gray-900 dark:text-gray-100;
+}
+
+.resource-summary-identifier {
+ @apply truncate text-xs leading-4 text-gray-500 dark:text-gray-400;
+}
+
+.resource-summary-status {
+ @apply ml-auto flex-shrink-0;
+}
+
+.resource-summary-facts {
+ @apply grid gap-x-3 gap-y-1 border-t border-gray-100 pt-2 dark:border-gray-800;
+ grid-template-columns: max-content minmax(0, 1fr);
+ margin: 0;
+}
+
+.resource-summary-fact {
+ display: contents;
+}
+
+.resource-summary-fact > dt {
+ @apply truncate text-xs leading-4 text-gray-500 dark:text-gray-400;
+}
+
+.resource-summary-fact > dd {
+ @apply min-w-0 truncate text-xs leading-4 text-gray-900 dark:text-gray-100;
+
+ /* Room inside the clipping box for a pill's hover padding, ring and dot,
+ which otherwise lose their left and top edges to the truncation. */
+ padding: 0.25rem 0 0.25rem 0.5rem;
+ margin: -0.25rem 0 -0.25rem -0.5rem;
+}
+
+.resource-summary-fact > dd .fleetbase-pill {
+ @apply inline-block align-middle;
+}
+
+.resource-summary-fact > dd .fleetbase-pill img,
+.resource-summary-fact > dd .fleetbase-pill .resource-pill-icon {
+ @apply h-4 w-4;
+ width: 1rem;
+ height: 1rem;
+}
+
+.resource-summary-fact > dd .fleetbase-pill .resource-pill-subtitle {
+ display: none;
+}
+
+/* The compact pill's image is 1rem, so its dot shrinks and sits just outside
+ the corner, the way the identity cell draws it. */
+.resource-summary-fact > dd .fleetbase-pill .fleetbase-pill-body {
+ @apply items-center;
+}
+
+.resource-summary-fact > dd .fleetbase-pill .resource-pill-online-dot {
+ @apply -ml-0.5 -mt-0.5 rounded-full bg-white dark:bg-gray-900;
+ width: 0.375rem;
+ height: 0.375rem;
+}
+
+.resource-summary-skeleton {
+ @apply flex flex-col gap-1.5 border-t border-gray-100 pt-2 dark:border-gray-800;
+}
+
+.resource-summary-skeleton > span {
+ @apply block h-2.5 rounded bg-gray-200 dark:bg-gray-700;
+ animation: resource-summary-pulse 1.2s ease-in-out infinite;
+}
+
+.resource-summary-skeleton > span:nth-child(2) {
+ width: 70%;
+}
+
+.resource-summary-skeleton > span:nth-child(3) {
+ width: 50%;
+}
+
+@keyframes resource-summary-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+.resource-summary-actions {
+ @apply flex justify-end border-t border-gray-100 pt-2 dark:border-gray-800;
+}
+
+/* ── Pill icon tile ── */
+.resource-pill-icon {
+ @apply flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 bg-gray-100 text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400;
+}
+
+/* ── Select option: photo or icon, name, and a line of detail ──
+ Rows stay short because a dropdown can list hundreds of them: one 1.5rem
+ circle, the name, and the detail on a second tight line. */
+.select-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0;
+ line-height: 1.15;
+}
+
+.select-option__photo {
+ flex-shrink: 0;
+ width: 1.5rem;
+ height: 1.5rem;
+ object-fit: cover;
+ border-radius: 9999px;
+ background-color: rgb(156 163 175 / 20%);
+}
+
+.select-option__icon,
+.select-option__component {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ color: rgb(107 114 128);
+}
+
+.select-option__text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0;
+ min-width: 0;
+}
+
+.select-option__title,
+.select-option__details {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-option__title {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1.15rem;
+}
+
+.select-option__details {
+ font-size: 0.6875rem;
+ line-height: 0.9rem;
+ opacity: 0.7;
+}
+
+/* In a closed select there is room for one line: a small photo, the name, then the detail. */
+.select-option--compact {
+ gap: 0.375rem;
+}
+
+.select-option--compact .select-option__photo {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.select-option--compact .select-option__text {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 0.375rem;
+}
+
+.select-option--compact .select-option__title {
+ flex-shrink: 0;
+ max-width: 60%;
+ font-weight: 500;
+}
diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css
index 8146e527..88da733a 100644
--- a/addon/styles/layout/next.css
+++ b/addon/styles/layout/next.css
@@ -2651,6 +2651,28 @@ a.text-danger:hover {
margin-top: 2rem;
}
+.filter-radio {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 1rem;
+ padding-top: 0.125rem;
+}
+
+.filter-radio-option {
+ @apply inline-flex cursor-pointer select-none items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300;
+ margin: 0;
+}
+
+.filter-radio-option--active {
+ @apply font-medium text-gray-900 dark:text-gray-100;
+}
+
+.filter-radio-option > input[type='radio'] {
+ @apply h-3.5 w-3.5 cursor-pointer;
+ accent-color: rgb(37 99 235);
+ margin: 0;
+}
+
.filter-string {
@apply flex flex-col relative;
}
@@ -2684,6 +2706,59 @@ a.text-danger:hover {
@apply opacity-50;
}
+.filter-model-multiple {
+ @apply relative;
+}
+
+.filter-model-multiple > .clear-button {
+ @apply absolute top-0 right-0 dark:text-gray-500 text-gray-700 pr-3 pt-2;
+}
+
+.filter-model-multiple > .clear-button:hover {
+ @apply opacity-50;
+}
+
+.filter-model-multiple > .clear-button:disabled {
+ @apply opacity-30 cursor-default;
+}
+
+.filter-model-multiple > .fleetbase-model-select,
+.filter-model-multiple > .fleetbase-model-select > .ember-basic-dropdown,
+.filter-model-multiple .ember-power-select-multiple-trigger {
+ flex: 1 1 auto;
+ width: 100%;
+ min-width: 0;
+}
+
+/* The small form-input height clips the multiple trigger and turns it into a scroller
+ before anything is selected; let it start at the input height and grow with chips. */
+.filter-model-multiple .ember-power-select-multiple-trigger {
+ display: flex;
+ align-items: center;
+ height: auto !important;
+ min-height: 2.125rem;
+ max-height: none !important;
+ overflow: visible !important;
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+ padding-right: 2.5rem;
+}
+
+.filter-model-multiple .ember-power-select-multiple-options {
+ flex: 1 1 auto;
+ min-width: 0;
+ gap: 0.125rem;
+}
+
+.filter-model-multiple .ember-power-select-multiple-option {
+ margin: 0.125rem 0.125rem 0.125rem 0;
+}
+
+.filter-model-multiple .ember-power-select-trigger-multiple-input {
+ height: 1.625rem;
+ min-width: 4rem;
+}
+
.filter-range {
@apply flex flex-col relative;
}
diff --git a/addon/utils/generate-uuid.js b/addon/utils/generate-uuid.js
new file mode 100644
index 00000000..a82b12e9
--- /dev/null
+++ b/addon/utils/generate-uuid.js
@@ -0,0 +1,18 @@
+/**
+ * A v4 UUID for records that arrive without an id, always from the
+ * platform's cryptographic random source.
+ */
+export default function generateUuid() {
+ if (typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
diff --git a/addon/utils/resource-descriptors/core.js b/addon/utils/resource-descriptors/core.js
new file mode 100644
index 00000000..bf6f8cef
--- /dev/null
+++ b/addon/utils/resource-descriptors/core.js
@@ -0,0 +1,261 @@
+/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */
+import { get } from '@ember/object';
+import { relationValue, safeIdentifier } from '../resource-registry';
+
+/**
+ * Descriptors for the console's core resources: user, company, group, role,
+ * file and category. They read attributes by duck typing so a POJO from an
+ * API payload renders the same as an Ember Data record.
+ *
+ * None of them opens anything by default: the console has no per-record
+ * routes for these. An engine that owns such a page registers an opener
+ * with `setResourceOpener(owner, key, fn)`.
+ */
+
+const FILE_ICONS = {
+ xlsx: 'file-excel',
+ xls: 'file-excel',
+ xlsb: 'file-excel',
+ xlsm: 'file-excel',
+ csv: 'file-csv',
+ tsv: 'file-csv',
+ doc: 'file-word',
+ docx: 'file-word',
+ docm: 'file-word',
+ pdf: 'file-pdf',
+ ppt: 'file-powerpoint',
+ pptx: 'file-powerpoint',
+ zip: 'file-zipper',
+ gz: 'file-zipper',
+ tar: 'file-zipper',
+ mp4: 'file-video',
+ mov: 'file-video',
+ mp3: 'file-audio',
+ wav: 'file-audio',
+ txt: 'file-lines',
+ md: 'file-lines',
+ json: 'file-code',
+ js: 'file-code',
+ xml: 'file-code',
+};
+
+function present(value) {
+ return value !== undefined && value !== null && String(value).trim() !== '';
+}
+
+function first(record, ...paths) {
+ for (const path of paths) {
+ const value = get(record, path);
+
+ if (present(value)) {
+ return value;
+ }
+ }
+
+ return null;
+}
+
+function count(record, path, countPath) {
+ const counted = get(record, countPath);
+
+ if (typeof counted === 'number') {
+ return counted;
+ }
+
+ const relation = relationValue(record, path);
+
+ if (relation && typeof relation.length === 'number') {
+ return relation.length;
+ }
+
+ return null;
+}
+
+export function fileExtension(file) {
+ const name = first(file ?? {}, 'original_filename', 'url', 'path');
+
+ if (typeof name !== 'string') {
+ return null;
+ }
+
+ const match = name.split('?')[0].match(/\.([a-z0-9]+)$/i);
+
+ return match ? match[1].toLowerCase() : null;
+}
+
+export function isImageFile(file) {
+ const type = get(file ?? {}, 'content_type');
+
+ if (typeof type === 'string') {
+ return type.startsWith('image/');
+ }
+
+ return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif'].includes(fileExtension(file));
+}
+
+export function fileIcon(file) {
+ return FILE_ICONS[fileExtension(file)] ?? 'file';
+}
+
+export function buildCoreResourceDescriptors() {
+ return [
+ {
+ key: 'user',
+ labelKey: 'resource.user',
+ icon: 'user',
+ modelNames: ['user'],
+ polymorphicTypes: ['Fleetbase\\Models\\User', 'core:user'],
+ title: (user) => first(user, 'name', 'email', 'public_id'),
+ identifier: (user) => first(user, 'email', 'phone'),
+ image: (user) => ({ url: get(user, 'avatar_url'), shape: 'round' }),
+ online: (user) => {
+ const online = get(user, 'is_online') ?? get(user, 'online');
+
+ return typeof online === 'boolean' ? online : undefined;
+ },
+ status: (user) => first(user, 'status', 'session_status'),
+ selectDetails: (user) => [first(user, 'email'), first(user, 'phone')],
+ facts: (user) => [
+ { labelKey: 'resource-summary.facts.email', label: 'Email', value: first(user, 'email') },
+ { labelKey: 'resource-summary.facts.phone', label: 'Phone', value: first(user, 'phone') },
+ { labelKey: 'resource-summary.facts.role', label: 'Role', value: first(user, 'role_name', 'role.name', 'type') },
+ { labelKey: 'resource-summary.facts.company', label: 'Company', value: first(user, 'company_name', 'company.name') },
+ { labelKey: 'resource-summary.facts.last-seen', label: 'Last seen', value: get(user, 'last_seen_at') ?? get(user, 'last_login') ?? get(user, 'created_at'), format: 'date' },
+ { labelKey: 'resource-summary.facts.status', label: 'Status', value: first(user, 'status', 'session_status'), format: 'humanize' },
+ ],
+ },
+ {
+ key: 'company',
+ labelKey: 'resource.company',
+ icon: 'building',
+ modelNames: ['company', 'organization'],
+ aliases: ['organization'],
+ polymorphicTypes: ['Fleetbase\\Models\\Company', 'core:company'],
+ title: (company) => first(company, 'name', 'public_id'),
+ identifier: (company) => safeIdentifier(first(company, 'public_id', 'slug')),
+ image: (company) => ({ url: get(company, 'logo_url'), shape: 'square' }),
+ status: (company) => first(company, 'status'),
+ selectDetails: (company) => [first(company, 'country'), first(company, 'timezone')],
+ facts: (company) => [
+ {
+ labelKey: 'resource-summary.facts.owner',
+ label: 'Owner',
+ related: relationValue(company, 'owner'),
+ relatedType: 'user',
+ value: first(company, 'owner.name', 'owner_name'),
+ },
+ { labelKey: 'resource-summary.facts.members', label: 'Members', value: count(company, 'users', 'users_count') },
+ { labelKey: 'resource-summary.facts.country', label: 'Country', value: first(company, 'country') },
+ { labelKey: 'resource-summary.facts.timezone', label: 'Timezone', value: first(company, 'timezone') },
+ { labelKey: 'resource-summary.facts.status', label: 'Status', value: first(company, 'status'), format: 'humanize' },
+ ],
+ },
+ {
+ key: 'group',
+ labelKey: 'resource.group',
+ icon: 'users',
+ modelNames: ['group'],
+ polymorphicTypes: ['Fleetbase\\Models\\Group', 'core:group'],
+ title: (group) => first(group, 'name', 'public_id'),
+ identifier: (group) => {
+ const members = count(group, 'users', 'users_count');
+
+ return typeof members === 'number' ? `${members} member${members === 1 ? '' : 's'}` : null;
+ },
+ image: () => ({ icon: 'users' }),
+ selectDetails: (group) => [first(group, 'description')],
+ facts: (group) => [
+ { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(group, 'description') },
+ { labelKey: 'resource-summary.facts.members', label: 'Members', value: count(group, 'users', 'users_count') },
+ { labelKey: 'resource-summary.facts.created', label: 'Created', value: get(group, 'created_at'), format: 'date' },
+ ],
+ },
+ {
+ key: 'role',
+ labelKey: 'resource.role',
+ icon: 'user-shield',
+ modelNames: ['role'],
+ polymorphicTypes: ['Fleetbase\\Models\\Role', 'core:role'],
+ title: (role) => first(role, 'name', 'public_id'),
+ identifier: (role) => first(role, 'guard_name', 'type'),
+ image: () => ({ icon: 'user-shield' }),
+ selectDetails: (role) => [first(role, 'description'), first(role, 'guard_name')],
+ facts: (role) => [
+ { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(role, 'description') },
+ { labelKey: 'resource-summary.facts.guard', label: 'Guard', value: first(role, 'guard_name', 'type') },
+ { labelKey: 'resource-summary.facts.policies', label: 'Policies', value: count(role, 'policies', 'policies_count') },
+ { labelKey: 'resource-summary.facts.permissions', label: 'Permissions', value: count(role, 'permissions', 'permissions_count') },
+ ],
+ },
+ {
+ key: 'file',
+ labelKey: 'resource.file',
+ icon: 'file',
+ modelNames: ['file'],
+ polymorphicTypes: ['Fleetbase\\Models\\File', 'core:file'],
+ title: (file) => first(file, 'original_filename', 'caption', 'url', 'public_id'),
+ identifier: (file) => first(file, 'content_type', 'type'),
+ image: (file) => (isImageFile(file) && present(get(file, 'url')) ? { url: get(file, 'url'), shape: 'square' } : { icon: fileIcon(file) }),
+ selectDetails: (file) => [first(file, 'content_type', 'type'), typeof get(file, 'file_size') === 'number' ? get(file, 'file_size') : null].filter(present),
+ facts: (file) => [
+ { labelKey: 'resource-summary.facts.type', label: 'Type', value: first(file, 'content_type', 'type') },
+ { labelKey: 'resource-summary.facts.size', label: 'Size', value: get(file, 'file_size'), format: 'bytes' },
+ {
+ labelKey: 'resource-summary.facts.uploader',
+ label: 'Uploaded by',
+ related: relationValue(file, 'uploader'),
+ relatedType: 'user',
+ value: first(file, 'uploader.name', 'uploader_name'),
+ },
+ { labelKey: 'resource-summary.facts.created', label: 'Uploaded', value: get(file, 'created_at'), format: 'date' },
+ ],
+ canOpen: (file) => present(get(file ?? {}, 'url')),
+ open: (file) => {
+ const url = get(file, 'url');
+
+ if (!present(url)) {
+ return false;
+ }
+
+ window.open(url, '_blank', 'noopener');
+
+ return true;
+ },
+ },
+ {
+ key: 'category',
+ labelKey: 'resource.category',
+ icon: 'folder',
+ modelNames: ['category'],
+ polymorphicTypes: ['Fleetbase\\Models\\Category', 'core:category'],
+ title: (category) => first(category, 'name', 'slug', 'public_id'),
+ identifier: (category) => first(category, 'for', 'slug'),
+ image: (category) => {
+ const icon = get(category, 'icon');
+ const url = get(category, 'icon_url');
+
+ if (present(icon)) {
+ return { icon };
+ }
+
+ return present(url) ? { url, shape: 'square' } : { icon: 'folder' };
+ },
+ selectDetails: (category) => [first(category, 'for'), first(category, 'description')],
+ facts: (category) => [
+ {
+ labelKey: 'resource-summary.facts.parent',
+ label: 'Parent',
+ related: relationValue(category, 'parent'),
+ relatedType: 'category',
+ value: first(category, 'parent.name', 'parent_name'),
+ },
+ { labelKey: 'resource-summary.facts.for', label: 'For', value: first(category, 'for', 'owner_type') },
+ { labelKey: 'resource-summary.facts.icon', label: 'Icon', value: first(category, 'icon') },
+ { labelKey: 'resource-summary.facts.colour', label: 'Colour', value: first(category, 'icon_color') },
+ { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(category, 'description') },
+ ],
+ },
+ ];
+}
+
+export default buildCoreResourceDescriptors;
diff --git a/addon/utils/resource-identity.js b/addon/utils/resource-identity.js
new file mode 100644
index 00000000..5dad6470
--- /dev/null
+++ b/addon/utils/resource-identity.js
@@ -0,0 +1,324 @@
+/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */
+import { get } from '@ember/object';
+import ObjectProxy from '@ember/object/proxy';
+import { getResourceDescriptor, readDescriptor, safeIdentifier, resourceComponentName, relationValue } from './resource-registry';
+import formatBytes from './format-bytes';
+import smartHumanize from './smart-humanize';
+import formatDate from './format-date';
+
+/**
+ * Shared presentation logic for the resource identity components: everything
+ * a pill, summary, identity cell or select-option derives from a descriptor
+ * and a record, with plain-attribute fallbacks when there is no descriptor.
+ */
+
+export const DEFAULT_STATUS_TONES = {
+ active: 'text-green-500',
+ available: 'text-green-500',
+ online: 'text-green-500',
+ connected: 'text-green-500',
+ success: 'text-green-500',
+ enabled: 'text-green-500',
+ equipped: 'text-green-500',
+ in_stock: 'text-green-500',
+ in_service: 'text-green-500',
+ on_duty: 'text-green-500',
+ published: 'text-green-500',
+ passed: 'text-green-500',
+ completed: 'text-green-500',
+ matched: 'text-green-500',
+ warning: 'text-yellow-500',
+ pending: 'text-yellow-500',
+ busy: 'text-yellow-500',
+ assigned: 'text-yellow-500',
+ maintenance: 'text-yellow-500',
+ recently_offline: 'text-yellow-500',
+ low_stock: 'text-yellow-500',
+ in_progress: 'text-yellow-500',
+ scheduled: 'text-yellow-500',
+ draft: 'text-yellow-500',
+ medium: 'text-yellow-500',
+ inactive: 'text-gray-400',
+ offline: 'text-gray-400',
+ unavailable: 'text-gray-400',
+ unequipped: 'text-gray-400',
+ never_connected: 'text-gray-400',
+ long_offline: 'text-gray-400',
+ detached: 'text-gray-400',
+ low: 'text-gray-400',
+ out_of_stock: 'text-red-500',
+ error: 'text-red-500',
+ disabled: 'text-red-500',
+ suspended: 'text-red-500',
+ out_of_service: 'text-red-500',
+ failed: 'text-red-500',
+ overdue: 'text-red-500',
+ expired: 'text-red-500',
+ critical: 'text-red-500',
+ high: 'text-red-500',
+ urgent: 'text-red-500',
+ canceled: 'text-red-500',
+ cancelled: 'text-red-500',
+ retired: 'text-red-500',
+};
+
+const TITLE_PATHS = ['name', 'display_name', 'displayName', 'title', 'tracking', 'public_id'];
+
+/** A record with any proxy peeled off; never a promise. */
+export function unwrapRecord(value) {
+ if (!value || typeof value !== 'object') {
+ return value ?? null;
+ }
+
+ if (value instanceof ObjectProxy) {
+ return value.content ?? null;
+ }
+
+ if (typeof value.then === 'function' && 'content' in value) {
+ return value.content ?? null;
+ }
+
+ return value;
+}
+
+function firstPresent(record, paths) {
+ for (const path of paths) {
+ const value = get(record, path);
+
+ if (value !== undefined && value !== null && String(value).trim() !== '') {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+export function resourceTitle(descriptor, record, fallback) {
+ if (!record) {
+ return fallback ?? null;
+ }
+
+ const title = readDescriptor(descriptor, 'title', record);
+
+ if (title !== undefined && title !== null && String(title).trim() !== '') {
+ return title;
+ }
+
+ return firstPresent(record, TITLE_PATHS) ?? fallback ?? null;
+}
+
+export function resourceIdentifier(descriptor, record) {
+ if (!record) {
+ return null;
+ }
+
+ return safeIdentifier(readDescriptor(descriptor, 'identifier', record));
+}
+
+/**
+ * Normalizes what a descriptor's `image()` returns into
+ * `{ url, fallback, shape, icon, component }`.
+ */
+export function resourceImage(descriptor, record, overrides = {}) {
+ const raw = record ? readDescriptor(descriptor, 'image', record) : undefined;
+ const image = raw && typeof raw === 'object' ? raw : {};
+ const url = overrides.url ?? image.url ?? (typeof raw === 'string' ? raw : undefined) ?? (record ? firstPresent(record, ['photo_url', 'avatar_url', 'logo_url', 'icon_url']) : undefined);
+
+ return {
+ url: url ?? null,
+ fallback: overrides.fallback ?? image.fallback ?? null,
+ shape: overrides.shape ?? image.shape ?? 'round',
+ icon: overrides.icon ?? image.icon ?? null,
+ iconClass: image.iconClass ?? null,
+ component: image.component ?? null,
+ };
+}
+
+export function resourceOnline(descriptor, record) {
+ if (!record) {
+ return undefined;
+ }
+
+ const online = readDescriptor(descriptor, 'online', record);
+
+ if (online === undefined && !descriptor) {
+ const value = get(record, 'online') ?? get(record, 'is_online');
+
+ return typeof value === 'boolean' ? value : undefined;
+ }
+
+ return online ?? undefined;
+}
+
+export function resourceStatus(descriptor, record) {
+ if (!record) {
+ return undefined;
+ }
+
+ const status = readDescriptor(descriptor, 'status', record);
+
+ if (status === undefined && !descriptor) {
+ return get(record, 'status') ?? undefined;
+ }
+
+ return status ?? undefined;
+}
+
+/** The dot colour class for an online flag or status value. */
+export function statusToneClass(value, tones = {}) {
+ if (typeof value === 'boolean') {
+ return value ? 'text-green-500' : 'text-yellow-200';
+ }
+
+ if (value === undefined || value === null || value === '') {
+ return 'text-gray-400';
+ }
+
+ const map = { ...DEFAULT_STATUS_TONES, ...tones };
+ const key = String(value);
+
+ return map[key] ?? map[key.toLowerCase()] ?? 'text-gray-400';
+}
+
+/** Badges from a descriptor, minus any that point back at the row itself. */
+export function resourceBadges(descriptor, record, context = {}) {
+ if (!record) {
+ return [];
+ }
+
+ const badges = readDescriptor(descriptor, 'badges', record, context);
+ const list = Array.isArray(badges) ? badges : [];
+ const selfId = context.selfId ?? null;
+
+ return list.filter(
+ (badge) =>
+ badge && badge.label !== undefined && badge.label !== null && String(badge.label).trim() !== '' && !(selfId && badge.relatedId && String(badge.relatedId) === String(selfId))
+ );
+}
+
+export function formatFactValue(value, format) {
+ if (value === undefined || value === null || value === '') {
+ return null;
+ }
+
+ if (typeof format === 'function') {
+ try {
+ return format(value);
+ } catch {
+ return null;
+ }
+ }
+
+ if (format === 'date' || value instanceof Date) {
+ const date = value instanceof Date ? value : new Date(value);
+
+ return Number.isNaN(date.getTime()) ? String(value) : formatDate(date, 'dd MMM yyyy, HH:mm');
+ }
+
+ if (format === 'bytes') {
+ return formatBytes(Number(value));
+ }
+
+ if (format === 'humanize') {
+ return smartHumanize(String(value));
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No';
+ }
+
+ return String(value);
+}
+
+function humanizeKey(key) {
+ const last = String(key).split('.').pop();
+
+ return smartHumanize(last.replace(/[-_]/g, ' '));
+}
+
+/**
+ * The facts a summary lists: `[{ label, value, related, relatedType, pillComponent }]`.
+ * A fact with a related record renders as that record's pill when the owner
+ * can render one, otherwise as its value text.
+ */
+export function resourceFacts(owner, descriptor, record, { translate, limit = 6 } = {}) {
+ if (!record) {
+ return [];
+ }
+
+ const facts = readDescriptor(descriptor, 'facts', record);
+ const list = Array.isArray(facts) ? facts : [];
+ const output = [];
+
+ for (const fact of list) {
+ if (!fact) {
+ continue;
+ }
+
+ const related = unwrapRecord(fact.related);
+ const pillComponent = related ? resourceComponentName(owner, 'pill', fact.relatedType ?? related) : null;
+ const value = related && !pillComponent ? resourceTitle(getResourceDescriptor(owner, fact.relatedType ?? related), related, fact.value) : formatFactValue(fact.value, fact.format);
+
+ if ((value === null || value === undefined || value === '') && !pillComponent) {
+ continue;
+ }
+
+ const label = fact.label ?? (fact.labelKey ? (typeof translate === 'function' ? translate(fact.labelKey) : null) : null) ?? humanizeKey(fact.labelKey ?? 'value');
+
+ output.push({ label, value, related, relatedType: fact.relatedType ?? null, pillComponent });
+
+ if (output.length >= limit) {
+ break;
+ }
+ }
+
+ return output;
+}
+
+/** The detail strings a select-option shows under the title. */
+export function resourceSelectDetails(descriptor, record) {
+ if (!record) {
+ return [];
+ }
+
+ const details = readDescriptor(descriptor, 'selectDetails', record);
+
+ if (Array.isArray(details)) {
+ return details;
+ }
+
+ if (details !== undefined && details !== null) {
+ return [details];
+ }
+
+ return [resourceIdentifier(descriptor, record)];
+}
+
+/** Translates a key through the owner's intl service when it has the key. */
+export function makeTranslator(owner) {
+ let intl = null;
+
+ try {
+ intl = owner?.lookup?.('service:intl') ?? null;
+ } catch {
+ intl = null;
+ }
+
+ return (key) => {
+ if (!intl || typeof intl.t !== 'function') {
+ return null;
+ }
+
+ try {
+ if (typeof intl.exists === 'function' && !intl.exists(key)) {
+ return null;
+ }
+
+ return intl.t(key);
+ } catch {
+ return null;
+ }
+ };
+}
+
+export { relationValue };
diff --git a/addon/utils/resource-registry.js b/addon/utils/resource-registry.js
new file mode 100644
index 00000000..fa9527d1
--- /dev/null
+++ b/addon/utils/resource-registry.js
@@ -0,0 +1,576 @@
+/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */
+import { get } from '@ember/object';
+import { dasherize } from '@ember/string';
+import { isArray } from '@ember/array';
+import ObjectProxy from '@ember/object/proxy';
+
+/**
+ * The resource identity registry.
+ *
+ * A descriptor teaches the console how to show one kind of record: its
+ * title, identifier, image, status, the facts a hover summary lists, and how
+ * to open it. Descriptors are stored in the shared `universe/registry-service`
+ * so the host application and every engine read the same set, and resolved
+ * by model name, alias, polymorphic type string or PHP class basename.
+ *
+ * Everything here is tolerant: unknown input resolves to `null`, and reading
+ * or opening never throws, so a template can always fall back to plain text.
+ */
+
+export const REGISTRY_SECTION = 'resource-identity';
+export const DESCRIPTOR_LIST = 'descriptors';
+const INDEX_LIST = 'index';
+const INDEX_KEY = 'index';
+
+export const COMPONENT_KINDS = {
+ pill: 'pill',
+ summary: 'summary',
+ identity: 'identity',
+ 'select-option': 'selectOption',
+ selectOption: 'selectOption',
+};
+
+const DEFAULT_COMPONENT_NAMES = {
+ pill: (key) => `${key}/pill`,
+ summary: (key) => `${key}/summary`,
+ identity: (key) => `table/cell/${key}-identity`,
+ selectOption: (key) => `select-option/${key}`,
+};
+
+/** Model-name prefixes that wrap a canonical resource in a polymorphic subtype. */
+export const SUBTYPE_PREFIXES = ['facilitator-', 'maintenance-subject-', 'customer-', 'attachable-'];
+
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const fallbackStores = new WeakMap();
+
+function lookupService(owner, name) {
+ try {
+ return owner?.lookup?.(`service:${name}`) ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Where descriptors live: the universe registry service when the owner can
+ * reach it, otherwise a per-owner in-memory store (a dummy app or a test
+ * without ember-core services).
+ */
+function getStore(owner) {
+ if (!owner) {
+ return null;
+ }
+
+ const service = lookupService(owner, 'universe/registry-service');
+
+ if (service && typeof service.register === 'function' && typeof service.getRegistry === 'function' && typeof service.lookup === 'function') {
+ return {
+ list: (name) => service.getRegistry(REGISTRY_SECTION, name),
+ put: (name, key, value) => service.register(REGISTRY_SECTION, name, key, value),
+ find: (name, key) => service.lookup(REGISTRY_SECTION, name, key),
+ };
+ }
+
+ let store = fallbackStores.get(owner);
+
+ if (!store) {
+ const lists = new Map();
+ const listFor = (name) => {
+ if (!lists.has(name)) {
+ lists.set(name, []);
+ }
+
+ return lists.get(name);
+ };
+
+ store = {
+ list: (name) => listFor(name),
+ put: (name, key, value) => {
+ const list = listFor(name);
+ const index = list.findIndex((item) => item._registryKey === key);
+
+ value._registryKey = key;
+
+ if (index === -1) {
+ list.push(value);
+ } else {
+ list.splice(index, 1, value);
+ }
+ },
+ find: (name, key) => listFor(name).find((item) => item._registryKey === key) ?? null,
+ };
+
+ fallbackStores.set(owner, store);
+ }
+
+ return store;
+}
+
+function uniqueStrings(values) {
+ return [...new Set((values ?? []).filter((value) => typeof value === 'string' && value.trim() !== '').map((value) => value.trim()))];
+}
+
+function normalizeDescriptor(descriptor) {
+ const key = descriptor.key.trim();
+ const components = descriptor.components ?? {};
+
+ return {
+ ...descriptor,
+ key,
+ labelKey: descriptor.labelKey ?? `resource.${key}`,
+ icon: descriptor.icon ?? 'cube',
+ aliases: uniqueStrings(descriptor.aliases),
+ modelNames: uniqueStrings([key, ...(descriptor.modelNames ?? [])]),
+ polymorphicTypes: uniqueStrings(descriptor.polymorphicTypes),
+ statusTones: descriptor.statusTones ?? {},
+ components: {
+ pill: components.pill ?? DEFAULT_COMPONENT_NAMES.pill(key),
+ summary: components.summary ?? DEFAULT_COMPONENT_NAMES.summary(key),
+ identity: components.identity ?? DEFAULT_COMPONENT_NAMES.identity(key),
+ selectOption: components.selectOption ?? components['select-option'] ?? DEFAULT_COMPONENT_NAMES.selectOption(key),
+ },
+ };
+}
+
+function rebuildIndex(store) {
+ const index = { byKey: {}, byAlias: {}, byModelName: {}, byType: {} };
+
+ for (const descriptor of store.list(DESCRIPTOR_LIST)) {
+ index.byKey[descriptor.key] = descriptor.key;
+ descriptor.aliases.forEach((alias) => (index.byAlias[alias] = descriptor.key));
+ descriptor.modelNames.forEach((modelName) => (index.byModelName[modelName] = descriptor.key));
+ descriptor.polymorphicTypes.forEach((type) => {
+ index.byType[type] = descriptor.key;
+ index.byType[type.toLowerCase()] = descriptor.key;
+ });
+ }
+
+ store.put(INDEX_LIST, INDEX_KEY, index);
+
+ return index;
+}
+
+function getIndex(owner) {
+ const store = getStore(owner);
+
+ if (!store) {
+ return null;
+ }
+
+ return store.find(INDEX_LIST, INDEX_KEY) ?? rebuildIndex(store);
+}
+
+/**
+ * Registers one descriptor. Returns the stored (normalized) descriptor.
+ */
+export function registerResourceDescriptor(owner, descriptor) {
+ return registerResourceDescriptors(owner, [descriptor])[0] ?? null;
+}
+
+/**
+ * Registers a list of descriptors and rebuilds the lookup indexes once.
+ * A descriptor with the same key replaces the earlier one.
+ */
+export function registerResourceDescriptors(owner, descriptors) {
+ const store = getStore(owner);
+
+ if (!store) {
+ return [];
+ }
+
+ const list = (isArray(descriptors) ? descriptors : [descriptors]).filter((descriptor) => descriptor && typeof descriptor.key === 'string' && descriptor.key.trim() !== '');
+ const normalized = list.map(normalizeDescriptor);
+
+ normalized.forEach((descriptor) => store.put(DESCRIPTOR_LIST, descriptor.key, descriptor));
+ rebuildIndex(store);
+
+ return normalized;
+}
+
+/**
+ * Lets another package supply (or replace) how a resource opens, for example
+ * an engine that owns the page for a core resource.
+ */
+export function setResourceOpener(owner, key, open, options = {}) {
+ const descriptor = getResourceDescriptor(owner, key);
+
+ if (!descriptor) {
+ return false;
+ }
+
+ descriptor.open = open;
+
+ if ('permission' in options) {
+ descriptor.permission = options.permission;
+ }
+
+ if ('canOpen' in options) {
+ descriptor.canOpen = options.canOpen;
+ }
+
+ return true;
+}
+
+/** Every registered descriptor. */
+export function getResourceDescriptors(owner) {
+ const store = getStore(owner);
+
+ return store ? [...store.list(DESCRIPTOR_LIST)] : [];
+}
+
+function normalizeName(raw) {
+ let name = raw.trim();
+
+ if (name.includes('\\')) {
+ name = name.slice(name.lastIndexOf('\\') + 1);
+ }
+
+ if (name.includes(':')) {
+ name = name.slice(name.lastIndexOf(':') + 1);
+ }
+
+ return dasherize(name).toLowerCase();
+}
+
+function lookupName(index, raw) {
+ if (typeof raw !== 'string' || raw.trim() === '') {
+ return null;
+ }
+
+ const exact = raw.trim();
+
+ if (index.byType[exact] || index.byType[exact.toLowerCase()]) {
+ return index.byType[exact] ?? index.byType[exact.toLowerCase()];
+ }
+
+ const name = normalizeName(exact);
+ const direct = index.byKey[name] ?? index.byAlias[name] ?? index.byModelName[name] ?? index.byType[name];
+
+ if (direct) {
+ return direct;
+ }
+
+ for (const prefix of SUBTYPE_PREFIXES) {
+ if (name.startsWith(prefix)) {
+ const stripped = name.slice(prefix.length);
+ const key = index.byKey[stripped] ?? index.byAlias[stripped] ?? index.byModelName[stripped];
+
+ if (key) {
+ return key;
+ }
+ }
+ }
+
+ return null;
+}
+
+function candidateNames(input) {
+ if (typeof input === 'string') {
+ return [input];
+ }
+
+ if (!input || typeof input !== 'object') {
+ return [];
+ }
+
+ const record = input instanceof ObjectProxy ? (input.content ?? input) : input;
+
+ return [get(record, 'constructor.modelName'), get(record, 'resourceType'), get(record, 'resource_type'), get(record, 'modelName')].filter((name) => typeof name === 'string');
+}
+
+/**
+ * Resolves anything that names a resource to its descriptor key, or `null`.
+ * Accepts a record, a stub carrying `resourceType`, a model name or alias,
+ * a `prefix:key` string, a PHP class name, or a polymorphic type string.
+ */
+export function resolveResourceKey(owner, input) {
+ const index = getIndex(owner);
+
+ if (!index) {
+ return null;
+ }
+
+ for (const name of candidateNames(input)) {
+ const key = lookupName(index, name);
+
+ if (key) {
+ return key;
+ }
+ }
+
+ return null;
+}
+
+/** The descriptor for anything `resolveResourceKey` understands, or `null`. */
+export function getResourceDescriptor(owner, input) {
+ const key = resolveResourceKey(owner, input);
+
+ if (!key) {
+ return null;
+ }
+
+ return getStore(owner).find(DESCRIPTOR_LIST, key) ?? null;
+}
+
+/** Whether `owner` can render the named component. */
+export function isComponentResolvable(owner, name) {
+ if (!name) {
+ return false;
+ }
+
+ if (typeof name !== 'string') {
+ return typeof name === 'function' || typeof name === 'object';
+ }
+
+ try {
+ return Boolean(owner?.factoryFor?.(`component:${name}`)) || Boolean(owner?.lookup?.(`template:components/${name}`));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The component name registered for a kind (`pill`, `summary`, `identity`,
+ * `select-option`) of a resource, or `null` when the resource is unknown or
+ * the component cannot be resolved by `owner`.
+ */
+export function resourceComponentName(owner, kind, input) {
+ const field = COMPONENT_KINDS[kind];
+
+ if (!field) {
+ return null;
+ }
+
+ const descriptor = getResourceDescriptor(owner, input);
+ const name = descriptor?.components?.[field];
+
+ if (!name || !isComponentResolvable(owner, name)) {
+ return null;
+ }
+
+ return name;
+}
+
+function unwrapSync(value) {
+ if (value === null || value === undefined) {
+ return null;
+ }
+
+ if (value instanceof ObjectProxy) {
+ return value.content ?? null;
+ }
+
+ if (typeof value === 'object' && typeof value.then === 'function' && 'content' in value) {
+ return value.content ?? null;
+ }
+
+ return value;
+}
+
+/**
+ * Reads a relation without ever returning a promise proxy: the loaded
+ * record (or many-array) for a model, the plain value for a POJO, else null.
+ */
+export function relationValue(record, name) {
+ if (!record || typeof name !== 'string' || name === '') {
+ return null;
+ }
+
+ const target = unwrapSync(record);
+
+ if (!target) {
+ return null;
+ }
+
+ if (typeof target.belongsTo === 'function') {
+ try {
+ const reference = target.belongsTo(name);
+
+ if (reference && typeof reference.value === 'function') {
+ return unwrapSync(reference.value());
+ }
+ } catch {
+ // not a belongsTo on this model; fall through
+ }
+ }
+
+ if (typeof target.hasMany === 'function') {
+ try {
+ const reference = target.hasMany(name);
+
+ if (reference && typeof reference.value === 'function') {
+ return reference.value() ?? null;
+ }
+ } catch {
+ // not a hasMany either; fall through
+ }
+ }
+
+ return unwrapSync(get(target, name));
+}
+
+/** A display identifier, or `null` when the value is blank or a UUID. */
+export function safeIdentifier(value) {
+ if (value === null || value === undefined) {
+ return null;
+ }
+
+ const text = String(value).trim();
+
+ if (text === '' || UUID_PATTERN.test(text)) {
+ return null;
+ }
+
+ return value;
+}
+
+/** Whether the value is a UUID string. */
+export function isUuidValue(value) {
+ return typeof value === 'string' && UUID_PATTERN.test(value.trim());
+}
+
+/**
+ * Resolves proxies, thenables and identity stubs to the record they stand
+ * for. A stub is any object with a `loadResource()` function.
+ */
+export async function unwrapResource(input) {
+ let value = input;
+
+ for (let step = 0; step < 8 && value; step++) {
+ if (typeof value.then === 'function') {
+ value = await value;
+ continue;
+ }
+
+ if (value instanceof ObjectProxy) {
+ value = value.content ?? null;
+ continue;
+ }
+
+ if (typeof value.loadResource === 'function') {
+ const loaded = await value.loadResource();
+
+ return loaded ?? value;
+ }
+
+ break;
+ }
+
+ return value ?? null;
+}
+
+async function loadCanonicalRecord(owner, descriptor, record) {
+ const modelName = get(record, 'constructor.modelName');
+ const canonical = descriptor.modelNames[0];
+
+ if (typeof modelName !== 'string' || modelName === canonical) {
+ return record;
+ }
+
+ const store = lookupService(owner, 'store');
+ const id = get(record, 'id') ?? get(record, 'uuid');
+
+ if (!store || !id) {
+ return record;
+ }
+
+ const peeked = typeof store.peekRecord === 'function' ? store.peekRecord(canonical, id) : null;
+
+ if (peeked) {
+ return peeked;
+ }
+
+ if (typeof store.findRecord === 'function') {
+ return (await store.findRecord(canonical, id)) ?? record;
+ }
+
+ return record;
+}
+
+/**
+ * Whether the resource has an open path the current user may use.
+ * `options.resourceType` names the type of a record that cannot be
+ * resolved on its own.
+ */
+export function canOpenResource(owner, input, options = {}) {
+ const descriptor = getResourceDescriptor(owner, input) ?? getResourceDescriptor(owner, options.resourceType);
+
+ if (!descriptor || typeof descriptor.open !== 'function') {
+ return false;
+ }
+
+ const record = typeof input === 'object' ? unwrapSync(input) : null;
+
+ if (typeof descriptor.canOpen === 'function') {
+ try {
+ if (!descriptor.canOpen(record, owner)) {
+ return false;
+ }
+ } catch {
+ return false;
+ }
+ }
+
+ const permission = typeof descriptor.permission === 'function' ? descriptor.permission(record) : descriptor.permission;
+
+ if (permission) {
+ const abilities = lookupService(owner, 'abilities');
+
+ if (abilities && typeof abilities.can === 'function') {
+ try {
+ return Boolean(abilities.can(permission));
+ } catch {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Opens a resource through its descriptor. Unwraps proxies and stubs, loads
+ * the canonical record for a subtype, and returns `true` only when the
+ * descriptor reported that it handled the open. Never throws.
+ */
+export async function openResource(owner, input, options = {}) {
+ try {
+ const record = await unwrapResource(input);
+
+ if (!record) {
+ return false;
+ }
+
+ const descriptor = getResourceDescriptor(owner, record) ?? getResourceDescriptor(owner, options.resourceType);
+
+ if (!descriptor || typeof descriptor.open !== 'function') {
+ return false;
+ }
+
+ const canonical = await loadCanonicalRecord(owner, descriptor, record);
+ const result = await descriptor.open(canonical, { ...options, owner, event: options.event ?? null });
+
+ return Boolean(result);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Calls a descriptor function field safely: `undefined` when the field is
+ * missing, not a function, or throws.
+ */
+export function readDescriptor(descriptor, field, ...args) {
+ const value = descriptor?.[field];
+
+ if (typeof value !== 'function') {
+ return value;
+ }
+
+ try {
+ return value(...args);
+ } catch {
+ return undefined;
+ }
+}
diff --git a/app/components/category/pill.js b/app/components/category/pill.js
new file mode 100644
index 00000000..d02cd159
--- /dev/null
+++ b/app/components/category/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/category/pill';
diff --git a/app/components/category/summary.js b/app/components/category/summary.js
new file mode 100644
index 00000000..bf25ab66
--- /dev/null
+++ b/app/components/category/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/category/summary';
diff --git a/app/components/company/pill.js b/app/components/company/pill.js
new file mode 100644
index 00000000..faf8aa49
--- /dev/null
+++ b/app/components/company/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/company/pill';
diff --git a/app/components/company/summary.js b/app/components/company/summary.js
new file mode 100644
index 00000000..6d11ed72
--- /dev/null
+++ b/app/components/company/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/company/summary';
diff --git a/app/components/file/pill.js b/app/components/file/pill.js
new file mode 100644
index 00000000..e07b1008
--- /dev/null
+++ b/app/components/file/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/file/pill';
diff --git a/app/components/file/summary.js b/app/components/file/summary.js
new file mode 100644
index 00000000..dc742a07
--- /dev/null
+++ b/app/components/file/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/file/summary';
diff --git a/app/components/filter/model-multiple.js b/app/components/filter/model-multiple.js
new file mode 100644
index 00000000..bd99174a
--- /dev/null
+++ b/app/components/filter/model-multiple.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/filter/model-multiple';
diff --git a/app/components/filter/radio.js b/app/components/filter/radio.js
new file mode 100644
index 00000000..5065b321
--- /dev/null
+++ b/app/components/filter/radio.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/filter/radio';
diff --git a/app/components/group/pill.js b/app/components/group/pill.js
new file mode 100644
index 00000000..e90ad89f
--- /dev/null
+++ b/app/components/group/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/group/pill';
diff --git a/app/components/group/summary.js b/app/components/group/summary.js
new file mode 100644
index 00000000..3819ecd5
--- /dev/null
+++ b/app/components/group/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/group/summary';
diff --git a/app/components/pill/body.js b/app/components/pill/body.js
new file mode 100644
index 00000000..72863aaf
--- /dev/null
+++ b/app/components/pill/body.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/pill/body';
diff --git a/app/components/resource/hover-card.js b/app/components/resource/hover-card.js
new file mode 100644
index 00000000..82c98869
--- /dev/null
+++ b/app/components/resource/hover-card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/resource/hover-card';
diff --git a/app/components/resource/pill.js b/app/components/resource/pill.js
new file mode 100644
index 00000000..641e6310
--- /dev/null
+++ b/app/components/resource/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/resource/pill';
diff --git a/app/components/resource/select-option.js b/app/components/resource/select-option.js
new file mode 100644
index 00000000..ae9d3e22
--- /dev/null
+++ b/app/components/resource/select-option.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/resource/select-option';
diff --git a/app/components/resource/summary.js b/app/components/resource/summary.js
new file mode 100644
index 00000000..b4161682
--- /dev/null
+++ b/app/components/resource/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/resource/summary';
diff --git a/app/components/role/pill.js b/app/components/role/pill.js
new file mode 100644
index 00000000..68382407
--- /dev/null
+++ b/app/components/role/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/role/pill';
diff --git a/app/components/role/summary.js b/app/components/role/summary.js
new file mode 100644
index 00000000..858bc5c7
--- /dev/null
+++ b/app/components/role/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/role/summary';
diff --git a/app/components/select-option/category.js b/app/components/select-option/category.js
new file mode 100644
index 00000000..b8635dcc
--- /dev/null
+++ b/app/components/select-option/category.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/category';
diff --git a/app/components/select-option/company.js b/app/components/select-option/company.js
new file mode 100644
index 00000000..a363e7be
--- /dev/null
+++ b/app/components/select-option/company.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/company';
diff --git a/app/components/select-option/file.js b/app/components/select-option/file.js
new file mode 100644
index 00000000..e9de922f
--- /dev/null
+++ b/app/components/select-option/file.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/file';
diff --git a/app/components/select-option/group.js b/app/components/select-option/group.js
new file mode 100644
index 00000000..7eb2ff19
--- /dev/null
+++ b/app/components/select-option/group.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/group';
diff --git a/app/components/select-option/role.js b/app/components/select-option/role.js
new file mode 100644
index 00000000..e71c2c07
--- /dev/null
+++ b/app/components/select-option/role.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/role';
diff --git a/app/components/select-option/user.js b/app/components/select-option/user.js
new file mode 100644
index 00000000..e5390dde
--- /dev/null
+++ b/app/components/select-option/user.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/select-option/user';
diff --git a/app/components/table/cell/category-identity.js b/app/components/table/cell/category-identity.js
new file mode 100644
index 00000000..05a23f3e
--- /dev/null
+++ b/app/components/table/cell/category-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/category-identity';
diff --git a/app/components/table/cell/company-identity.js b/app/components/table/cell/company-identity.js
new file mode 100644
index 00000000..28495777
--- /dev/null
+++ b/app/components/table/cell/company-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/company-identity';
diff --git a/app/components/table/cell/file-identity.js b/app/components/table/cell/file-identity.js
new file mode 100644
index 00000000..455bbb9c
--- /dev/null
+++ b/app/components/table/cell/file-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/file-identity';
diff --git a/app/components/table/cell/group-identity.js b/app/components/table/cell/group-identity.js
new file mode 100644
index 00000000..de89aec9
--- /dev/null
+++ b/app/components/table/cell/group-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/group-identity';
diff --git a/app/components/table/cell/identity.js b/app/components/table/cell/identity.js
new file mode 100644
index 00000000..95eb1ce9
--- /dev/null
+++ b/app/components/table/cell/identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/identity';
diff --git a/app/components/table/cell/identity/body.js b/app/components/table/cell/identity/body.js
new file mode 100644
index 00000000..0bdf2edd
--- /dev/null
+++ b/app/components/table/cell/identity/body.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/identity/body';
diff --git a/app/components/table/cell/role-identity.js b/app/components/table/cell/role-identity.js
new file mode 100644
index 00000000..dc127182
--- /dev/null
+++ b/app/components/table/cell/role-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/role-identity';
diff --git a/app/components/table/cell/user-identity.js b/app/components/table/cell/user-identity.js
new file mode 100644
index 00000000..d3acc496
--- /dev/null
+++ b/app/components/table/cell/user-identity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/table/cell/user-identity';
diff --git a/app/components/user/summary.js b/app/components/user/summary.js
new file mode 100644
index 00000000..3987ec6f
--- /dev/null
+++ b/app/components/user/summary.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/user/summary';
diff --git a/app/helpers/resource-component.js b/app/helpers/resource-component.js
new file mode 100644
index 00000000..f705c2a3
--- /dev/null
+++ b/app/helpers/resource-component.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/helpers/resource-component';
diff --git a/app/helpers/resource-relation.js b/app/helpers/resource-relation.js
new file mode 100644
index 00000000..0e635fc1
--- /dev/null
+++ b/app/helpers/resource-relation.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/helpers/resource-relation';
diff --git a/app/helpers/resource-type.js b/app/helpers/resource-type.js
new file mode 100644
index 00000000..14334c6b
--- /dev/null
+++ b/app/helpers/resource-type.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/helpers/resource-type';
diff --git a/app/instance-initializers/register-core-resource-descriptors.js b/app/instance-initializers/register-core-resource-descriptors.js
new file mode 100644
index 00000000..2875237d
--- /dev/null
+++ b/app/instance-initializers/register-core-resource-descriptors.js
@@ -0,0 +1 @@
+export { default, initialize } from '@fleetbase/ember-ui/instance-initializers/register-core-resource-descriptors';
diff --git a/app/services/resource-registry.js b/app/services/resource-registry.js
new file mode 100644
index 00000000..031c153d
--- /dev/null
+++ b/app/services/resource-registry.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/services/resource-registry';
diff --git a/app/utils/generate-uuid.js b/app/utils/generate-uuid.js
new file mode 100644
index 00000000..767e3660
--- /dev/null
+++ b/app/utils/generate-uuid.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/utils/generate-uuid';
diff --git a/app/utils/resource-descriptors/core.js b/app/utils/resource-descriptors/core.js
new file mode 100644
index 00000000..2499806d
--- /dev/null
+++ b/app/utils/resource-descriptors/core.js
@@ -0,0 +1,2 @@
+export * from '@fleetbase/ember-ui/utils/resource-descriptors/core';
+export { default } from '@fleetbase/ember-ui/utils/resource-descriptors/core';
diff --git a/app/utils/resource-identity.js b/app/utils/resource-identity.js
new file mode 100644
index 00000000..00c319b2
--- /dev/null
+++ b/app/utils/resource-identity.js
@@ -0,0 +1 @@
+export * from '@fleetbase/ember-ui/utils/resource-identity';
diff --git a/app/utils/resource-registry.js b/app/utils/resource-registry.js
new file mode 100644
index 00000000..92e1ad8b
--- /dev/null
+++ b/app/utils/resource-registry.js
@@ -0,0 +1 @@
+export * from '@fleetbase/ember-ui/utils/resource-registry';
diff --git a/index.js b/index.js
index f3b3b6d2..89c50aaf 100644
--- a/index.js
+++ b/index.js
@@ -4,38 +4,7 @@ const Funnel = require('broccoli-funnel');
const MergeTrees = require('broccoli-merge-trees');
const resolve = require('resolve');
const path = require('path');
-const postcssImport = require('postcss-import');
-const postcssPresetEnv = require('postcss-preset-env');
-const postcssEach = require('postcss-each');
-const postcssMixins = require('postcss-mixins');
-const postcssConditionals = require('postcss-conditionals-renewed');
-const postcssAtRulesVariables = require('postcss-at-rules-variables');
-const autoprefixer = require('autoprefixer');
-const tailwind = require('tailwindcss');
-
-const tailwindConfigPath = path.resolve(__dirname, 'tailwind.config.js');
-const postcssOptions = {
- compile: {
- enabled: true,
- cacheInclude: [/.*\.(css|scss|hbs)$/, /.*\/tailwind\/config\.js$/, /.*tailwind\.js$/],
- plugins: [
- postcssAtRulesVariables,
- postcssImport({
- path: ['node_modules', path.join(__dirname, 'addon/styles')],
- plugins: [postcssAtRulesVariables, postcssImport],
- }),
- postcssMixins,
- postcssPresetEnv({ stage: 1 }),
- postcssEach,
- tailwind(tailwindConfigPath),
- autoprefixer,
- ],
- },
- filter: {
- enabled: true,
- plugins: [postcssAtRulesVariables, postcssMixins, postcssEach, postcssConditionals, tailwind(tailwindConfigPath)],
- },
-};
+const buildPostcssOptions = require('./lib/postcss-options');
module.exports = {
name,
@@ -52,12 +21,11 @@ module.exports = {
excludeJS: true,
excludeImages: true,
},
- postcssOptions,
},
treeForStyles: function () {
- // Only provide styles to the root application, not to engines
- // This prevents engines from trying to compile ember-ui styles
+ // Only expose the app styles tree to the root application. Addon styles
+ // are compiled separately by Ember's treeForAddon/compileStyles hooks.
let parent = this.parent;
while (parent) {
const isEngine = parent.lazyLoading === true || (parent.lazyLoading && parent.lazyLoading.enabled === true);
@@ -73,16 +41,18 @@ module.exports = {
},
included: function (app) {
+ // Configure the addon's compiler before its ember-cli-postcss child is
+ // included, including when this instance belongs to an engine.
+ const postcssOptions = buildPostcssOptions(this.project.targets && this.project.targets.browsers);
+ this.options.postcssOptions = postcssOptions;
this._super.included.apply(this, arguments);
- // Check if we're being included by an engine
- // If so, skip setting postcssOptions to prevent engines from trying to compile our styles
+ // Nested engine instances configure their own compiler above, but must
+ // not overwrite host options or import the shared vendor assets again.
let parent = this.parent;
while (parent) {
const isEngine = parent.lazyLoading === true || (parent.lazyLoading && parent.lazyLoading.enabled === true);
if (isEngine) {
- // We're in an engine - don't set postcssOptions
- // The engine will inherit from the host app
return;
}
parent = parent.parent;
diff --git a/lib/postcss-options.js b/lib/postcss-options.js
new file mode 100644
index 00000000..54700538
--- /dev/null
+++ b/lib/postcss-options.js
@@ -0,0 +1,45 @@
+/* eslint-env node */
+'use strict';
+
+const path = require('path');
+const postcssImport = require('postcss-import');
+const postcssPresetEnv = require('postcss-preset-env');
+const postcssEach = require('postcss-each');
+const postcssMixins = require('postcss-mixins');
+const postcssConditionals = require('postcss-conditionals-renewed');
+const postcssAtRulesVariables = require('postcss-at-rules-variables');
+const autoprefixer = require('autoprefixer');
+const tailwind = require('tailwindcss');
+
+module.exports = function buildPostcssOptions(browsers) {
+ const tailwindConfigPath = path.resolve(__dirname, '../tailwind.config.js');
+
+ return {
+ compile: {
+ enabled: true,
+ // The host tree contains compiled addon templates as JavaScript.
+ // Include them so new utility classes invalidate the CSS cache.
+ cacheInclude: [/.*\.(css|scss|hbs|js)$/],
+ plugins: [
+ postcssAtRulesVariables,
+ postcssImport({
+ path: ['node_modules', path.resolve(__dirname, '../addon/styles')],
+ plugins: [postcssAtRulesVariables, postcssImport],
+ }),
+ postcssMixins,
+ // Ember CLI's targets are not automatically forwarded to an
+ // instantiated preset. Avoid polyfilling selectors the host supports.
+ postcssPresetEnv({ stage: 1, browsers, autoprefixer: false }),
+ postcssEach,
+ tailwind(tailwindConfigPath),
+ autoprefixer({ overrideBrowserslist: browsers }),
+ ],
+ },
+ filter: {
+ enabled: true,
+ // Conditionals must run after compile has expanded loop variables
+ // and mixins. Tailwind and the expanders have already finished.
+ plugins: [postcssConditionals],
+ },
+ };
+};
diff --git a/lib/tailwind-content.js b/lib/tailwind-content.js
new file mode 100644
index 00000000..3d94ea48
--- /dev/null
+++ b/lib/tailwind-content.js
@@ -0,0 +1,43 @@
+/* eslint-env node */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+function entries(directory) {
+ try {
+ return fs.readdirSync(directory);
+ } catch (error) {
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
+ return [];
+ }
+ throw error;
+ }
+}
+
+// Resolve only the consumer's direct packages. Recursive node_modules globs follow
+// pnpm links into dependency graphs and linked packages' development worktrees.
+module.exports = function addonContent(root = process.cwd()) {
+ const modules = path.resolve(root, 'node_modules');
+ const packages = entries(modules).flatMap((name) => (name.startsWith('@') ? entries(path.join(modules, name)).map((child) => `${name}/${child}`) : [name]));
+ const roots = new Set();
+
+ for (const name of packages) {
+ if (!name.startsWith('@fleetbase/') && !name.endsWith('-engine')) {
+ continue;
+ }
+ const addon = path.join(modules, name, 'addon');
+ if (fs.existsSync(addon)) {
+ roots.add(fs.realpathSync(addon));
+ }
+ }
+
+ // Keep globs within each source tree so newly created templates are watched.
+ return [...roots].sort().map(
+ (directory) =>
+ `${directory
+ .split(path.sep)
+ .join('/')
+ .replace(/[\\!*?{}()[\]]/g, '\\$&')}/**/*.{hbs,js}`
+ );
+};
diff --git a/package.json b/package.json
index f990bb14..56736976 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ember-ui",
- "version": "0.4.0",
+ "version": "0.4.1",
"description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.",
"keywords": [
"fleetbase-ui",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b382bc2f..dc800fb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10214,7 +10214,7 @@ snapshots:
'@types/glob@9.0.0':
dependencies:
- glob: 8.1.0
+ glob: 13.0.6
'@types/http-errors@2.0.5': {}
diff --git a/scripts/check-tailwind-content.js b/scripts/check-tailwind-content.js
new file mode 100644
index 00000000..2d1d8e56
--- /dev/null
+++ b/scripts/check-tailwind-content.js
@@ -0,0 +1,93 @@
+/* eslint-env node */
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const vm = require('node:vm');
+const { createRequire } = require('node:module');
+const glob = createRequire(require.resolve('tailwindcss'))('fast-glob');
+
+// Accept another independently packaged configuration to run the same contract
+// against console without making console depend on a new ember-ui release.
+const configPath = path.resolve(process.argv[2] || path.join(__dirname, '../tailwind.config.js'));
+const configRequire = createRequire(configPath);
+const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tailwind-content-')));
+const root = path.join(temporary, 'host [fixture]');
+const originalCwd = process.cwd();
+
+function write(file, text = '
') {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, text);
+}
+
+function link(name, packageRoot) {
+ const target = path.join(root, 'node_modules', name);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.symlinkSync(packageRoot, target, 'dir');
+}
+
+try {
+ write(path.join(root, 'app', 'host.hbs'));
+ const registry = path.join(root, 'node_modules/.pnpm/@fleetbase+example@1.0.0/node_modules/@fleetbase/example');
+ const linked = path.join(temporary, 'linked-package');
+ const scoped = path.join(temporary, 'scoped-engine');
+ const unscoped = path.join(temporary, 'unscoped-engine');
+ const ignored = path.join(temporary, 'unrelated-package');
+
+ for (const packageRoot of [registry, linked, scoped, unscoped, ignored]) {
+ write(path.join(packageRoot, 'addon', 'component.hbs'));
+ write(path.join(packageRoot, 'node_modules/nested-engine/addon', 'must-not-scan.hbs'));
+ write(path.join(packageRoot, '.claude/worktrees/example/addon', 'must-not-scan.hbs'));
+ write(path.join(packageRoot, 'tests/dummy/app', 'must-not-scan.hbs'));
+ }
+ link('@fleetbase/example', registry);
+ link('@fleetbase/linked', linked);
+ link('@fleetbase/linked-alias', linked);
+ link('@partner/example-engine', scoped);
+ link('example-engine', unscoped);
+ link('@partner/unrelated', ignored);
+ // Model the cyclic dependency links that broad node_modules globs follow.
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(linked, 'node_modules', 'cycle'), 'dir');
+ process.chdir(root);
+
+ const context = {
+ module: { exports: {} },
+ __dirname: root,
+ require(name) {
+ return name === '@tailwindcss/forms' ? () => {} : configRequire(name);
+ },
+ };
+ vm.runInNewContext(fs.readFileSync(configPath, 'utf8'), context, { filename: configPath });
+ const content = context.module.exports.content;
+ const patterns = Array.from(Array.isArray(content) ? content : content.files);
+ const expected = [path.join(root, 'app/host.hbs'), ...[registry, linked, scoped, unscoped].map((packageRoot) => path.join(packageRoot, 'addon/component.hbs'))].sort();
+ const visited = [];
+ const scan = () =>
+ glob
+ .sync(patterns, {
+ cwd: root,
+ absolute: true,
+ fs: {
+ ...fs,
+ readdirSync(directory, options) {
+ visited.push(directory);
+ assert(visited.length < 100, 'content scanning escaped the source trees');
+ assert(!directory.includes('/.claude/'), 'content scanning entered a development worktree');
+ assert(!directory.includes('/node_modules/nested-engine'), 'content scanning entered a nested dependency');
+ return fs.readdirSync(directory, options);
+ },
+ },
+ })
+ .sort();
+
+ assert.deepEqual(scan(), expected, 'include host, linked, registry, scoped and unscoped engines exactly once');
+ write(path.join(linked, 'addon/new-template.hbs'));
+ expected.push(path.join(linked, 'addon/new-template.hbs'));
+ assert.deepEqual(scan(), expected.sort(), 'existing patterns must discover newly created templates');
+ console.log(`PASS ${configPath}: linked/registry coverage, deduplication, bounded traversal and new template discovery`);
+} finally {
+ process.chdir(originalCwd);
+ fs.rmSync(temporary, { recursive: true, force: true });
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 94b914c0..d729e014 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -5,16 +5,9 @@ module.exports = {
'./app/**/*.{hbs,js}',
'./addon/**/*.{hbs,js}',
'./addon/**/*.css',
- // All Fleetbase packages (both pnpm symlinked and normal installs)
- './node_modules/@fleetbase+*/**/addon/**/*.{hbs,js}',
- './node_modules/@fleetbase/**/addon/**/*.{hbs,js}',
- // Explicit ember-ui paths
- './node_modules/@fleetbase/ember-ui/addon/templates/**/*.{hbs,js}',
- './node_modules/@fleetbase/ember-ui/addon/components/**/*.{hbs,js}',
- // Explicit support for .pnpm paths (for unhoisted deps)
- './node_modules/.pnpm/@fleetbase+*/**/addon/**/*.{hbs,js}',
- // Engine packages
- './node_modules/**/*-engine/addon/**/*.{hbs,js}',
+ // The addon also configures its host application, so resolve installed
+ // packages from the build cwd, matching the local app/addon paths above.
+ ...require('./lib/tailwind-content')(),
],
safelist: [
{
diff --git a/tests/dummy/app/services/universe/registry-service.js b/tests/dummy/app/services/universe/registry-service.js
new file mode 100644
index 00000000..4e8db2be
--- /dev/null
+++ b/tests/dummy/app/services/universe/registry-service.js
@@ -0,0 +1,66 @@
+import Service from '@ember/service';
+import { A } from '@ember/array';
+
+/**
+ * Stub of the host console's `universe/registry-service`, with the section /
+ * list / key storage the addon relies on. The real service lives in
+ * ember-core and needs the console's extension loader to boot.
+ */
+export default class UniverseRegistryService extends Service {
+ calls = [];
+ registries = new Map();
+
+ /** Map of registryName -> renderable components array. */
+ renderableComponents = {};
+
+ getOrCreateList(sectionName, listName) {
+ if (!this.registries.has(sectionName)) {
+ this.registries.set(sectionName, {});
+ }
+
+ const section = this.registries.get(sectionName);
+
+ if (!section[listName]) {
+ section[listName] = A([]);
+ }
+
+ return section[listName];
+ }
+
+ register(sectionName, listName, key, value) {
+ this.calls.push({ method: 'register', args: [sectionName, listName, key] });
+
+ const list = this.getOrCreateList(sectionName, listName);
+
+ if (typeof value === 'object' && value !== null) {
+ value._registryKey = key;
+ }
+
+ const existing = list.find((item) => item && item._registryKey === key);
+
+ if (existing) {
+ list.replace(list.indexOf(existing), 1, [value]);
+ } else {
+ list.pushObject(value);
+ }
+ }
+
+ getRegistry(sectionName, listName) {
+ const section = this.registries.get(sectionName);
+
+ return section?.[listName] ?? A([]);
+ }
+
+ lookup(sectionName, listName, key) {
+ return this.getRegistry(sectionName, listName).find((item) => item && item._registryKey === key) ?? null;
+ }
+
+ createRegistry(sectionName, type = 'menu-item') {
+ return this.getOrCreateList(sectionName, type);
+ }
+
+ getRenderableComponents(registryName) {
+ this.calls.push({ method: 'getRenderableComponents', args: [registryName] });
+ return this.renderableComponents[registryName] ?? [];
+ }
+}
diff --git a/tests/integration/components/attach/popover-test.js b/tests/integration/components/attach/popover-test.js
index b7318e6f..558ad11c 100644
--- a/tests/integration/components/attach/popover-test.js
+++ b/tests/integration/components/attach/popover-test.js
@@ -1,26 +1,113 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, triggerEvent, triggerKeyEvent, settled, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+function attacher() {
+ return document.querySelector('.ember-attacher');
+}
+
+function content() {
+ return document.querySelector('.ember-attacher > div');
+}
+
+async function waitForShown() {
+ await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'false', { timeout: 2000 });
+ await settled();
+}
+
+async function waitForHidden() {
+ await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'true', { timeout: 2000 });
+ await settled();
+}
+
module('Integration | Component | attach/popover', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ test('it renders its block once shown', async function (assert) {
+ await render(hbs`Target popover text `);
+ await waitForShown();
+
+ assert.dom(attacher()).includesText('popover text');
+ });
- await render(hbs` `);
+ test('it applies @class to the floating element', async function (assert) {
+ await render(hbs`Target x `);
+ await waitForShown();
- assert.dom(this.element).hasText('');
+ assert.dom(attacher()).hasClass('ember-attacher');
+ assert.dom(attacher()).hasClass('resource-hover-card');
+ assert.dom(content()).hasClass('inner-class');
+ assert.dom(content()).doesNotHaveClass('resource-hover-card');
+ });
- // Template block usage:
+ test('an interactive popover opened by hover accepts pointer events and can be entered', async function (assert) {
await render(hbs`
-
- template block text
-
- `);
+ Target
+
+ inside
+
+
+ `);
+
+ assert.notOk(attacher()?.getAttribute('aria-hidden') === 'false', 'hidden before hover');
+
+ await triggerEvent('#target', 'mouseenter');
+ await waitForShown();
+
+ assert.strictEqual(getComputedStyle(content()).pointerEvents, 'auto', 'the content accepts pointer events while shown');
+ assert.strictEqual(attacher().style.pointerEvents, 'auto', 'the floating element accepts pointer events while shown');
+
+ // Leave the target and move onto the popover: it must stay open.
+ await triggerEvent('#target', 'mouseleave');
+ await triggerEvent('#inside', 'mousemove');
+ assert.strictEqual(attacher().getAttribute('aria-hidden'), 'false', 'still shown while the pointer is over the popover');
+
+ // Move away from both: it hides, and pointer events go back to none.
+ await triggerEvent(document.body, 'mousemove');
+ await waitForHidden();
+ assert.strictEqual(getComputedStyle(content()).pointerEvents, 'none', 'transparent to the pointer once hidden');
+ assert.strictEqual(attacher().style.pointerEvents, 'none');
+ });
+
+ test('a non-interactive popover stays transparent to the pointer while shown', async function (assert) {
+ await render(hbs`Target x `);
+ await waitForShown();
+
+ assert.strictEqual(getComputedStyle(content()).pointerEvents, 'none');
+ assert.strictEqual(attacher().style.pointerEvents, 'none');
+ });
+
+ test('it hides on escape and follows later changes to @isShown', async function (assert) {
+ this.set('shown', false);
+ await render(
+ hbs`Target x `
+ );
+
+ assert.notOk(attacher(), 'lazy popover renders nothing until it is shown');
+
+ this.set('shown', true);
+ await waitForShown();
+ assert.strictEqual(attacher().getAttribute('aria-hidden'), 'false', 'shown after @isShown becomes true');
+
+ this.set('shown', false);
+ await waitForHidden();
+ assert.strictEqual(attacher().getAttribute('aria-hidden'), 'true', 'hidden after @isShown becomes false');
+
+ this.set('shown', true);
+ await waitForShown();
+ await triggerKeyEvent(document, 'keydown', 'Escape');
+ await waitForHidden();
+ assert.strictEqual(attacher().getAttribute('aria-hidden'), 'true', 'hidden after escape');
+ });
+
+ test('setting @isShown to false before anything rendered is a no-op', async function (assert) {
+ this.set('shown', undefined);
+ await render(hbs`Target x `);
+
+ this.set('shown', false);
+ await settled();
- assert.dom(this.element).hasText('template block text');
+ assert.notOk(attacher(), 'nothing rendered and nothing thrown');
});
});
diff --git a/tests/integration/components/attach/tooltip-test.js b/tests/integration/components/attach/tooltip-test.js
index 262d179d..b3258f82 100644
--- a/tests/integration/components/attach/tooltip-test.js
+++ b/tests/integration/components/attach/tooltip-test.js
@@ -1,26 +1,74 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { find, render, settled, triggerEvent, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+function attacher() {
+ return document.querySelector('.ember-attacher');
+}
+
+async function waitForShown() {
+ await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'false', { timeout: 2000 });
+ await settled();
+}
+
module('Integration | Component | attach/tooltip', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ test('it renders its block as a tooltip', async function (assert) {
+ await render(hbs`Target tooltip text `);
+ await waitForShown();
- await render(hbs` `);
+ assert.dom(attacher()).includesText('tooltip text');
+ assert.dom('.ember-attacher > div').hasClass('ember-attacher-tooltip');
+ assert.dom(attacher()).hasAttribute('role', 'tooltip');
+ });
- assert.dom(this.element).hasText('');
+ test('it forwards class, style, delays, target, container and onChange to the popover', async function (assert) {
+ const changes = [];
+ this.set('onChange', (visible) => changes.push(visible));
+ await render(hbs`
+
+ Target
+
+ `);
+ await waitForShown();
- // Template block usage:
+ assert.dom(attacher()).hasClass('clean');
+ assert.ok(document.querySelector('.ember-attacher > div').getAttribute('style').includes('min-width: 123px'), 'style is applied');
+ assert.ok(document.querySelector('#container .ember-attacher'), 'rendered into the requested container');
+ assert.deepEqual(changes, [true], 'onChange reported the tooltip becoming visible');
+ });
+
+ test('it targets the element given as @floatingTarget', async function (assert) {
+ this.set('target', null);
await render(hbs`
-
- template block text
-
- `);
+ Target
+
+ {{#if this.target}}
+
targeted
+ {{/if}}
+
+ `);
+ this.set('target', find('#target'));
+ await settled();
+
+ await triggerEvent('#target', 'mouseenter');
+ await waitForShown();
- assert.dom(this.element).hasText('template block text');
+ assert.dom(attacher()).includesText('targeted');
});
});
diff --git a/tests/integration/components/filter/model-multiple-test.js b/tests/integration/components/filter/model-multiple-test.js
new file mode 100644
index 00000000..f413d46f
--- /dev/null
+++ b/tests/integration/components/filter/model-multiple-test.js
@@ -0,0 +1,113 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import Service from '@ember/service';
+import { A } from '@ember/array';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
+import { registerResourceDescriptor } from '@fleetbase/ember-ui/utils/resource-registry';
+
+const RECORDS = [
+ { id: 'a', name: 'Ada' },
+ { id: 'b', name: 'Bob' },
+ { id: 'c', name: 'Cy' },
+];
+
+class StoreStub extends Service {
+ peeked = [];
+ found = [];
+
+ query() {
+ return Promise.resolve(A(RECORDS.slice()));
+ }
+
+ peekRecord(modelName, id) {
+ this.peeked.push(id);
+ return id === 'a' ? RECORDS[0] : null;
+ }
+
+ findRecord(modelName, id) {
+ this.found.push(id);
+
+ if (id === 'gone') {
+ return Promise.reject(new Error('not found'));
+ }
+
+ return Promise.resolve(RECORDS.find((record) => record.id === id) ?? null);
+ }
+}
+
+module('Integration | Component | filter/model-multiple', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:store', StoreStub);
+ this.owner.register('template:components/select-option/person', hbs`{{@option.name}} `);
+ });
+
+ test('it restores chips from a comma-separated id value, peeking before fetching', async function (assert) {
+ this.set('filter', { model: 'person' });
+
+ await render(hbs` `);
+
+ const store = this.owner.lookup('service:store');
+ assert.deepEqual(store.peeked, ['a', 'b', 'gone']);
+ assert.deepEqual(store.found, ['b', 'gone'], 'only ids that were not in the store are fetched');
+ assert.dom('.ember-power-select-multiple-option').exists({ count: 2 }, 'an id that no longer resolves drops out');
+ assert.dom('.ember-power-select-multiple-option').includesText('Ada');
+ });
+
+ test('an array value and an empty value are accepted', async function (assert) {
+ this.set('filter', { model: 'person' });
+ this.set('value', ['a', null]);
+
+ await render(hbs` `);
+ assert.dom('.ember-power-select-multiple-option').exists({ count: 1 });
+
+ this.set('value', '');
+ await render(hbs` `);
+ assert.dom('.ember-power-select-multiple-option').doesNotExist();
+
+ this.set('filter', {});
+ await render(hbs` `);
+ assert.dom('.ember-power-select-multiple-option').doesNotExist('no model name means nothing to restore');
+ });
+
+ test('it emits joined ids, reports clearing, and renders options with the registered select-option', async function (assert) {
+ registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] });
+ const changes = [];
+ const clears = [];
+ this.set('filter', { model: 'person' });
+ this.set('onChange', (filter, value) => changes.push(value));
+ this.set('onClear', (filter) => clears.push(filter));
+
+ await render(hbs` `);
+ await clickTrigger('.ember-model-select');
+ assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 3 });
+
+ await click('.ember-power-select-option');
+ await clickTrigger('.ember-model-select');
+ await click('.ember-power-select-option:nth-child(2)');
+ assert.deepEqual(changes, ['a', 'a,b']);
+ assert.dom('.ember-power-select-multiple-option [data-test-person-option][data-compact="true"]').exists({ count: 2 });
+
+ await click('.clear-button');
+ assert.deepEqual(clears, [this.filter]);
+ assert.dom('.ember-power-select-multiple-option').doesNotExist();
+ assert.dom('.clear-button').isDisabled();
+ });
+
+ test('removing the last chip reports a clear instead of an empty change', async function (assert) {
+ const changes = [];
+ const clears = [];
+ this.set('filter', { model: 'person' });
+ this.set('onChange', (filter, value) => changes.push(value));
+ this.set('onClear', (filter) => clears.push(filter));
+
+ await render(hbs` `);
+ await click('.ember-power-select-multiple-remove-btn');
+
+ assert.deepEqual(changes, []);
+ assert.deepEqual(clears, [this.filter]);
+ });
+});
diff --git a/tests/integration/components/filter/model-test.js b/tests/integration/components/filter/model-test.js
index 96c80809..d2c4c2fb 100644
--- a/tests/integration/components/filter/model-test.js
+++ b/tests/integration/components/filter/model-test.js
@@ -1,26 +1,91 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+import Service from '@ember/service';
+import { A } from '@ember/array';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
+import { registerResourceDescriptor } from '@fleetbase/ember-ui/utils/resource-registry';
+
+const RECORDS = [
+ { id: 'a', name: 'Ada', phone: '+1' },
+ { id: 'b', name: 'Bob', phone: '+2' },
+];
+
+class StoreStub extends Service {
+ query() {
+ return Promise.resolve(A(RECORDS.slice()));
+ }
+
+ peekRecord(modelName, id) {
+ return RECORDS.find((record) => record.id === id) ?? null;
+ }
+
+ findRecord(modelName, id) {
+ return Promise.resolve(this.peekRecord(modelName, id));
+ }
+}
module('Integration | Component | filter/model', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ hooks.beforeEach(function () {
+ this.owner.register('service:store', StoreStub);
+ this.owner.register('template:components/select-option/person', hbs`{{@option.name}} `);
+ this.owner.register('template:components/custom-option', hbs`{{@option.name}}! `);
+ });
- await render(hbs` `);
+ test('it renders options and the trigger with the select-option registered for the model', async function (assert) {
+ assert.expect(6);
+ registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] });
+ this.set('filter', { model: 'person', filterValue: null });
+ this.set('onChange', (filter, value) => {
+ assert.strictEqual(filter, this.filter);
+ assert.strictEqual(value, 'a', 'the id is emitted');
+ });
+
+ await render(hbs` `);
+ await clickTrigger('.ember-model-select');
+
+ assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 2 });
+ assert.dom('.ember-power-select-option [data-test-person-option]').doesNotHaveAttribute('data-compact');
+
+ await click('.ember-power-select-option');
+ assert.dom('.ember-power-select-trigger [data-test-person-option]').hasText('Ada');
+ assert.dom('.ember-power-select-trigger [data-test-person-option]').hasAttribute('data-compact', 'true', 'the trigger shows the compact variant');
+ });
+
+ test('a column may name its own option component', async function (assert) {
+ registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] });
+ this.set('filter', { model: 'person', filterOptionComponent: 'custom-option' });
+
+ await render(hbs` `);
+ await clickTrigger('.ember-model-select');
+
+ assert.dom('.ember-power-select-option [data-test-custom-option]').exists({ count: 2 });
+ assert.dom('.ember-power-select-option [data-test-person-option]').doesNotExist();
+ });
+
+ test('it falls back to the model name path when no select-option exists', async function (assert) {
+ this.set('filter', { model: 'unknown-thing', modelNamePath: 'phone' });
+
+ await render(hbs` `);
+ await clickTrigger('.ember-model-select');
+
+ assert.dom('.ember-power-select-option').exists({ count: 2 });
+ assert.dom('.ember-power-select-option').hasText('+1');
+ });
- assert.dom(this.element).hasText('');
+ test('clearing reports the filter', async function (assert) {
+ assert.expect(2);
+ this.set('filter', { model: 'unknown-thing' });
+ this.set('onClear', (filter) => assert.strictEqual(filter, this.filter));
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
+ await render(hbs` `);
+ await clickTrigger('.ember-model-select');
+ await click('.ember-power-select-option');
+ assert.dom('.ember-power-select-selected-item').hasText('Ada');
- assert.dom(this.element).hasText('template block text');
+ await click('.ember-power-select-clear-btn');
});
});
diff --git a/tests/integration/components/multi-select-test.js b/tests/integration/components/multi-select-test.js
index 495402c3..bebd0e46 100644
--- a/tests/integration/components/multi-select-test.js
+++ b/tests/integration/components/multi-select-test.js
@@ -1,26 +1,54 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
module('Integration | Component | multi-select', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ hooks.beforeEach(function () {
+ this.set('noop', () => {});
+ });
+
+ test('it renders options and forwards the selected item component', async function (assert) {
+ this.owner.register('template:components/test-selected', hbs`{{@option.name}} `);
+ this.set('options', [{ name: 'Ada' }, { name: 'Bob' }]);
+ this.set('selected', [this.options[0]]);
+
+ await render(hbs`
+
+ {{option.name}}
+
+ `);
- await render(hbs` `);
+ assert.dom('[data-test-selected]').hasText('Ada');
+ });
- assert.dom(this.element).hasText('');
+ test('it forwards the search field, position and placeholder', async function (assert) {
+ this.set('options', [{ name: 'Ada' }, { name: 'Bob' }]);
+ this.set('selected', []);
- // Template block usage:
await render(hbs`
-
- template block text
-
- `);
+
+ {{option.name}}
+
+ `);
+
+ await clickTrigger();
+ assert.dom('.ember-power-select-search-input').hasAttribute('placeholder', 'Find a person');
- assert.dom(this.element).hasText('template block text');
+ await fillIn('.ember-power-select-search-input', 'bo');
+ assert.dom('.ember-power-select-option').exists({ count: 1 });
+ assert.dom('.ember-power-select-option').hasText('Bob');
});
});
diff --git a/tests/integration/components/pill-test.js b/tests/integration/components/pill-test.js
index 39162d30..9d8315e7 100644
--- a/tests/integration/components/pill-test.js
+++ b/tests/integration/components/pill-test.js
@@ -1,26 +1,121 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | pill', function (hooks) {
setupRenderingTest(hooks);
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
+ test('it renders the resource name, subtitle and image', async function (assert) {
+ this.set('resource', { name: 'Ada Driver', photo_url: 'https://example.test/ada.png' });
+
+ await render(hbs` `);
+
+ assert.dom('.fleetbase-pill').includesText('Ada Driver');
+ assert.dom('.fleetbase-pill').includesText('+1 555');
+ assert.dom('.fleetbase-pill img').hasAttribute('src', 'https://example.test/ada.png');
+ });
+
+ test('it falls back through display_name, displayName, tracking, public_id and the title fallback', async function (assert) {
+ this.set('resource', { display_name: 'Truck 1' });
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('Truck 1');
+
+ this.set('resource', { displayName: 'Truck 2' });
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('Truck 2');
+
+ this.set('resource', { tracking: 'TRK-3' });
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('TRK-3');
+
+ this.set('resource', { public_id: 'vehicle_4' });
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('vehicle_4');
+
+ this.set('resource', null);
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('No vehicle');
await render(hbs` `);
+ assert.dom('.fleetbase-pill').includesText('-');
+ });
+
+ test('it is a link only when given something to do', async function (assert) {
+ assert.expect(7);
+
+ this.set('resource', { name: 'Ada' });
+ this.set('onClick', (resource, event) => {
+ assert.strictEqual(resource, this.resource, 'the resource is passed first');
+ assert.ok(event instanceof Event, 'followed by the event');
+ });
+
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill > a[href]').exists('clickable pills render an anchor');
+ assert.dom('.fleetbase-pill').doesNotHaveClass('fleetbase-pill--static');
+ await click('.fleetbase-pill > a');
- assert.dom().hasText('');
+ await render(hbs` `);
+ assert.dom('.fleetbase-pill > a').doesNotExist('a pill with nothing to do is not a link');
+ assert.dom('.fleetbase-pill').hasClass('fleetbase-pill--static');
+ assert.dom('[data-test-pill-static]').includesText('Ada');
+ });
+
+ test('it calls onClick without a resource argument when there is no resource', async function (assert) {
+ assert.expect(1);
+ this.set('onClick', (first) => assert.ok(first instanceof Event, 'the event is the first argument'));
+
+ await render(hbs` `);
+ await click('.fleetbase-pill > a');
+ });
+
+ test('it passes the resource to the tooltip component', async function (assert) {
+ this.owner.register('template:components/test-tip', hbs`Tip for {{@resource.name}} `);
+ this.set('resource', { name: 'Ada' });
+
+ await render(hbs` `);
+
+ assert.dom('[data-test-tip]', document.body).hasText('Tip for Ada');
+ });
+
+ test('it yields the resource to the image, default and tooltip blocks', async function (assert) {
+ this.set('resource', { name: 'Ada' });
- // Template block usage:
await render(hbs`
-
- template block text
-
- `);
+
+ <:image as |r|>{{r.name}}
+ <:default as |r|>{{r.name}}
+ <:tooltip as |r|>{{r.name}}
+
+ `);
+
+ assert.dom('[data-test-image-block]').hasText('Ada');
+ assert.dom('[data-test-default-block]').hasText('Ada');
+ assert.dom('.fleetbase-pill img').doesNotExist('the image block replaces the default image');
+ assert.dom('[data-test-tooltip-block]', document.body).hasText('Ada');
+ });
+
+ test('it renders no tooltip when @noTooltip is set', async function (assert) {
+ this.set('resource', { name: 'Ada' });
+
+ await render(hbs`<:tooltip>never `);
+
+ assert.dom('.ember-attacher', document.body).doesNotExist();
+ });
+
+ test('the online indicator reads @online first, then the online path on the resource', async function (assert) {
+ this.set('resource', { name: 'Ada', online: false, is_online: true });
+
+ await render(hbs` `);
+ assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'offline by the default path');
+
+ await render(hbs` `);
+ assert.dom('[data-test-pill-online-indicator]').hasClass('text-green-500', 'online by a custom path');
+
+ await render(hbs` `);
+ assert.dom('[data-test-pill-online-indicator]').hasClass('text-green-500', '@online wins');
- assert.dom().hasText('template block text');
+ await render(hbs` `);
+ assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'no resource means offline');
});
});
diff --git a/tests/integration/components/resource/smoke-test.js b/tests/integration/components/resource/smoke-test.js
new file mode 100644
index 00000000..241f9381
--- /dev/null
+++ b/tests/integration/components/resource/smoke-test.js
@@ -0,0 +1,74 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click, triggerEvent, settled, waitUntil } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { registerResourceDescriptors } from '@fleetbase/ember-ui/utils/resource-registry';
+import buildCoreResourceDescriptors from '@fleetbase/ember-ui/utils/resource-descriptors/core';
+
+module('Integration | Component | resource identity smoke', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ registerResourceDescriptors(this.owner, buildCoreResourceDescriptors(this.owner));
+ this.set('user', { resourceType: 'user', name: 'Ada Lovelace', email: 'ada@example.test', phone: '+1', is_online: true, status: 'active', role_name: 'Admin' });
+ this.set('file', { resourceType: 'file', original_filename: 'report.pdf', content_type: 'application/pdf', file_size: 2048, url: 'https://example.test/report.pdf' });
+ });
+
+ test('the user family renders a pill, a summary, a select option and an identity cell', async function (assert) {
+ await render(hbs`
+
+
+
+
+
+ `);
+
+ assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('Ada Lovelace');
+ assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('ada@example.test');
+ assert.dom('[data-test-resource-pill][data-resource-type="user"] [data-test-pill-online-indicator]').hasClass('text-green-500');
+ assert.dom('[data-test-resource-pill][data-resource-type="user"]').hasClass('fleetbase-pill--static', 'no opener registered, so static');
+ assert.dom('[data-test-resource-summary-title]').hasText('Ada Lovelace');
+ assert.dom('[data-test-resource-summary-fact]').exists();
+ assert.dom('[data-test-resource-summary-view]').doesNotExist();
+ assert.dom('[data-test-select-option]').hasClass('select-option--compact');
+ assert.dom('[data-test-select-option-title]').hasText('Ada Lovelace');
+ assert.dom('[data-test-identity-cell][data-test-identity-static]').exists();
+ assert.dom('[data-test-identity-label]').hasText('Ada Lovelace');
+ assert.dom('[data-test-resource-identity-meta-badge]').doesNotExist('core families carry no inline badges');
+ assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500');
+ assert.dom('[data-test-resource-pill][data-resource-type="file"] a[href]').exists('a file with a url opens');
+ assert.dom('[data-test-resource-pill][data-resource-type="file"] [data-test-resource-pill-icon]').exists('non-image files get an icon tile');
+ });
+
+ test('hovering a pill arms and shows the summary card, which can be entered', async function (assert) {
+ await render(hbs` `);
+ assert.dom('.resource-hover-card', document.body).doesNotExist('nothing rendered before hover');
+
+ await triggerEvent('.fleetbase-pill', 'mouseenter');
+ await waitUntil(() => document.querySelector('.resource-hover-card')?.getAttribute('aria-hidden') === 'false', { timeout: 3000 });
+ await settled();
+ assert.dom('.resource-hover-card [data-test-resource-summary-title]', document.body).hasText('Ada Lovelace');
+
+ await triggerEvent('.fleetbase-pill', 'mouseleave');
+ await triggerEvent('.resource-hover-card [data-test-resource-summary]', 'mousemove');
+
+ assert.strictEqual(document.querySelector('.resource-hover-card').getAttribute('aria-hidden'), 'false', 'stays open over the card');
+
+ await triggerEvent(document.body, 'mousemove');
+ await waitUntil(() => !document.querySelector('.resource-hover-card'), { timeout: 3000 });
+ assert.dom('.resource-hover-card', document.body).doesNotExist('torn down after leaving');
+ });
+
+ test('an opener makes the pill and the cell clickable', async function (assert) {
+ const opened = [];
+ const registry = this.owner.lookup('service:resource-registry');
+ registry.setOpener('user', (record) => opened.push(record) && true);
+
+ await render(hbs` `);
+ await click('[data-test-resource-pill] a');
+ await click('[data-test-identity-button]');
+
+ assert.strictEqual(opened.length, 2);
+ assert.strictEqual(opened[0], this.user);
+ });
+});
diff --git a/tests/integration/components/table/cell/resource-identity-test.js b/tests/integration/components/table/cell/resource-identity-test.js
index bb927513..81ffdbe2 100644
--- a/tests/integration/components/table/cell/resource-identity-test.js
+++ b/tests/integration/components/table/cell/resource-identity-test.js
@@ -30,7 +30,6 @@ module('Integration | Component | table/cell/resource-identity', function (hooks
assert.dom('.table-cell-resource-identity').exists();
assert.dom('[data-test-resource-identity-image]').hasAttribute('src', 'https://example.com/truck.png');
assert.dom('button').hasClass('items-start');
- assert.dom('button').doesNotHaveClass('py-0.5');
assert.dom('[data-test-resource-identity-image]').hasClass('h-7');
assert.dom('[data-test-resource-identity-image]').hasClass('w-7');
assert.dom('[data-test-resource-identity-image]').hasClass('border');
@@ -133,8 +132,8 @@ module('Integration | Component | table/cell/resource-identity', function (hooks
await render(hbs` `);
assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 2 });
- assert.dom('[data-test-resource-identity-meta-badge]').includesText('+1 555 0100');
- assert.dom('[data-test-resource-identity-meta-badge]').includesText('Van 12');
+ assert.dom('[data-test-resource-identity-meta-badge]:nth-of-type(1)').includesText('+1 555 0100');
+ assert.dom('[data-test-resource-identity-meta-badge]:nth-of-type(2)').includesText('Van 12');
assert.dom('[data-test-resource-identity-status-badge]').exists();
assert.dom('[data-test-resource-identity-status-badge]').hasClass('status-badge-xxs');
});
@@ -160,4 +159,21 @@ module('Integration | Component | table/cell/resource-identity', function (hooks
assert.dom('[data-test-resource-identity-status-dot]').hasClass('-ml-1');
assert.dom('[data-test-resource-identity-status-dot]').hasClass('-mt-1');
});
+
+ test('it renders no meta row when there is nothing to put in it', async function (assert) {
+ this.set('row', { name: 'Bare Row' });
+ this.set('column', { labelPath: 'name' });
+
+ await render(hbs` `);
+
+ assert.dom('button').includesText('Bare Row');
+ assert.dom('[data-test-resource-identity-meta-row]').doesNotExist('no identifier, meta paths or status means no second line');
+
+ this.set('column', { labelPath: 'name', statusPath: 'status' });
+ this.set('row', { name: 'Bare Row', status: 'active' });
+ await render(hbs` `);
+
+ assert.dom('[data-test-resource-identity-meta-row]').exists('a status brings the row back');
+ assert.dom('[data-test-resource-identity-meta-row]').includesText('Active');
+ });
});
diff --git a/tests/integration/helpers/resource-helpers-test.js b/tests/integration/helpers/resource-helpers-test.js
new file mode 100644
index 00000000..c93b300a
--- /dev/null
+++ b/tests/integration/helpers/resource-helpers-test.js
@@ -0,0 +1,52 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { registerResourceDescriptors } from '@fleetbase/ember-ui/utils/resource-registry';
+
+module('Integration | Helper | resource-type, resource-component, resource-relation', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ registerResourceDescriptors(this.owner, [{ key: 'vehicle', aliases: ['attachable-vehicle'] }]);
+ this.owner.register('template:components/vehicle/pill', hbs`{{@resource.name}} `);
+ });
+
+ test('resource-type resolves a key and yields nothing for unknown input', async function (assert) {
+ this.set('record', { resourceType: 'attachable-vehicle' });
+
+ await render(hbs`[{{resource-type this.record}}][{{resource-type "vehicle"}}][{{resource-type "nothing"}}]`);
+
+ assert.dom(this.element).hasText('[vehicle][vehicle][]');
+ });
+
+ test('resource-component names a renderable component or nothing', async function (assert) {
+ this.set('record', { resourceType: 'vehicle', name: 'Truck 1' });
+
+ await render(hbs`
+ {{#let (resource-component "pill" this.record) as |pill|}}
+ {{#if pill}}{{component pill resource=this.record}}{{else}}text {{/if}}
+ {{/let}}
+ {{#let (resource-component "summary" this.record) as |summary|}}
+ {{#if summary}}{{component summary}}{{else}}no summary {{/if}}
+ {{/let}}
+ `);
+
+ assert.dom('[data-test-vehicle-pill]').hasText('Truck 1');
+ assert.dom('[data-test-fallback]').doesNotExist();
+ assert.dom('[data-test-no-summary]').exists();
+ });
+
+ test('resource-relation reads a relation without a proxy', async function (assert) {
+ const driver = { name: 'Ada' };
+ this.set('record', {
+ belongsTo(name) {
+ return { value: () => (name === 'driver' ? driver : null) };
+ },
+ });
+
+ await render(hbs`[{{get (resource-relation this.record "driver") "name"}}][{{if (resource-relation this.record "vendor") "vendor" "none"}}]`);
+
+ assert.dom(this.element).hasText('[Ada][none]');
+ });
+});
diff --git a/tests/node/postcss-build-test.cjs b/tests/node/postcss-build-test.cjs
new file mode 100644
index 00000000..3d20e185
--- /dev/null
+++ b/tests/node/postcss-build-test.cjs
@@ -0,0 +1,85 @@
+/* eslint-env node */
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { createRequire } = require('node:module');
+const { test } = require('node:test');
+const postcss = createRequire(require.resolve('broccoli-postcss-single'))('postcss');
+const tailwind = require('tailwindcss');
+const buildPostcssOptions = require('../../lib/postcss-options');
+const browsers = ['last 1 Chrome versions', 'last 1 Firefox versions', 'last 1 Safari versions'];
+
+async function compile(css, from) {
+ const options = buildPostcssOptions(browsers);
+ const config = { ...require('../../tailwind.config'), content: [{ raw: '
', extension: 'html' }] };
+ const plugins = options.compile.plugins.map((plugin) => (plugin.postcssPlugin === 'tailwindcss' ? tailwind(config) : plugin));
+ const compiled = await postcss(plugins).process(css, { from, map: false });
+ const filtered = await postcss(options.filter.plugins).process(compiled.css, { from, map: false });
+ return { compiled, filtered };
+}
+
+test('dark @apply selectors compile for the host browsers without lossy :is() warnings', async () => {
+ const { compiled, filtered } = await compile('.condition-content select:focus { @apply dark:bg-gray-900; }');
+ assert.equal(compiled.warnings().length, 0);
+ assert.match(filtered.css, /data-theme/);
+ assert.match(filtered.css, /select:focus/);
+ assert.match(filtered.css, /background-color/);
+ assert.doesNotMatch(filtered.css, /@apply/);
+});
+
+test('tooltip loops and mixins finish before directional conditionals are evaluated', async () => {
+ const from = path.resolve(__dirname, '../../addon/styles/components/attacher.css');
+ const { filtered } = await compile(fs.readFileSync(from, 'utf8'), from);
+ const rotations = new Map();
+ filtered.root.walkDecls('transform', (decl) => {
+ if (!decl.value.startsWith('rotate(')) return;
+ let node = decl.parent;
+ while (node) {
+ const side = node.selector && node.selector.match(/x-placement\^=['"]?(top|bottom|left|right)/);
+ if (side) rotations.set(side[1], decl.value);
+ node = node.parent;
+ }
+ });
+ assert.deepEqual(Object.fromEntries(rotations), {
+ bottom: 'rotate(135deg)',
+ top: 'rotate(-45deg)',
+ right: 'rotate(45deg)',
+ left: 'rotate(225deg)',
+ });
+ filtered.root.walkAtRules((rule) => {
+ assert.ok(!['each', 'mixin', 'define-mixin', 'if', 'else', 'apply'].includes(rule.name), `Unprocessed @${rule.name}`);
+ });
+});
+
+test('JavaScript utility changes invalidate the Broccoli CSS cache', async () => {
+ const { Builder } = createRequire(require.resolve('ember-cli'))('broccoli');
+ const PostcssCompiler = require('broccoli-postcss-single');
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ember-ui-css-rebuild-'));
+ let builder;
+ try {
+ fs.writeFileSync(path.join(directory, 'app.css'), '@tailwind utilities;');
+ const source = path.join(directory, 'component.js');
+ fs.writeFileSync(source, 'const className = "w-[13px]";');
+ const options = buildPostcssOptions(browsers).compile;
+ options.plugins = [tailwind({ content: [path.join(directory, '*.js')], corePlugins: { preflight: false } })];
+ builder = new Builder(new PostcssCompiler([directory], 'app.css', 'app.css', options));
+ await builder.build();
+ assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /width: 13px/);
+
+ fs.writeFileSync(source, 'const className = "w-[29px]";');
+ const later = new Date(Date.now() + 1000);
+ fs.utimesSync(source, later, later);
+ await builder.build();
+ assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /width: 29px/);
+
+ fs.writeFileSync(path.join(directory, 'new-component.js'), 'const className = "h-[37px]";');
+ await builder.build();
+ assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /height: 37px/);
+ } finally {
+ if (builder) await builder.cleanup();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/tests/unit/services/resource-registry-test.js b/tests/unit/services/resource-registry-test.js
new file mode 100644
index 00000000..10baa7ba
--- /dev/null
+++ b/tests/unit/services/resource-registry-test.js
@@ -0,0 +1,29 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import ObjectProxy from '@ember/object/proxy';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Unit | Service | resource-registry', function (hooks) {
+ setupTest(hooks);
+
+ test('it fronts the registry util for an owner', async function (assert) {
+ const service = this.owner.lookup('service:resource-registry');
+ this.owner.register('template:components/vehicle/pill', hbs`x`);
+
+ const [vehicle] = service.registerDescriptors([{ key: 'vehicle', aliases: ['attachable-vehicle'], identifier: (r) => r.plate }]);
+ assert.strictEqual(vehicle.key, 'vehicle');
+ assert.strictEqual(service.register({ key: 'driver' }).key, 'driver');
+ assert.ok(service.descriptors.some((d) => d.key === 'vehicle') && service.descriptors.some((d) => d.key === 'driver'));
+ assert.strictEqual(service.resolveKey('attachable-vehicle'), 'vehicle');
+ assert.strictEqual(service.getDescriptor('vehicle').key, 'vehicle');
+ assert.strictEqual(service.componentName('pill', 'vehicle'), 'vehicle/pill');
+ assert.false(service.canOpen('vehicle'));
+ assert.true(service.setOpener('vehicle', () => true));
+ assert.true(service.canOpen('vehicle'));
+ assert.true(await service.open({ resourceType: 'vehicle' }));
+ assert.strictEqual(service.relationValue({ driver: { name: 'Ada' } }, 'driver').name, 'Ada');
+ assert.strictEqual(service.safeIdentifier('9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab'), null);
+ const record = { name: 'Ada' };
+ assert.strictEqual(await service.unwrap(ObjectProxy.create({ content: record })), record);
+ });
+});
diff --git a/tests/unit/utils/resource-registry-test.js b/tests/unit/utils/resource-registry-test.js
new file mode 100644
index 00000000..caff7fd3
--- /dev/null
+++ b/tests/unit/utils/resource-registry-test.js
@@ -0,0 +1,451 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+import ObjectProxy from '@ember/object/proxy';
+import { hbs } from 'ember-cli-htmlbars';
+import {
+ registerResourceDescriptor,
+ registerResourceDescriptors,
+ setResourceOpener,
+ getResourceDescriptors,
+ getResourceDescriptor,
+ resolveResourceKey,
+ resourceComponentName,
+ isComponentResolvable,
+ relationValue,
+ safeIdentifier,
+ isUuidValue,
+ unwrapResource,
+ canOpenResource,
+ openResource,
+ readDescriptor,
+ SUBTYPE_PREFIXES,
+} from '@fleetbase/ember-ui/utils/resource-registry';
+
+class VehicleModel {
+ static modelName = 'vehicle';
+ constructor(attrs = {}) {
+ Object.assign(this, attrs);
+ }
+}
+
+class MaintenanceSubjectVehicleModel {
+ static modelName = 'maintenance-subject-vehicle';
+ constructor(attrs = {}) {
+ Object.assign(this, attrs);
+ }
+}
+
+function fakeOwner(services = {}) {
+ return {
+ lookup(name) {
+ return services[name] ?? null;
+ },
+ };
+}
+
+module('Unit | Utility | resource-registry', function (hooks) {
+ setupTest(hooks);
+
+ test('it normalizes descriptors on registration and rejects junk', function (assert) {
+ const [vehicle, driver] = registerResourceDescriptors(this.owner, [
+ {
+ key: 'vehicle',
+ aliases: ['attachable-vehicle', ' '],
+ modelNames: ['maintenance-subject-vehicle'],
+ polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'],
+ },
+ { key: 'driver', components: { pill: 'custom/driver-pill', 'select-option': 'custom/driver-option' }, icon: 'id-card', labelKey: 'x.driver' },
+ { nokey: true },
+ null,
+ { key: ' ' },
+ ]);
+
+ assert.strictEqual(vehicle.key, 'vehicle');
+ assert.deepEqual(vehicle.aliases, ['attachable-vehicle']);
+ assert.deepEqual(vehicle.modelNames, ['vehicle', 'maintenance-subject-vehicle']);
+ assert.deepEqual(vehicle.polymorphicTypes, ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle']);
+ assert.strictEqual(vehicle.labelKey, 'resource.vehicle');
+ assert.strictEqual(vehicle.icon, 'cube');
+ assert.deepEqual(vehicle.components, { pill: 'vehicle/pill', summary: 'vehicle/summary', identity: 'table/cell/vehicle-identity', selectOption: 'select-option/vehicle' });
+ assert.deepEqual(vehicle.statusTones, {});
+ assert.strictEqual(driver.components.pill, 'custom/driver-pill');
+ assert.strictEqual(driver.components.selectOption, 'custom/driver-option');
+ assert.strictEqual(driver.icon, 'id-card');
+ assert.strictEqual(driver.labelKey, 'x.driver');
+ const count = getResourceDescriptors(this.owner).length;
+ assert.ok(count >= 2, 'both registered (the core families are registered by the instance-initializer)');
+
+ const replaced = registerResourceDescriptor(this.owner, { key: 'driver', icon: 'user' });
+ assert.strictEqual(replaced.icon, 'user');
+ assert.strictEqual(getResourceDescriptors(this.owner).length, count, 'the same key replaces');
+ assert.strictEqual(getResourceDescriptor(this.owner, 'driver').icon, 'user');
+ assert.deepEqual(
+ registerResourceDescriptors(this.owner, { key: 'single' }).map((d) => d.key),
+ ['single'],
+ 'a single descriptor is accepted'
+ );
+ assert.strictEqual(registerResourceDescriptor(this.owner, null), null);
+ assert.deepEqual(registerResourceDescriptors(null, [{ key: 'nowhere' }]), [], 'no owner, no registration');
+ });
+
+ test('it resolves keys from every accepted input form', function (assert) {
+ registerResourceDescriptors(this.owner, [
+ { key: 'vehicle', aliases: ['attachable-vehicle'], modelNames: ['maintenance-subject-vehicle'], polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'] },
+ { key: 'driver' },
+ { key: 'integrated-vendor' },
+ ]);
+
+ assert.strictEqual(resolveResourceKey(this.owner, 'vehicle'), 'vehicle', 'key');
+ assert.strictEqual(resolveResourceKey(this.owner, ' Vehicle '), 'vehicle', 'trimmed and lowercased');
+ assert.strictEqual(resolveResourceKey(this.owner, 'attachable-vehicle'), 'vehicle', 'alias');
+ assert.strictEqual(resolveResourceKey(this.owner, 'maintenance-subject-vehicle'), 'vehicle', 'model name');
+ assert.strictEqual(resolveResourceKey(this.owner, 'fleet-ops:vehicle'), 'vehicle', 'polymorphic prefix:key');
+ assert.strictEqual(resolveResourceKey(this.owner, 'Fleetbase\\FleetOps\\Models\\Vehicle'), 'vehicle', 'PHP class');
+ assert.strictEqual(resolveResourceKey(this.owner, 'fleetbase\\fleetops\\models\\vehicle'), 'vehicle', 'PHP class, any case');
+ assert.strictEqual(resolveResourceKey(this.owner, 'App\\Models\\IntegratedVendor'), 'integrated-vendor', 'PHP class basename is dasherized');
+ assert.strictEqual(resolveResourceKey(this.owner, 'facilitator-driver'), 'driver', 'subtype prefix stripped');
+ assert.strictEqual(resolveResourceKey(this.owner, 'customer-driver'), 'driver');
+ assert.strictEqual(resolveResourceKey(this.owner, 'maintenance-subject-driver'), 'driver');
+ assert.strictEqual(resolveResourceKey(this.owner, 'attachable-driver'), 'driver');
+ assert.strictEqual(resolveResourceKey(this.owner, 'facilitator-nothing'), null, 'a stripped prefix still needs a match');
+ assert.strictEqual(resolveResourceKey(this.owner, new VehicleModel()), 'vehicle', 'record');
+ assert.strictEqual(resolveResourceKey(this.owner, ObjectProxy.create({ content: new VehicleModel() })), 'vehicle', 'proxied record');
+ assert.strictEqual(resolveResourceKey(this.owner, ObjectProxy.create({ content: null })), null, 'empty proxy');
+ assert.strictEqual(resolveResourceKey(this.owner, { resourceType: 'driver' }), 'driver', 'stub resourceType');
+ assert.strictEqual(resolveResourceKey(this.owner, { resource_type: 'fleet-ops:driver' }), 'driver', 'stub resource_type');
+ assert.strictEqual(resolveResourceKey(this.owner, { modelName: 'driver', resource: {} }), 'driver', 'wrapper modelName');
+ assert.strictEqual(resolveResourceKey(this.owner, { name: 'nothing' }), null, 'unknown object');
+ assert.strictEqual(resolveResourceKey(this.owner, 'nothing'), null, 'unknown string');
+ assert.strictEqual(resolveResourceKey(this.owner, ''), null);
+ assert.strictEqual(resolveResourceKey(this.owner, 42), null);
+ assert.strictEqual(resolveResourceKey(this.owner, null), null);
+ assert.strictEqual(resolveResourceKey(null, 'vehicle'), null, 'no owner');
+ assert.strictEqual(getResourceDescriptor(this.owner, 'nothing'), null);
+ assert.deepEqual(SUBTYPE_PREFIXES, ['facilitator-', 'maintenance-subject-', 'customer-', 'attachable-']);
+ });
+
+ test('it stores descriptors in a per-owner fallback when there is no universe registry', function (assert) {
+ const owner = fakeOwner();
+ const other = fakeOwner();
+
+ assert.strictEqual(resolveResourceKey(owner, 'vehicle'), null, 'empty before registration');
+ registerResourceDescriptors(owner, [{ key: 'vehicle' }]);
+ registerResourceDescriptors(owner, [{ key: 'vehicle', icon: 'truck' }]);
+ assert.strictEqual(resolveResourceKey(owner, 'vehicle'), 'vehicle');
+ assert.strictEqual(getResourceDescriptor(owner, 'vehicle').icon, 'truck', 'replaced in place');
+ assert.strictEqual(getResourceDescriptors(owner).length, 1);
+ assert.strictEqual(resolveResourceKey(other, 'vehicle'), null, 'another owner has its own store');
+ assert.deepEqual(getResourceDescriptors(null), []);
+
+ const throwing = {
+ lookup() {
+ throw new Error('no container');
+ },
+ };
+ registerResourceDescriptors(throwing, [{ key: 'driver' }]);
+ assert.strictEqual(resolveResourceKey(throwing, 'driver'), 'driver', 'a throwing lookup falls back too');
+ });
+
+ test('it uses the universe registry service when the owner provides one', function (assert) {
+ registerResourceDescriptors(this.owner, [{ key: 'vehicle' }]);
+ const registry = this.owner.lookup('service:universe/registry-service');
+
+ assert.ok(registry.calls.some((call) => call.method === 'register' && call.args[0] === 'resource-identity' && call.args[2] === 'vehicle'));
+ assert.strictEqual(registry.lookup('resource-identity', 'descriptors', 'vehicle').key, 'vehicle');
+ });
+
+ test('setResourceOpener lets another package supply how a resource opens', async function (assert) {
+ registerResourceDescriptors(this.owner, [{ key: 'widget-thing' }]);
+ const record = { resourceType: 'widget-thing' };
+
+ assert.false(canOpenResource(this.owner, record), 'nothing to open yet');
+ assert.false(setResourceOpener(this.owner, 'nothing', () => true));
+ assert.true(setResourceOpener(this.owner, 'widget-thing', () => 'opened', { permission: null, canOpen: () => true }));
+ assert.strictEqual(getResourceDescriptor(this.owner, 'widget-thing').permission, null);
+ assert.true(canOpenResource(this.owner, record));
+ assert.true(await openResource(this.owner, record));
+ });
+
+ test('resourceComponentName returns a name only for a known resource and a resolvable component', function (assert) {
+ registerResourceDescriptors(this.owner, [{ key: 'vehicle', components: { summary: () => {} } }, { key: 'driver' }]);
+ this.owner.register('template:components/vehicle/pill', hbs`x`);
+ this.owner.register('template:components/select-option/vehicle', hbs`x`);
+
+ assert.strictEqual(resourceComponentName(this.owner, 'pill', 'vehicle'), 'vehicle/pill');
+ assert.strictEqual(resourceComponentName(this.owner, 'select-option', 'vehicle'), 'select-option/vehicle');
+ assert.strictEqual(resourceComponentName(this.owner, 'selectOption', 'vehicle'), 'select-option/vehicle');
+ assert.strictEqual(typeof resourceComponentName(this.owner, 'summary', 'vehicle'), 'function', 'a component class passes through');
+ assert.strictEqual(resourceComponentName(this.owner, 'identity', 'vehicle'), null, 'not resolvable in this owner');
+ assert.strictEqual(resourceComponentName(this.owner, 'pill', 'driver'), null, 'default name does not resolve');
+ assert.strictEqual(resourceComponentName(this.owner, 'pill', 'nothing'), null, 'unknown resource');
+ assert.strictEqual(resourceComponentName(this.owner, 'nope', 'vehicle'), null, 'unknown kind');
+ });
+
+ test('isComponentResolvable covers every input shape', function (assert) {
+ this.owner.register('template:components/known-thing', hbs`x`);
+ assert.false(isComponentResolvable(this.owner, null));
+ assert.false(isComponentResolvable(this.owner, ''));
+ assert.true(isComponentResolvable(this.owner, () => {}));
+ assert.true(isComponentResolvable(this.owner, {}));
+ assert.true(isComponentResolvable(this.owner, 'known-thing'));
+ assert.false(isComponentResolvable(this.owner, 'unknown-thing'));
+ assert.false(isComponentResolvable(null, 'known-thing'));
+ assert.false(
+ isComponentResolvable(
+ {
+ factoryFor() {
+ throw new Error('boom');
+ },
+ },
+ 'known-thing'
+ ),
+ 'a throwing owner is treated as unresolvable'
+ );
+ assert.true(
+ isComponentResolvable(
+ {
+ factoryFor: () => ({}),
+ },
+ 'anything'
+ ),
+ 'factoryFor alone is enough'
+ );
+ });
+
+ test('relationValue never returns a proxy', function (assert) {
+ const driver = { name: 'Ada' };
+ const model = {
+ belongsTo(name) {
+ if (name === 'driver') {
+ return { value: () => driver };
+ }
+
+ if (name === 'vendor') {
+ return { value: () => null };
+ }
+
+ throw new Error(`${name} is not a belongsTo`);
+ },
+ hasMany(name) {
+ if (name === 'trailers') {
+ return { value: () => ['t1'] };
+ }
+
+ if (name === 'devices') {
+ return { value: () => null };
+ }
+
+ throw new Error(`${name} is not a hasMany`);
+ },
+ plain: 'value',
+ promised: { then() {}, content: driver },
+ empty: { then() {}, content: null },
+ };
+
+ assert.strictEqual(relationValue(model, 'driver'), driver);
+ assert.strictEqual(relationValue(model, 'vendor'), null);
+ assert.deepEqual(relationValue(model, 'trailers'), ['t1']);
+ assert.strictEqual(relationValue(model, 'devices'), null);
+ assert.strictEqual(relationValue(model, 'plain'), 'value');
+ assert.strictEqual(relationValue(model, 'promised'), driver, 'a promise proxy yields its content');
+ assert.strictEqual(relationValue(model, 'empty'), null);
+ assert.strictEqual(relationValue(model, 'missing'), null);
+ assert.strictEqual(relationValue({ driver }, 'driver'), driver, 'plain objects use get');
+ assert.strictEqual(relationValue(ObjectProxy.create({ content: { driver } }), 'driver'), driver, 'a proxied record is unwrapped first');
+ assert.strictEqual(relationValue(ObjectProxy.create({ content: null }), 'driver'), null);
+ assert.strictEqual(relationValue({ driver: ObjectProxy.create({ content: driver }) }, 'driver'), driver, 'a proxied value is unwrapped');
+ assert.strictEqual(relationValue(null, 'driver'), null);
+ assert.strictEqual(relationValue(model, ''), null);
+ assert.strictEqual(relationValue(model, 5), null);
+ });
+
+ test('safeIdentifier and isUuidValue drop UUIDs', function (assert) {
+ assert.strictEqual(safeIdentifier('ABC-123'), 'ABC-123');
+ assert.strictEqual(safeIdentifier(42), 42);
+ assert.strictEqual(safeIdentifier(' 9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab '), null);
+ assert.strictEqual(safeIdentifier(''), null);
+ assert.strictEqual(safeIdentifier(' '), null);
+ assert.strictEqual(safeIdentifier(null), null);
+ assert.strictEqual(safeIdentifier(undefined), null);
+ assert.true(isUuidValue('9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab'));
+ assert.false(isUuidValue('vehicle_123'));
+ assert.false(isUuidValue(null));
+ });
+
+ test('unwrapResource resolves thenables, proxies and stubs', async function (assert) {
+ const record = { name: 'Ada' };
+ assert.strictEqual(await unwrapResource(record), record);
+ assert.strictEqual(await unwrapResource(Promise.resolve(record)), record);
+ assert.strictEqual(await unwrapResource(ObjectProxy.create({ content: record })), record);
+ assert.strictEqual(await unwrapResource(Promise.resolve(ObjectProxy.create({ content: record }))), record);
+ assert.strictEqual(await unwrapResource(ObjectProxy.create({ content: null })), null);
+ assert.strictEqual(await unwrapResource({ loadResource: () => Promise.resolve(record) }), record);
+ const stub = { loadResource: () => null };
+ assert.strictEqual(await unwrapResource(stub), stub, 'a stub that loads nothing stands for itself');
+ assert.strictEqual(await unwrapResource(null), null);
+ assert.strictEqual(await unwrapResource(undefined), null);
+
+ let nested = record;
+ for (let i = 0; i < 10; i++) {
+ nested = ObjectProxy.create({ content: nested });
+ }
+ assert.ok(await unwrapResource(nested), 'deep nesting stops after a bounded number of steps');
+ });
+
+ test('canOpenResource checks the open path, the descriptor guard and the permission', function (assert) {
+ const abilities = { can: (permission) => permission === 'fleet-ops view vehicle' };
+ const owner = fakeOwner({ 'service:abilities': abilities });
+ registerResourceDescriptors(owner, [
+ { key: 'vehicle', open: () => true, permission: 'fleet-ops view vehicle' },
+ { key: 'driver', open: () => true, permission: () => 'fleet-ops view driver' },
+ { key: 'trailer', open: () => true, canOpen: (record) => Boolean(record?.id) },
+ {
+ key: 'device',
+ open: () => true,
+ canOpen: () => {
+ throw new Error('boom');
+ },
+ },
+ { key: 'place', open: () => true },
+ { key: 'zone' },
+ ]);
+
+ assert.true(canOpenResource(owner, { resourceType: 'vehicle' }));
+ assert.false(canOpenResource(owner, { resourceType: 'driver' }), 'a permission function is evaluated');
+ assert.true(canOpenResource(owner, { resourceType: 'trailer', id: 't1' }));
+ assert.false(canOpenResource(owner, { resourceType: 'trailer' }), 'canOpen guard');
+ assert.true(canOpenResource(owner, ObjectProxy.create({ content: { resourceType: 'trailer', id: 't1' } })), 'proxies are unwrapped for the guard');
+ assert.false(canOpenResource(owner, { resourceType: 'device' }), 'a throwing guard denies');
+ assert.true(canOpenResource(owner, 'place'), 'no permission means allowed');
+ assert.false(canOpenResource(owner, 'zone'), 'no open path');
+ assert.false(canOpenResource(owner, 'nothing'));
+
+ const throwingAbilities = fakeOwner({
+ 'service:abilities': {
+ can() {
+ throw new Error('boom');
+ },
+ },
+ });
+ registerResourceDescriptors(throwingAbilities, [{ key: 'vehicle', open: () => true, permission: 'x' }]);
+ assert.false(canOpenResource(throwingAbilities, 'vehicle'), 'a throwing abilities service denies');
+
+ const noAbilities = fakeOwner();
+ registerResourceDescriptors(noAbilities, [{ key: 'vehicle', open: () => true, permission: 'x' }]);
+ assert.true(canOpenResource(noAbilities, 'vehicle'), 'without an abilities service the permission is not enforced');
+ });
+
+ test('openResource unwraps, loads the canonical record for a subtype and never throws', async function (assert) {
+ const opened = [];
+ const canonical = new VehicleModel({ id: 'v1' });
+ const found = new VehicleModel({ id: 'v2' });
+ const store = {
+ peekRecord: (modelName, id) => (modelName === 'vehicle' && id === 'v1' ? canonical : null),
+ findRecord: (modelName, id) => Promise.resolve(id === 'v2' ? found : null),
+ };
+ const owner = fakeOwner({ 'service:store': store });
+ registerResourceDescriptors(owner, [
+ { key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record, context) => opened.push([record, context]) && true },
+ { key: 'driver', open: () => false },
+ {
+ key: 'trailer',
+ open: () => {
+ throw new Error('boom');
+ },
+ },
+ { key: 'zone' },
+ ]);
+
+ assert.false(await openResource(owner, null));
+ assert.false(await openResource(owner, { resourceType: 'nothing' }));
+ assert.false(await openResource(owner, { resourceType: 'zone' }), 'no open path');
+ assert.false(await openResource(owner, { resourceType: 'driver' }), 'the descriptor declined');
+ assert.false(await openResource(owner, { resourceType: 'trailer' }), 'a throwing open is swallowed');
+
+ const event = { type: 'click' };
+ assert.true(await openResource(owner, new MaintenanceSubjectVehicleModel({ id: 'v1' }), { event }));
+ assert.strictEqual(opened[0][0], canonical, 'the subtype was swapped for the peeked canonical record');
+ assert.strictEqual(opened[0][1].event, event);
+ assert.strictEqual(opened[0][1].owner, owner);
+
+ assert.true(await openResource(owner, new MaintenanceSubjectVehicleModel({ uuid: 'v2' })));
+ assert.strictEqual(opened[1][0], found, 'or fetched when not in the store');
+
+ const notFound = new MaintenanceSubjectVehicleModel({ id: 'v3' });
+ assert.true(await openResource(owner, notFound));
+ assert.strictEqual(opened[2][0], notFound, 'an unknown id opens the subtype itself');
+
+ const noId = new MaintenanceSubjectVehicleModel();
+ assert.true(await openResource(owner, noId));
+ assert.strictEqual(opened[3][0], noId, 'no id means nothing to load');
+
+ assert.true(await openResource(owner, canonical));
+ assert.strictEqual(opened[4][0], canonical, 'a canonical record is used as is');
+ assert.strictEqual(opened[4][1].event, null);
+
+ const stub = { loadResource: () => Promise.resolve(canonical) };
+ assert.true(await openResource(owner, stub));
+ assert.strictEqual(opened[5][0], canonical, 'stubs are loaded first');
+
+ assert.true(await openResource(owner, Promise.resolve(ObjectProxy.create({ content: canonical }))));
+ assert.strictEqual(opened[6][0], canonical, 'thenables and proxies are unwrapped');
+
+ assert.true(await openResource(owner, { id: 'plain' }, { resourceType: 'vehicle' }), 'options.resourceType names the type of an anonymous record');
+ assert.strictEqual(opened[7][0].id, 'plain');
+
+ const noStore = fakeOwner();
+ registerResourceDescriptors(noStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]);
+ const subtype = new MaintenanceSubjectVehicleModel({ id: 'v1' });
+ assert.true(await openResource(noStore, subtype));
+ assert.strictEqual(opened[8][0], subtype, 'without a store the subtype opens itself');
+
+ const partialStore = fakeOwner({ 'service:store': { peekRecord: () => null } });
+ registerResourceDescriptors(partialStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]);
+ assert.true(await openResource(partialStore, subtype));
+ assert.strictEqual(opened[9][0], subtype, 'a store without findRecord cannot fetch');
+
+ const nullStore = fakeOwner({ 'service:store': { findRecord: () => Promise.resolve(null) } });
+ registerResourceDescriptors(nullStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]);
+ assert.true(await openResource(nullStore, subtype));
+ assert.strictEqual(opened[10][0], subtype, 'a fetch that returns nothing keeps the subtype');
+ });
+
+ test('readDescriptor reads plain fields and calls functions safely', function (assert) {
+ const descriptor = {
+ icon: 'truck',
+ title: (record) => record.name,
+ broken: () => {
+ throw new Error('boom');
+ },
+ };
+
+ assert.strictEqual(readDescriptor(descriptor, 'icon'), 'truck');
+ assert.strictEqual(readDescriptor(descriptor, 'title', { name: 'Ada' }), 'Ada');
+ assert.strictEqual(readDescriptor(descriptor, 'broken'), undefined);
+ assert.strictEqual(readDescriptor(descriptor, 'missing'), undefined);
+ assert.strictEqual(readDescriptor(null, 'icon'), undefined);
+ });
+});
+
+module('Unit | Utility | resource-registry | abilities service', function (hooks) {
+ setupTest(hooks);
+
+ test('the abilities service of the owner gates opening', function (assert) {
+ this.owner.register(
+ 'service:abilities',
+ class extends Service {
+ can(permission) {
+ return permission === 'allowed';
+ }
+ }
+ );
+ registerResourceDescriptors(this.owner, [
+ { key: 'allowed-thing', open: () => true, permission: 'allowed' },
+ { key: 'denied-thing', open: () => true, permission: 'denied' },
+ ]);
+
+ assert.true(canOpenResource(this.owner, 'allowed-thing'));
+ assert.false(canOpenResource(this.owner, 'denied-thing'));
+ });
+});