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|}} + + {{/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 +
+ {{#if this.isClickable}} + + + <:image as |resource|>{{yield resource to="image"}} + <:default as |resource|>{{yield resource}} + <:tooltip as |resource|>{{yield resource to="tooltip"}} + + + {{else}} + + + <:image as |resource|>{{yield resource to="image"}} + <:default as |resource|>{{yield resource}} + <:tooltip as |resource|>{{yield resource to="tooltip"}} + + + {{/if}} +
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}} + {{or + {{#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 @@ + 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}} + {{this.title}} + {{/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.image.component}} + {{component this.image.component resource=this.record}} + {{else if this.image.url}} + + {{else}} +
+ +
+ {{/if}} + {{#if this.hasOnline}} + + {{/if}} +
+
+
{{this.title}}
+ {{#if this.identifier}} +
{{this.identifier}}
+ {{/if}} +
+ {{#if this.status}} + + {{/if}} +
+ {{#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}} +
+ +
+ {{/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``); + 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``); + 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 - - `); + + `); + + 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``); + 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`` + ); + + 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``); + + 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``); + 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` +
+ +
+ + forwarded + +
+ `); + 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 - - `); + +
+ {{#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')); + }); +});