Skip to content

Release v0.4.1 - #175

Merged
roncodes merged 41 commits into
mainfrom
release/v0.4.1
Sep 16, 2026
Merged

roncodes merged 41 commits into
mainfrom
release/v0.4.1

Conversation

@roncodes

@roncodes roncodes commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Release branch for v0.4.1: the resource identity system the console and its engines share. The coverage/playground release that previously held this slot is now release/v0.4.2 (#174, continuing #173).

Architecture

Descriptor registry — addon/utils/resource-registry.js. A descriptor is a plain object that teaches the console how to show one kind of record: key, aliases, modelNames, polymorphicTypes, labelKey, icon, title(r), identifier(r) (never a UUID), image(r) ({ url, fallback, shape }, { icon } or { component }), online(r), status(r), statusTones, badges(r, ctx), selectDetails(r), facts(r), optional hydrate(r, owner), open(r, { owner, event }), canOpen(r, owner), permission, and components (pill, summary, identity, selectOption, defaulting to <key>/pill, <key>/summary, table/cell/<key>-identity, select-option/<key>).

Descriptors are stored in the shared universe/registry-service (section resource-identity), so the host and every engine read one set; an owner without that service gets a per-owner fallback store. Exports:

  • registerResourceDescriptor(s)(owner, …), setResourceOpener(owner, key, fn, { permission, canOpen }), getResourceDescriptors(owner)
  • resolveResourceKey(owner, input) — a record (constructor.modelName), a stub with resourceType, a model name or alias, a prefix:key string, a PHP class name, or a polymorphic type; strips the facilitator-, maintenance-subject-, customer- and attachable- prefixes; null when unknown
  • getResourceDescriptor, resourceComponentName(owner, kind, input) — null when the key is unknown or the component is not resolvable in owner
  • openResource(owner, input, opts) — unwraps proxies, thenables and loadResource() stubs, loads the canonical record for a subtype (peekRecord ?? findRecord), calls descriptor.open, returns false when nothing happened, never throws
  • canOpenResource(owner, input, { resourceType }) — open path exists, canOpen guard passes, abilities.can(permission)
  • relationValue(record, name) (never a proxy), safeIdentifier(value) (drops UUIDs), unwrapResource, readDescriptor

Helpers: {{resource-type x}}, {{resource-component "pill"|"summary"|"identity"|"select-option" x}}, {{resource-relation record "name"}}. The resource-registry service fronts the util for engines that prefer owner.lookup('service:resource-registry') over importing an ember-ui module.

Bases — Resource::Summary (compact card: 48px image or icon, title and identifier, status badge and online state, up to six facts, one View action shown only when the resource can be opened; hydrates on insert with skeleton bars, falls back on error), Resource::HoverCard (stage 0 is a hidden anchor with listeners on its target; after 350ms of continuous hover it renders an interactive Attach::Popover with the summary; drops back to stage 0 when the popover hides; skipped on (hover: none) devices; z-index: 10000, above the context panel), Resource::Pill (descriptor-driven pill that opens on click when it can, otherwise the static variant; hover card unless @noPopover), Table::Cell::Identity (one line: image with status dot, name, up to column.badgeLimit ?? 2 badges; badges pointing at the row itself are dropped; click chain @onClick → column.onClick → column.action → openResource, with stopPropagation; column.permission disables; compact, showStatusBadge and showStatus are accepted and ignored) and Resource::SelectOption (photo or icon, title, details; @compact for the closed trigger).

The record's own type always wins over a wrapper's @resourceType, so a polymorphic field holding a customer-vendor renders and opens as a vendor.

Core families — user, company, group, role, file and category each get <key>/pill, <key>/summary, select-option/<key> and table/cell/<key>-identity, with descriptors registered by instance-initializers/register-core-resource-descriptors in the host. None opens by default (the console has no per-record routes for them; a file opens its URL); an engine that owns such a page calls setResourceOpener. User::Pill keeps its @user / "No user" / email defaults and gains blocks, @subtitle, @showOnlineIndicator/@onlinePath, @noPopover and the class args.

Fixes (one commit each)

  • E1 03f2930 — Attach::Popover applies @class to the floating element
  • E2 5c4018d — an interactive popover opened by hover accepts pointer events and can be entered; @isShown is followed after the first render
  • E3 0868af9 — Attach::Tooltip forwards hideDelay, hideDuration, @class, @style, floatingTarget, floatingContainer, onChange
  • E4/E5 2aee601 — Pill renders a static span when it has no @onClick (hover style scoped to real anchors, in the addon), and passes resource=@resource to @tooltipComponent
  • E6 7f5af2e — User::Pill upgraded as the user family pill
  • E7 2a38025 — MultiSelect forwards @selectedItemComponent, @searchField, @searchFieldPosition, @searchPlaceholder
  • E8 5693211 — filter/model option hook (@filter.filterOptionComponent ?? (resource-component "select-option" @filter.model), falling back to modelNamePath), plus filter/model-multiple
  • E9 e4fc02e — table/cell/resource-identity renders no empty meta row
  • 6ee1833 — found while touching it: Attach::Popover never removed its document listeners on destroy, and an interactive popover shown programmatically could not close on mouse-out

For engines

// addon/instance-initializers/register-my-resource-descriptors.js
export function initialize(engineInstance) {
    const registry = engineInstance.lookup('service:resource-registry');
    if (!registry) return; // ember-ui < 0.4.1: components fall back to text

    registry.registerDescriptors([
        {
            key: 'vehicle',
            aliases: ['attachable-vehicle'],
            polymorphicTypes: ['fleet-ops:vehicle'],
            permission: 'fleet-ops view vehicle',
            title: (v) => v.displayName ?? v.name,
            identifier: (v) => v.plate_number,
            image: (v) => ({ url: v.photo_url, shape: 'square' }),
            online: (v) => v.online,
            status: (v) => v.status,
            badges: (v) => [{ key: 'plate', icon: 'id-card', label: v.plate_number }],
            facts: (v) => [{ labelKey: 'resource-summary.facts.plate', value: v.plate_number }],
            open: (v) => engineInstance.lookup('service:vehicle-actions').panel.view(v),
            components: { identity: 'cell/vehicle-identity' },
        },
    ]);
}

Then <Vehicle::Pill @vehicle={{x}} />, cellComponent: 'cell/vehicle-identity', <SelectOption::Vehicle @option={{x}} /> and filterComponent: 'filter/model' all work from the one descriptor. FleetOps adopts this in fleetbase/fleetops (PR B), which depends on this release being published.

Dummy app

Pill and ModelSelect no longer import @fleetbase/ember-core (a local model-name fallback and a local generate-uuid), so the components this release adds can render in the test app without ember-core. The branch briefly declared ember-core as a dependency and reverted it: its app tree collides with ember-ui's and its services need the console. The dummy app gains a universe/registry-service stub with the section/list/key storage the registry uses.

Tests and coverage

New and updated tests: tests/unit/utils/resource-registry-test.js (resolution by key, alias, model name, prefix:key, PHP class and polymorphic type; subtype prefixes; the service store and the per-owner fallback; openResource unwrapping proxies, thenables and stubs, alias → canonical load through peekRecord/findRecord, the false path; canOpenResource guards and permissions; relationValue, safeIdentifier, readDescriptor), tests/unit/services/resource-registry-test.js, tests/integration/helpers/resource-helpers-test.js, tests/integration/components/resource/smoke-test.js (the user family renders all four components; hovering a pill arms and shows the summary, which can be entered and tears down on leave; an opener makes pill and cell clickable), attach/popover-test.js (class application, computed pointer-events and entering from target to popover, escape, reactive @isShown), attach/tooltip-test.js (forwarded options), pill-test.js (static vs clickable, tooltip resource, blocks, online), multi-select-test.js, filter/model-test.js and filter/model-multiple-test.js (option component, column override, label fallback, restore from ids, clear), and the meta-row case in table/cell/resource-identity-test.js.

Locally, the CI subset (ember test --filter='!Integration', 70 tests) plus every new and touched integration module pass together (41 + 6 tests, 0 failures). main has no coverage gate (the test:coverage / coverage:check scripts and ember-cli-code-coverage live on release/v0.4.2), so no coverage number is reported here; the new files were written to be covered line by line and will meet the 100% gate when v0.4.2 lands on top of this.

Follow-ups

  • Playground pages for Resource::Pill, Resource::Summary, Table::Cell::Identity and Resource::SelectOption once they are documented on fleetbase.io/docs/ui (see PLAYGROUND.md on release/v0.4.2).
  • release/v0.4.2 (Release v0.4.3 #174) deletes custom-field/form, which ember-core's custom-fields-registry still opens; creating or editing custom fields will break on that release unless it is restored.
  • The placeholder "it renders" integration tests on main have always failed and are filtered out of CI (--filter='!Integration'); the coverage campaign on v0.4.2 replaces them.
  • pnpm lint / pnpm test:ember on main need pnpm ≥ 10 (the workspace file has only allowBuilds), and pnpm 11's pre-run dependency check fails when an older pnpm is first on PATH; running node_modules/.bin/eslint, ember-template-lint, stylelint and ember test directly works.

Manual test plan (after linking ember-ui into the console)

  1. Vehicles table: one-line name cell (small image + dot, name, plate badge, driver badge, no status text); ~350ms hover shows the summary, which can be entered, and its View works; click opens the context panel; the driver column never repeats the row's own vehicle; stub rows with only driver_name still open the driver.
  2. Drivers table mirrors it; vendor opens the vendor panel.
  3. Trailers table: towing vehicle is a vehicle identity cell; vendor no longer throws.
  4. Orders table: driver, vehicle, customer (contact and vendor), facilitator, pickup/dropoff, created by.
  5. Details: vehicle driver pill and header links; driver vehicle and vendor; fuel report reporter user pill; work order target/assignee and a Linked Schedule that now renders; maintenance and schedule; equipment and part fitted-to; sensor and device-event device display name; fleet and service rate; warranty static pill + hover; file/category pills.
  6. Pickers: order form (customer, facilitator, driver, vehicle, places); equipment form type switch updates the trigger; work-order target/assignee, maintenance-schedule, attach telematic device, fleet assign, driver assign order; user and role pickers; rows show photo/icon, title and details; the trigger shows the compact option; clear works.
  7. Filters: vehicles driver multi-filter chips; orders driver, vehicle and customer; events device; chips restore after reload.
  8. Map drawer: a cell click does not also fire the row action.
  9. Hover card: mousing from target to card keeps it open; leaving or Escape hides it; not clipped inside the context panel; above overlays; light and dark; no lag on 100 rows.
  10. Regression: the activity-log popover can still be entered; existing ResourceIdentity users in other engines are unchanged.

Pill and ModelSelect import utilities from @fleetbase/ember-core, but the
addon never declared it, so every rendering test that touched them died
with "Could not find module" and the integration suite has been failing
on main. Declare ember-core as a dependency, and add ember-data with the
tracked-storage polyfill it needs so its services can boot in the dummy
app. The resource identity work that follows is tested through these
components.
With ember-core installed, the report-widget instance-initializer finds a
real universe/widget-service, which injects universe/registry-service,
which needs the console's extension loader and cannot boot outside the
console. Give the dummy app small stubs with the section/list/key storage
the addon relies on, so rendering tests boot cleanly.
Attach::Popover copied @Class onto the component but never rendered it;
only @classnames reached the DOM, on the inner div. Put @Class on the
floating .ember-attacher element so a caller can size and position the
popover as a whole, and replace the placeholder test with one that
renders the popover.
computedStyle wrote pointer-events: none unless isShown, but show() never
set isShown, and Floating forced pointer-events: none on the floating
element as well. So an @Interactive popover could never be entered: the
document mousemove saw whatever lay underneath and hid it. Maintain
isShown in show() and hide(), let Floating take its pointer-events value
from the popover, and only ever enable pointer events for interactive
popovers so plain tooltips stay transparent to the pointer. @isShown is
now followed after the first render as well.

The test drives the real sequence: hover the target, leave it, move onto
the popover and check it stays open with computed pointer-events auto.
Attach::Tooltip passed a fixed subset of its arguments through, so
hideDelay, hideDuration, @Class, @Style, floatingTarget, floatingContainer
and onChange were silently ignored; every @Class="clean" in the addon was
a no-op. Forward them to the popover.
…ip component its resource

Pill always rendered an anchor, so a pill that only names a record looked
clickable and did nothing when clicked; the hover style FleetOps added for
it matched those pills too. Render a non-link span with the
fleetbase-pill--static class when there is no @OnClick, move the hover
style into the addon scoped to real anchors, and share the pill body
between the two variants. The tooltip component now receives the resource
it is describing, and the online dot can be driven by an explicit @online
flag as well as a path on the resource.
filter/multi-option already passed @SEARCHFIELD and @searchPlaceholder to
MultiSelect, which never forwarded them to PowerSelectMultiple, and there
was no way to render a selected chip with a component. Forward
@selectedItemComponent, @SEARCHFIELD, @searchFieldPosition and
@searchPlaceholder.
…ilter

filter/model rendered a fixed ModelSelect whose options were the bare label
path. Each option and the selected item now render through the column's
filterOptionComponent, or the select-option registered for the filtered
model, falling back to modelNamePath text as before.

filter/model-multiple is the multi-record filter FleetOps kept locally as
filter/multi-model: the value travels as a comma-separated id list and the
records are re-fetched on init so chips show names after a reload. It uses
the same option hook.
table/cell/resource-identity always rendered its second line, so a cell
with no identifier, meta paths or status still took two lines. Render the
row only when there is something to put in it.
A descriptor teaches the console how to show one kind of record: title,
identifier, image, status, online state, badges, the facts a summary
lists, and how to open it. Descriptors live in the shared
universe/registry-service so the host and every engine read one set, with
a per-owner fallback store for apps without it.

resolveResourceKey accepts a record, an identity stub, a model name or
alias, a prefix:key string, a PHP class name or a polymorphic type, and
strips the facilitator-, maintenance-subject-, customer- and attachable-
subtype prefixes. openResource unwraps proxies, thenables and
loadResource() stubs, swaps a subtype for its canonical record through
the store, and never throws. canOpenResource gates on the descriptor's
guard and the abilities service. relationValue reads a relation without
ever returning a promise proxy, and safeIdentifier refuses UUIDs.

The resource-type, resource-component and resource-relation helpers and
the resource-registry service expose it to templates and to engines that
prefer runtime lookup over importing ember-ui modules.
…ic interactive popover

Attach::Popover registered listeners on the document and its target but
never removed them when destroyed, so every escape key and mouse move
kept calling into dead components. Remove them in willDestroy along with
the pending timers.

An interactive popover shown through @isShown, or whose target the pointer
left before the listeners were bound, had no way to close on mouse-out
because the document mousemove watcher was only installed on the target's
mouseleave. Install it when the popover shows.
Resource::Summary is the compact card for one record: image or icon tile,
title and identifier, status badge and online state, four to six facts
from the descriptor (a fact with a related record renders that record's
pill without its own popover), and one View action shown only when the
resource can be opened. Hydration runs when the card is inserted and shows
skeleton bars meanwhile; a failed hydrate falls back to what the record
carries.

Resource::HoverCard hosts it lazily. Stage 0 is a hidden anchor with hover
and focus listeners on its target; only after 350ms of continuous hover
does it render an interactive Attach::Popover with the summary, and when
the popover hides it drops back to stage 0. Devices without hover never
arm. The shared resource-identity util carries the presentation logic
(title, identifier, image, status tone, badges, facts) the identity
components derive from a descriptor.
A pill driven by the registry: the descriptor supplies the image or icon
tile, the title, the identifier subtitle and the online dot; the pill
opens the resource on click when its descriptor knows how and otherwise
renders the static variant, and hovering shows the summary card unless
@noPopover is set. Wrappers pass @useBlock so a caller's block replaces
the default content only when the caller actually gave one.
A small image or icon with the status dot, the truncated name, and up to
column.badgeLimit inline badges from the descriptor, all on one line. There
is never a status badge; compact, showStatusBadge and showStatus are
accepted and ignored so existing column definitions keep working. Badges
whose relatedId is the row's own id are dropped. Clicking runs @OnClick,
column.onClick and column.action, otherwise opens the resource through its
descriptor, and stops propagation so a row action does not fire as well;
column.permission disables the button. Without any open path the cell
renders plain text and keeps the hover card.
The option row for a ModelSelect or PowerSelect: photo or icon, title and
a line of detail, on one line in @compact form for a closed trigger. Given
@option or @model the descriptor fills these in; the presentational
arguments override it. The CSS is the select-option style FleetOps kept
locally, moved into the addon.
user, company, group, role, file and category, read by duck typing so an
API payload renders like a record. None opens anything by default because
the console has no per-record routes for them; an engine that owns such a
page supplies an opener with setResourceOpener. A file opens its URL in a
new tab.
user/pill, user/summary, select-option/user and table/cell/user-identity, as wrappers over the resource identity bases for the user descriptor.
company/pill, company/summary, select-option/company and table/cell/company-identity, as wrappers over the resource identity bases for the company descriptor.
group/pill, group/summary, select-option/group and table/cell/group-identity, as wrappers over the resource identity bases for the group descriptor.
role/pill, role/summary, select-option/role and table/cell/role-identity, as wrappers over the resource identity bases for the role descriptor.
file/pill, file/summary, select-option/file and table/cell/file-identity, as wrappers over the resource identity bases for the file descriptor.
category/pill, category/summary, select-option/category and table/cell/category-identity, as wrappers over the resource identity bases for the category descriptor.
Tailwind refuses a selector on a utility class inside an @apply file, so
give the pill subtitle a resource-pill-subtitle class and target that.
Glimmer refuses a bare helper as a named argument, so the family pills
pass @useBlock={{(has-block)}}. User::Pill also leaves the online dot to
the descriptor unless an @onlinepath is given.
The family wrappers pass @resourceType, but a polymorphic field holds a
record whose model name already says what it is: a customer-vendor is a
vendor, a facilitator-driver is a driver. Resolve the record first and use
the wrapper's type only as a fallback for records that cannot be resolved
on their own, so cell/customer-identity opens the vendor panel for a
customer-vendor and the correct summary appears. canOpenResource takes the
same fallback.
Port the trigger and chip sizing FleetOps had for its multi-model filter:
the small form-input height clipped the multiple trigger into a scroller
before anything was selected.
The registry and descriptors read records and identity stubs by path,
which is exactly what Ember's get is for; disable ember/no-get in those
files with a note. Prettier formatting and no settled() right after a
test helper.
…r ember-core

Clearing filter/model through the select's own clear control selected
null and reported it as a change to an empty value; report it as a clear
so the filter is removed. The dummy app gains the fetch and universe
stubs the v0.4.2 branch already carries, which the dashboard and
docs-panel unit tests need once ember-core is installed. The registry
tests allow for the six core descriptors the instance-initializer
registers, and the picker tests use the power-select helpers correctly.
Declaring @fleetbase/ember-core turned out to cost more than it bought:
its app tree collides with ember-ui's (is-image-file), its services need
the console (docs-panel, dashboard), and CI's unit subset went red. Drop
it again and remove the two ember-core imports the tested components
had instead: Pill falls back to the record's model name directly and
ModelSelect uses a local generate-uuid. The dummy app keeps only the
universe/registry-service stub the registry tests exercise.

Two assertions in the resource-identity test contradicted the template
on main (the button has py-0.5; includesText on a multi-element selector
reads the first badge); they are corrected while the file is touched.
CodeQL flagged the Math.random fallback in generate-uuid; use
crypto.getRandomValues when randomUUID is unavailable.
Fix CSS build performance with linked addons
Comment thread lib/tailwind-content.js Fixed
roncodes and others added 8 commits September 16, 2026 11:24
…caping or encoding'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
The document mousemove handler hid the popover as soon as the pointer left
the target, so the gap between a cell and its card closed the card before the
View button could be reached. Leaving now schedules the hide, moving back over
the target or onto the floating element cancels it, and hide() clears the
pending state and the listener.
The card is now a 17rem column with a 36px image, the title and identifier,
the status badge at the right and a two-column fact list, with facts that
merely repeat the title, identifier or status dropped. It carries a 1px
gray-200 border on white in light mode and gray-700 on gray-900 in dark mode,
opens with the scale animation, and shows the online state as a dot at the
image's top-left corner like the identity cell instead of text. Date facts go
through date-fns. View opens the resource before the card closes, so the
click reaches its target.
A pill for a resource with no image rendered a broken image; it now falls
back to the descriptor's icon (or a cube). The clickable pill gets a little
padding so its content no longer sits on the hover border, and the online dot
carries a class the compact fact pill can size.
The core user, company, group, role, file and category descriptors no longer
declare badges. The identity cell draws its status dot only when the column
asks for one, names a path for it, or the resource reports an online state;
places, contacts and vendors have none and render without a bulb.
… to a getter

- Pill images carried a 2px gray-800 ring that read as a black border in
  light mode, including the related-record pills inside a summary card; they
  now use a 1px gray-200 ring (gray-700 in dark mode).
- Every image and icon tile in pills, identity cells and summary cards is a
  circle now, at one size per component, and each passes the console's
  placeholder as a fallback so a dead avatar URL no longer shows a broken image.
- A pill with no record ("No driver assigned") is gray-500 in light mode
  instead of the barely readable gray-400.
- The clickable pill's hover box keeps a 0.5rem right margin instead of a
  negative one, so it no longer overlaps the content beside it; the summary
  fact cell leaves room on the left and top so the pill's ring and dot are
  not clipped.
- The column picker handed the column list to (mut @columns), which threw
  "Cannot set property columns ... which has only a getter" on controllers
  that compute their columns; the tabular actions now only forward the list
  to an explicit @onColumnsChange.
The badge palette gains a Fleet-Ops section covering each value the 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), including the entries that had lived in the
fleetops engine stylesheet: long-offline, attached, in-use, detached,
retired, out-of-service, passed, submitted, needs-review and unsafe. Values
such as processed, fuel-provider, not-equipped and in-stock were unstyled.

Select options are shorter now: one 1.5rem circle regardless of resource,
no square variant, a tighter two-line text block, and the placeholder image
as a fallback.
A presence filter such as "assigned / not assigned" read badly as a
dropdown whose placeholder doubled as the "any" state. filter/radio shows
every option, with the placeholder as the first "any" radio that clears the
filter, and takes the same options, optionLabel and optionValue arguments as
filter/select.
@roncodes
roncodes merged commit b77c15a into main Sep 16, 2026
11 checks passed
@roncodes
roncodes deleted the release/v0.4.1 branch September 16, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants