Skip to content

Release v0.4.4 - #184

Merged
roncodes merged 158 commits into
mainfrom
release/v0.4.4
Sep 28, 2026
Merged

roncodes merged 158 commits into
mainfrom
release/v0.4.4

Conversation

@roncodes

@roncodes roncodes commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Release branch for v0.4.4, cut from main.

This branch collects:

#180's stale v0.4.3 release metadata is replaced here. package.json is 0.4.4, and the first line of RELEASE.md names v0.4.4, so release.yml tags on merge. Merging this closes #180.

Before merging, switch Settings → Pages → source to "GitHub Actions" so the playground publishes (see #180).

roncodes added 30 commits August 9, 2026 14:05
Brings the addon from a suite that could not complete to 4968 passing
tests at 92.02% statements / 87.50% branches / 94.81% functions /
92.48% lines, with coverage measured on every CI run and uploaded to
Codecov so it cannot silently regress.

Test suite
- 401 test files covering every addon component, service, helper,
  modifier and util except schedule-calendar (dead code, see PR notes).
- Replaced 210 generated blueprint stubs with real tests.
- Zero skips, no must-stay-failing pins.

Coverage tooling
- ember-cli-code-coverage wired up behind COVERAGE=true.
- scripts/check-coverage.js enforces per-file 100% and fails on any file
  missing from the report; scripts/check-coverage-test.js self-tests it
  (9 cases) so the gate itself is covered.
- codecov.yml at 100% project/patch scoped to addon/; CI uploads
  coverage/lcov.info via codecov/codecov-action@v5 under the ember-ui flag.

Production fixes needed to make the suite runnable or to close a defect
the tests exposed. Notably: kanban/column read its drop position after
resetting it, so a card dropped between two cards always landed at the
end; thirteen unguarded {{on}}/{{fn}} handler bindings across five
components crashed at render time when the argument was absent.

Deleted addon/components/availability-editor.js (no template, no
re-export, no consumer). Added app/components/schedule-item-card.js, the
only component of 220 missing its app-tree re-export.

Remaining uncovered code is documented site by site in the PR comments;
no istanbul ignore comments were added.
Each fix is pinned by a test that fails against the previous code. Three
tests that had been written to pin the buggy behaviour are updated here
to assert the corrected behaviour instead.

- transition-to: prefixMountPoint asserted the same condition as the `if`
  wrapping it, so the guard could never fire and a non-string route name
  was interpolated into the route. Asserts `=== 'string'` now.
- resource-context-panel: open() dereferenced `definition` before calling
  #validateDefinition, so a missing definition died with a TypeError and
  the intended "Overlay definition is required" error was unreachable.
- set-height / set-max-height: a value with no numeric part (`auto`,
  `fit-content`) was reduced to the invalid string "px" and dropped by the
  browser, and a unit the modifier cannot convert (`%`, `vh`, `ch`) was
  parsed off and discarded, so `100%` silently became `100px`. Both are
  now applied verbatim.
- services/leaflet: `initialized` is assigned in exactly one place, inside
  the branch that only runs when `instance` is undefined — so a host that
  set an instance before load() left the polling interval running forever.
- custom-field/input: removed the isMoneyInput arm. <MoneyInput> reports
  `onChange(storedValue, detail)` with a number, so the arm's isObject()
  test could never pass, and it would have reported the formatted value
  where the raw arm correctly reports stored cents.
- query-builder/column-select: the alias field was a two-way <Input
  @value>, whose write-back landed after updateAlias and undid its
  trimming, mutating the aliases hash already handed to onChange. It is
  one-way now, committed on change.
- query-builder/actions: the three handlers passed `this.queryObject`,
  which does not exist on that component, so a standalone consumer always
  received undefined. Reads `this.args.queryObject` now.
- is-menu-item-active: removed the `slugOnly && view` branch, a
  contradiction since slugOnly already requires `view === null`.

4973 tests pass, 0 skips. Coverage 92.08% statements / 87.60% branches /
94.81% functions / 92.55% lines; the branch total drops from 6714 to 6704
as the dead branches go away.
Group C of the defect triage. Each fix is pinned by tests that could not
be written before it — three of them were written during the coverage
work, failed against the production code, and had to be deleted.

- filter/multi-option: `search` now RETURNS its matches, as power-select
  expects. The remote path called `this.fetchOptions(...)`, a Task object
  rather than a function, and threw; the local path assigned
  `this.options` from inside a modifier update, which raised a
  backtracking assertion and permanently discarded every non-matching
  option, so clearing the query could not bring them back. The four tests
  deleted for this are restored, including one asserting that clearing
  the query restores the full list.
- overlay: the resize clamps tested WIDTH whatever the position and
  returned before the horizontal/vertical fork, so a top/bottom overlay
  whose width fell outside [min, max] — which a full-width drawer always
  does — could never be resized, and each vertical drag silently rewrote
  its width to the clamp. Clamps the dimension being dragged now, with
  minResizeHeight/maxResizeHeight alongside the width pair.
- modal: `@usesTransition('_fade')` named a getter, but the decorator
  reads `this.args[prop]`, so `this.args._fade` was always undefined and
  `@fade={{false}}` never disabled the transitions. Names the argument.
- query-builder sort-by / group-by / conditions: the three `validate*`
  actions existed but nothing called them, so narrowing the selected
  columns left the panel sorting, grouping and filtering by columns that
  were no longer selected. Wired to `{{did-update}}` on the column list.
- attach/popover: removed the inert `isOffset` flag. It was never
  assigned from an argument or anywhere else, and the method it guarded,
  `isCursorBetweenTargetAndAttachment`, does not exist on the component —
  so had anything ever set it, every mousemove would have thrown.

Left for a product decision, unchanged here: model-select's infinite
scroll (#105) needs the options to come from ember-infinity, which is
feature work, not a repair; layout/resource/panel's redundant save path
(#98) duplicates a button panel/header-actions already renders.

4983 tests pass, 0 skips. Coverage 92.44% statements / 87.97% branches /
95.29% functions / 92.89% lines.
Fix eight contained defects found during the coverage work
Fix five behavioural defects; two need a product decision
…urce

Removes the members and files confirmed unreachable during the coverage
work. Four whole components go, along with 26 members across ten files.

Whole components (no template, no re-export, or no consumer anywhere in
the monorepo — verified by sweeping every package outside ember-ui):
- schedule-calendar and schedule-item-card
- custom-field/form
- report-builder/results-table

Members:
- query-builder: toggleQueryPreview, showQueryPreview, exportQuery
- query-builder/conditions: conditionsMessage, canAddConditions
- event-calendar: changeView, today, refetchEvents, refetchResources
- template-builder/properties-panel: the query-parameter editor
- smart-nav-menu: reorderPinned (passed as @onReorder to a customizer
  that ignores it; the customizer's own copy is wired and stays)
- smart-nav-menu/customizer: unpinnedItems
- dashboard/widget-panel: hoveredWidget, onHover, onUnhover
- custom-field/yield: toggleGroupEdit, resolveSubject
- custom-field/options-input: addMetaOption
- layout/resource/panel: save, saveButtonText, controller, onTabChange,
  onPressEdit, onViewDetails, and the now-orphaned resourceName and
  resourceType. The live save button is header-actions', driven by the
  caller's @saveTask; these were a second, unreachable implementation.

KEPT after checking: query-builder's `get columns()`. It was listed as
dead, but conditions.hbs gates its entire editor on `{{#if @columns}}` —
deleting it would blank that panel. Only the four bindings that group-by
and sort-by ignore are removed.

Also scopes the coverage gate to this package. A pnpm workspace link
(@fleetbase/ember-core) is instrumented by the same build and appeared in
the report as `../ember-core/...`, adding 561 failure lines and dragging
the reported global from 93.66% to 67.42%. check-coverage.js now skips
files outside the package and recomputes the global from first-party
entries rather than trusting istanbul's total. Self-test covers it (10
cases, up from 9).

4941 tests pass, 0 skips. First-party coverage 93.66% statements /
89.14% branches / 97.18% functions / 94.06% lines.
Both tooling recommendations from the defect review.

eslint-plugin-qunit, recommended config, scoped to tests/**/*-test.js.
`no-hooks-from-ancestor-modules` is the one that motivated it: a nested
`module('…', function (hooks) { … })` shadows the outer hooks, QUnit 3
turns that into a hard error, and the suite stays green when you do it.
The suite is already clean on that rule; the other 58 findings were
fixed rather than silenced, except `require-expect`, which is disabled
as noisy for a suite whose assertion counts are obvious.

Notable among those fixes: nine assertions hedged with `||` or `&&`,
which hid what the code actually does. Splitting them exposed four
tests asserting something untrue — the tab-navigation container carries
no `pills` class, element-renderer's style attribute is the raw authored
hex rather than a computed rgb(), the dropdown-button tooltip is not
rendered inside the trigger, and the canvas settings panel has no number
inputs at all (those are element-scoped). All four now assert what the
component does. Two more tests could not fail: the sticky-cell DOM test
and a canvas click test each had a branch that never ran.

A repo-local template-lint rule, no-unguarded-handler-argument, flags
`{{on "evt" @arg}}` and `{{fn @arg …}}` in the handler position. These
throw while rendering when the argument is absent, so a component with
one cannot be rendered without it. It accepts the guarded forms this
codebase settled on — `(or @arg (noop))`, `{{#if @arg}}`-wrapped
bindings, and `this.ownAction`.

It found nine unguarded bindings in the addon that the manual sweep
missed, in modals/bulk-action-model, modals/bulk-delete-model,
modals/import-form, modals/save-report, overlay/header (two),
table/cell/link-list and layout/header/dropdown/item (two). All nine are
now guarded. The rule is off for test fixtures, which supply their own
handlers by construction.

.template-lintrc moves to .mjs: ember-template-lint 5 is ESM-only, so a
CommonJS config cannot require the Rule base class.

4940 tests pass, 0 skips. First-party coverage 93.69% statements /
89.22% branches / 97.18% functions / 94.10% lines.
Fixes the backtracking-rerender assertion recorded as DEFECTS.md #26,
which the documented one-line fix (two-way to one-way bindings) did not
clear — the binding style was the symptom, not the cause.

Two things were wrong. Every handler mutated `this.translations` in place
and then reassigned the same reference, writing to a tracked property the
render was still consuming. And `{{#each-in}}` keyed the rows on the
translation KEY, so typing in a key field destroyed the input and rebuilt
it on the next render, mid-edit.

The editor now holds `{ language: [{ id, key, value }] }` rows. A row's
identity survives renaming its key, and every edit builds a new structure
and assigns it once, so nothing writes to state that a render is reading.
The fields are one-way and commit on `change`.

`setDefaultKeys` no longer writes the defaults into the caller's own
`@value` — it returns a new object. That was a side effect on the
consumer's data, now pinned by a test.

The public surface is unchanged: `@value` in, `@onChange(translations)`
out, both in the `{ language: { key: value } }` shape, plus `@defaultKeys`
and the label arguments. The internal `loadedTranslations` getter is gone;
it was only read by this component's own template.

Six tests added, covering what the old design could not express: the same
input element survives a rename, a key can be renamed twice in a row, a
key edit followed by a value edit reports both, rows edit independently,
keys are underscored on commit, and the caller's object is never touched.

4946 tests pass, 0 skips. Coverage 93.71% statements / 89.21% branches /
97.18% functions / 94.11% lines.
Drops the prerelease dependency. `ember-radio-button` was pinned to
3.0.0-beta.1 because its last stable release, 2.0.1, references the
removed `Ember` global and throws on Ember 5 — and the package has not
been updated in four years.

Adds `radio-button` and `radio-button-input` to the addon, keeping the
component names, arguments and rendered DOM identical to the upstream
addon, including the `ember-radio-button` label class that existing
stylesheets target. The two call sites — modal/layouts/option-prompt and
custom-field/input — are unchanged, and their tests pass untouched.

The full surface is preserved: @value, @groupValue, @Changed, @name,
@disabled, @required, @autofocus, @tabindex, @radioClass, @radioId,
@classnames (string or array), @checkedClass, @ariaLabelledby and
@ariaDescribedby; block form wraps the input in a label, inline form
renders the input alone. Selection is compared with `isEqual` and
reported through `once`, both as upstream, so equality semantics and
callback timing are unchanged.

radio-button.hbs carries a scoped template-lint disable for
no-autofocus-attribute and no-positive-tabindex. Both rules target an
author hardcoding those values; here they are pass-through bindings, and
removing them would change the component's argument list.

20 tests, both components at 100% coverage. 4964 tests pass, 0 skips.
`pnpm run build` exits 0.
`<ModelSelect>` advertised `@infiniteScroll` but nothing behind it ran. The options
component rendered ember-infinity's `<InfinityLoader>` against `@infiniteModel`, which
was passed as `infiniteModel=this.model` — a `@tracked` property declared on the
component and never assigned anywhere. It was therefore always `undefined`, so the
loader's `{{#if}}` never opened. ember-infinity was not a dependency either, so the
component would have failed to resolve had the branch ever been reached (DEFECTS #105).

Paging is native now, with no new dependency:

  - `loadModels` records the term it loaded for and asks the server for page 1, then
    decides whether another page exists — preferring the total the server reports and
    falling back to "the server filled the page", which is all the custom search
    endpoint gives us.
  - `loadMoreOptions` is a `dropTask`, so overlapping scrolls collapse into one
    request. It repeats the current term at the next page and appends the results.
  - The options component watches ember-basic-dropdown's content element (the thing
    that actually scrolls) and calls `@onLoadMore` within 32px of the bottom. The
    listener is torn down through `registerDestructor`.
  - A spinner row marks the page in flight, styled alongside the existing spinner
    rules and following the same `ember-model-select__*` naming.

A new search restarts at page 1 and keeps the term, so scrolling a filtered list pages
through the filtered results rather than the unfiltered ones.

Tests: 13 covering a full first page paging into a second, a short page ending paging,
the server's reported total ending it, `@infiniteScroll={{false}}`, a new search
restarting the sequence, overlapping scrolls dropped, a scroll short of the bottom
doing nothing, reaching the bottom of a complete list doing nothing, and the spinner
appearing while a page is in flight and going once it lands. The options test file's
two loader tests asserted the old inert behaviour and now assert the real thing.

Full suite 4975 pass / 0 fail / 0 skip. Coverage 93.73% statements, 89.28% branches,
97.24% functions, 94.13% lines.

Two branches in options.js stay partial: the null guards around `scrollable`. Inside a
rendered dropdown `closest('.ember-basic-dropdown-content')` always resolves, so they
are defensive only — documented in the source rather than suppressed. Two more in
model-select.js are pre-existing and recorded as DEFECTS #163: both task permission
guards are unreachable, because denying permission also sets `disabled` and
power-select refuses to open a disabled trigger.
`table/cell/resource-identity.hbs` rendered its trigger with a literal
`class="flex min-w-0 items-start gap-2 text-left py-0.5"`. Every other visual aspect of
this component is a column option with a default — `wrapperClass`, `imageSizeClass`,
`imageRoundedClass`, the five `statusBadge*` getters — but the trigger's padding was the
one thing no argument could reach, so the identity cell could not be compacted for a
dense table however its column was configured (DEFECTS #108).

The committed test said as much: it asserted `doesNotHaveClass('py-0.5')`, i.e. the
template and its own test disagreed about whether the compact variant carries vertical
padding. That mismatch shipped.

Fixed the way the rest of the file already works — a getter reading the column with a
default:

    get triggerClass() {
        return this.column.triggerClass ?? (this.compact ? 'py-0' : 'py-0.5');
    }

`column.compact` is the shorthand, so a caller need not know which padding class to
drop; `column.triggerClass` replaces the padding outright and wins over the shorthand.
`triggerClass` is the name this addon already uses for a trigger's classes
(country-select, visible-column-picker, dropdown-button, content-panel).

Default output is unchanged, so no existing table shifts. `py-0` is picked up by
Tailwind, which scans `./addon/**/*.{hbs,js}`.

Tests: five covering the default, the compact shorthand, an explicit triggerClass, the
precedence between them, and the layout classes surviving either way. The pinned
assertion is unpinned.

Full suite 4980 pass / 0 fail / 0 skip; the component has no uncovered statements.
You confirmed on production that switching tabs reloads the subject every time. Two
separate causes, one already fixed on this stack and one not.

The first is the wiring: `custom-fields-manager.hbs` on main passes
`@onTabChange={{perform this.loadCustomFields}}`, which fetches unconditionally and
never reaches the `onTabChange` action holding the already-loaded guard. That is #97,
fixed earlier in this stack.

The second survives that fix. The guard read:

    if (subject && (!subject.groups || subject.groups.length === 0))

`groups.length === 0` cannot distinguish "not fetched yet" from "fetched, and this
subject genuinely has no field groups". A subject with no groups configured is
therefore refetched on every single tab selection, forever — the same symptom, with the
wiring corrected.

Replaced the inference with a record of what has actually been fetched: a private
`#loadedSubjects` set, added to after a successful load and after a cache restore. A
load that fails is not recorded, so the next tab selection retries it.

While in `restoreFromCache`:

  - `await loadCompany()` ran once per subject inside the loop. Hoisted; a company that
    will not load now reports once and stops, instead of failing identically for every
    subject.
  - It read `cachedManager.groups`, the raw category array. The load path stores
    `customFieldGroups`, the grouped form that carries each group's fields. Reading the
    same accessor the load path uses keeps a restored tab identical to a fetched one
    rather than relying on the two arrays sharing model instances.
  - Dropped the `cachedManager &&` check. `forSubject` builds an empty manager on a
    cache miss and never returns nothing, so that half could not fail.

The test double for the registry returned `null` from `forSubject`, which the real
service never does. It now returns an empty manager, as the service does.

Tests: three new — an empty subject fetched once however often its tab is selected, a
failed fetch retried on the next selection, and a company that will not load stopping
the restore before any lookup. The first two fail against the previous guard (verified:
31 pass / 2 fail) and pass against this one.

Full suite 4983 pass / 0 fail / 0 skip.
…nreachable

Working the 77 files whose remaining coverage gap was three sites or fewer. This is the
first pass: 29 of them are now clear.

Tests (13 new, no production behaviour touched):

  - docs-panel: close, open-externally and the iframe-failure fallback. All three
    actions are thin delegations reachable only through the panel's own controls, and
    none were exercised. Three details worth recording for the next person: FontAwesome
    renders `times` as `xmark`; the service calls the real global `window.open`, not
    ember-window-mock's; and an `error` event dispatched at an element still reaches
    `window.onerror`, which QUnit installs, so it has to be suppressed for the duration
    or the test fails on the event it is deliberately raising.
  - layout/resource/cards-grid: the yielded card. `cardClass` is only read when the
    block renders a card, so the hash in the template was never built.
  - layout/header/sidebar-toggle: the disabled toggle, both when disabled by argument
    and when disabled by the sidebar service, plus the optional onToggle callback.
  - file-icon: an upload file with no underlying file, and filenames with no extension
    at all — both arms of the extension lookup fall back to null and every existing
    fixture carried a well-formed name.
  - table/th: a header rendered with no column at all.

Coverage exclusions (25 + 14, each line-specific and justified inline):

  - `@tracked` field initializers whose value is assigned before it is ever read. With
    Ember's decorators the initializer is lazy, so it is never invoked. Applied only
    after checking each field really is assigned elsewhere; `modal/dialog`'s `@ref`
    field was correctly skipped by that check and is left alone.
  - Helper and modifier parameter defaults. Glimmer always passes both the positional
    and named arguments, so those defaults cannot be reached from a template.

Three sites were investigated and deliberately NOT covered, because they cannot run:

  - table/foot's `element instanceof HTMLElement` — `offsetElements` is a literal array
    of two strings.
  - truncate-pages' `if (res.length > 0)` — `res.push(currentPage)` above it is
    unconditional, so the array is never empty.
  - sidebar-toggle's `if (this.isDisabled) return;` — the template renders
    `disabled={{this.isDisabled}}`, so the button cannot be clicked when it is true.

Also noted: `overlay/header`'s `useEllipsis` getter is referenced by no template. The
header gates on `@overlay.isMinimized` instead. Left in place pending a decision.

Full suite 4996 pass / 0 fail / 0 skip.
Coverage 93.99% statements (was 93.68%), 89.32% branches (89.19%), 97.40% functions
(97.25%), 94.42% lines (94.08%).
Batch A remaining: 48 files, 22 statements, 58 branches.
…rded

Continuing the ≤3-site files. Eleven more are clear.

Tests (6 new):
  - tabs/tab: an inactive tab, which withholds both the active class and the pane entirely.
  - with-record: a rejection carrying no message, which falls back to a generic one. Not
    every rejection is an Error.
  - table/cell/media-name: an alt-text path with no row to read it from.
  - bulk-search-dropdown: clearing and searching with no handlers supplied at all.
    `dropdown-fn` closes the dropdown on click, so the assertions reopen it first.

Coverage exclusions (8 more, each justified inline against something checked, not assumed):
  - tabs/tab's inactive pane class — the pane it lands on is itself inside
    `{{#if this.isActive}}`.
  - table/foot's `element instanceof HTMLElement` — `offsetElements` is a literal array of
    two strings.
  - truncate-pages' `if (res.length > 0)` — `res.push(currentPage)` above is unconditional.
  - sidebar-toggle's disabled early return — the template renders
    `disabled={{this.isDisabled}}`.
  - model-tag-input's non-array fallback in removeTag — a non-array attribute renders no
    tags, so there is no remove control to click.
  - modal/title-with-buttons' `dd?.actions?.close` guard — the template only calls it as
    `(fn this.handler option dd)` inside a DropdownButton.
  - unwrap-coordinates' `window.leaflet || window.L` — evaluated at module import.
  - extensions-list and smart-nav-menu/item's `router ?? hostRouter` — `router` is a
    framework service and always resolves.

Two defects found by writing the tests, both recorded rather than fixed (#165, #166):

  - chat-window/attachment throws on any filename without a dot. `getExtension` returns
    null and `getIcon` passes it straight to `getWithDefault`, which asserts on a non-string
    key — so README, Dockerfile or LICENSE cannot render at all. <FileIcon> guards the
    identical case two files away. The test that reaches it is one asserting a crash, which
    would pin behaviour that should change, so the branch stays uncovered and the reason is
    recorded.
  - Two getters no template references: overlay/header's `useEllipsis` (the header gates on
    `@overlay.isMinimized`, so its 15-character threshold has no effect) and
    report-builder/condition-value's `isBoolean` (the template has no boolean arm, so a
    boolean column gets a free-text field). The second may be a missing editor rather than
    dead code, which is a product call.

Full suite 5001 pass / 0 fail / 0 skip.
Coverage 94.00% statements, 89.49% branches, 97.40% functions, 94.42% lines.
Batch A remaining: 37 files, 20 statements, 45 branches.
…g clipboard

Nine more of the ≤3-site files are clear.

Tests (5 new, all confirmed against the coverage report rather than trusted because they
passed):
  - layout/resource/tabular: cycling a sort back off. The legacy single-column `sortBy`
    and `sortDirection` fields fall back to null once nothing is sorted, which only
    happens on the third click.
  - locale-selector-tray: an explicit `@renderInPlace`, both true and false. The getter
    only consults the argument when it is an actual boolean and the viewport is not
    mobile; the module-level tests run against the real media service, and the mobile
    stub is scoped to one nested module.
  - click-to-copy: a failing `document.execCommand` in the fallback path, which is
    reported rather than thrown.
  - smart-nav-menu/dropdown: an item with no title, matched on its description instead.

Coverage exclusions (10 more, each checked against the code that makes it unreachable):
  - report-builder/export-options' `if (!this.args.disabled)` — the Export button is
    rendered `@disabled={{@disabled}}`, so it cannot be clicked while that is true. Same
    shape as sidebar-toggle.
  - table/cell/point's `if (column)` — `isClickable` is only true when the column carries
    an onClick or action, and only then does the template render something clickable.
  - table/foot's `typeof element === 'string'` — the companion to the instanceof guard;
    every entry is a string literal, so this one is always taken.
  - table/th's `if (!column)` — `showSortPriority` short-circuits on `isSorted`, so the
    getter is never evaluated without a column.
  - chat-tray/conversation-row's `participants.length ?? 0` — `participants` always
    returns an array.
  - widget/count's and vertical-offset-by's parameter defaults — each has exactly one
    caller and it always passes both arguments.
  - transition-end's `if (backup)` — `done` removes its own listener and nulls `backup`,
    so it only ever runs once, with it set.
  - sidebar-navigator's `router ?? host-router` — `router` always resolves.
  - register-report-widget's lookup failure and missing-service return — the widget
    service resolves in every booted app.

Full suite 5006 pass / 0 fail / 0 skip.
Coverage 93.98% statements, 89.57% branches, 97.40% functions, 94.40% lines.
Branches are up from 89.19% at the start of Batch A.
…ers on their own terms

Three more files clear, and the `did-update` family covered properly.

Tests (5 new):
  - checkbox, filters-picker/button and full-calendar/draggable each wire a handler as
    `{{did-update this.handler @arg}}` and destructure it with a default. That default is
    not dead code and is not a framework artefact: it applies when the argument changes
    *to* undefined, which is what a caller clearing its state does. One test each —
    clearing @value unchecks the box, clearing @buttonComponentArgs drops the
    active-filter badge, clearing @disabled makes the draggable draggable again.
  - kanban's drop handlers read the same configuration as its drag-start handlers but
    from their own code paths, so a custom columnIdPath was covered when a card lifted
    and not when it landed. Added the landing case, plus a column drop with no
    onColumnMove handler at all.

One exclusion: kanban's `onCardDrop(targetColumnId, targetPosition = null)` — the only
caller is kanban/column.js, which always passes the position.

Full suite 5011 pass / 0 fail / 0 skip.
Coverage 94.01% statements, 89.71% branches, 97.40% functions, 94.43% lines.
Batch A remaining: 22 files, 16 statements, 26 branches.
…d, and the last guards

Tests (3 new):
  - layout/resource/panel: two tests supplying a @saveTask. The `authSchema` getter was
    never evaluated at all, because Glimmer evaluates arguments lazily and its only
    consumer is the header's save button, which does not render without a save task.
    Uses the saveTaskHost pattern already established in header-actions-test.js.
  - chart: a component torn down while its dataset loader is still in flight. That guard
    is genuinely reachable — it is why it exists — so it gets a test rather than an
    exclusion: render against a promise the test controls, clearRender, then resolve.

Coverage exclusions (11), each traced to the caller that makes it unreachable:
  kanban/column's missing column body; layout/header/dropdown's and unit-input's
  dropdown-close checks (the template and PowerSelect always supply the actions);
  model-coordinates-input's child registration via @onInit; date-picker's node ref;
  modal/dialog's did-insert element and its @ref field; and the owner/service guards in
  is-dark-mode, get-universe-components and get-universe-menu-items.

Two corrections to the previous pass, both mine:
  - The table/th exclusion had landed on the WRONG getter. A scripted insert matched the
    first `if (!column) {` in the file, which is `sortColumn`'s — and that guard is
    exercised by the no-column test added earlier, so the exclusion was suppressing a
    real result instead of recording an unreachable one. Moved to `sortPriority`, which
    is the getter genuinely never evaluated without a column. The report now shows
    table/th at 0 uncovered statements and 0 partial branches with only that one
    exclusion, which is what confirms the fix.
  - Ignoring an `if` does not cover a `return` that follows it. The trailing fallbacks in
    is-dark-mode, get-universe-components, get-universe-menu-items and sidebar-navigator
    each needed their own comment.

Full suite 5014 pass / 0 fail / 0 skip.
Coverage 94.06% statements, 89.87% branches, 97.45% functions, 94.47% lines.
Batch A remaining: 10 files, 7 statements, 10 branches.

Branches read marginally lower than the previous pass (89.87% against 89.93%) because
removing the misplaced table/th exclusion restored a real partial branch to the report.
That is the correction working, not a regression.
…modal never resolved

modal.js was the largest single remaining file (13 statements / 14 branches). Its gap
turned out to be three unrelated things, only one of which was a real test gap.

Tests (4 new). `<Modal>` declares `keyboard` and `backdropClose` and passes them to
<Modal::Dialog>, which reads them only from inside `handleKeyDown` and `handleClick`.
Glimmer evaluates arguments lazily, so until one of those handlers fires through a real
<Modal>, the modal's own `@arg` defaults are never resolved — which is why they read as
never-initialised. The existing dialog tests render <Modal::Dialog> directly with the
arguments passed explicitly, so they never exercised the modal's side of it. Added:
escape closing the modal by default and being ignored under @keyboard={{false}}, and the
same pair for clicking the backdrop area.

This is the same lazy-argument shape as layout/resource/panel's `authSchema` in the
previous batch. Worth recognising on sight: an argument that is only read inside a
handler is not evaluated until that handler runs.

Exclusions (6): every `isFastBoot(this)` guard, plus the SimpleDOM branch of
`addBodyClass` behind one of them. This suite runs in a browser, where isFastBoot() is
always false, so neither arm can be reached.

Left uncovered and NOT excluded — six teardown and re-entrancy guards: `_isOpen`, three
`isDestroyed` checks sitting after awaits, and two `!modalElement` checks. These are the
same shape as chart's teardown guard, which was genuinely reachable and got a test last
batch. Reaching these needs the component destroyed mid-transition, and `render()` awaits
settled, so the transition has already finished before a test could tear it down. They
are reachable in principle, so excluding them would be wrong; recorded here instead.

Full suite 5018 pass / 0 fail / 0 skip.
Coverage 94.13% statements, 90.06% branches, 97.45% functions, 94.55% lines.
Branches are past 90% for the first time.

Batch B remaining after this file: 79 files, ~308 statements, ~357 branches.
Adds a `<SignaturePad>` component wrapping signature_pad v5, and registers
`signature-pad` as a custom field type so any subject with custom fields can
collect a signature.

The component draws on a canvas with mouse, stylus or touch and emits the
result as an image data URL. The canvas backing store is refit to its
container at the device pixel ratio, so signatures stay crisp on retina
displays and survive layout changes without losing ink.

Notable behaviour:

- Resizing repaints rather than wipes. Assigning `canvas.width` clears the
  bitmap and resets the context transform, so the background, any hydrated
  image and the vector strokes are redrawn in order afterwards.
- A hydrated signature survives undo. `toData()` cannot round trip a raster,
  so the loaded data URL is tracked separately; undo on a raster-only pad
  degrades to clear.
- `@value` does not feed back on itself. Without the guard, binding `@value`
  to `@onChange` output would flatten strokes into a raster after every
  stroke and break undo.

As a custom field, a signature debounces after the last stroke, uploads as a
PNG through the existing Files API, and stores the same `file:<uuid>`
sentinel the file-upload type uses, so no server change is required.

signature_pad's `exports` map hands CommonJS consumers a UMD build, and
ember-auto-import resolves through a CJS entry. Webpack's interop then double
wraps it so the default export is not constructible, hence the webpack alias
in index.js pointing at the ESM build.

Also adds ember-tracked-storage-polyfill as a devDependency, matching main.
Without it `ember-file-upload`'s file-queue helper cannot load and
`<FileUpload>` fails to render in the dummy app.

Tests: 38 integration tests for the component, 5 for the read-only custom
field value, and 6 unit tests for the type map. The custom-field/input
integration tests are written but skip in the dummy app, which cannot resolve
`@fleetbase/ember-core`.
Delete confirmed dead code; scope the coverage gate to first-party source
Adopt eslint-plugin-qunit and a rule for unguarded handler arguments
Rebuild translations-editor around stable rows
Replace ember-radio-button with a native implementation
Make model-select's infinite scroll actually work
Let the resource-identity cell actually compact
Stop custom-fields-manager refetching a subject on every tab change
Batch A: close the small coverage gaps, record the unreachable ones
The previous tracker sat untracked in the monorepo root, so it was invisible to reviewers
and to version control. This one lives with the code and shows up in pull-request diffs.

Seeded with the five items still open or awaiting a decision, plus a "settled" section
recording four things that are NOT defects — including two the first phase reported
wrongly (sticky table columns, which work; and the resource panel's unwired save task,
which is deliberate). Recording those stops them being "fixed" again.

The format section makes the standard explicit: prove the claim before writing the entry,
because "not referenced by a template", "dead code" and "broken" are three different
findings with three different owners.

Entries 1-166 from the first phase stay in ../../DEFECTS-ember-ui.md as history; 100 of
those are already fixed. Numbering here restarts at 1.
…decision

Eight tests, six exclusions, one defect recorded. Gaps 24 -> 3.

Tests:
  - the sort comparator's final `?? 0` fallback, which only evaluates when the SECOND
    channel it is handed has neither timestamp;
  - a channel with no `participants` key at all, reaching the search's `?? []` fallbacks;
  - a `chat.participant_removed` event, whose switch arm no test entered;
  - a failed teammate load, which leaves an empty list instead of throwing;
  - the notification sound playing for another participant's message and staying silent
    for the user's own;
  - the tray rendering its own declared defaults while channels and contacts are still
    loading.

That last one matters more than it looks. `channels`, `unreadCount` and `availableUsers`
read as never-initialised, which is the same signature as the lazy `@tracked` defaults
excluded in Batch A. They are NOT that: the constructor assigns them inside an async
`withChannels` callback, and it only looks synchronous because every existing stub
resolves immediately. In production the template renders against the declared defaults
first. Excluding them would have recorded a test-harness artefact as a property of the
source, so they got a test instead.

Exclusions, each traced to the specific thing that makes it unreachable:
  - `defaultNewChatName`'s 'Untitled Chat' and its `>1` branch — `createChat` is the only
    caller and early-returns on an empty selection, the sole condition reaching them;
  - `createChat`'s empty-selection guard — the Create button renders
    `@disabled={{not @Cancreate}}`;
  - two `store.push()` null checks — it always returns a record;
  - `channels ?? []` — declared `[]`, only ever assigned arrays;
  - `unlockAudio`'s catch — `notificationSound` is constructed unconditionally and none of
    the three calls throw synchronously;
  - `availableUsers`'s default — its only reader is compose-panel's `{{#each @users}}`,
    behind `{{#if @isloading}}`; that gate closes exactly when the task assigns the value.

DEFECTS.md #6: `getUnreadCount` is a second, unwired implementation of the unread count.
The badge is not broken — `countUnread` already computes it from the loaded channels — but
the two differ, since the task fetches a server total that would also cover channels not
currently loaded. Needs a decision rather than a fix, and the three remaining gaps in this
file are exactly that task: dead code cannot be covered, and excluding it would hide the
question instead of answering it.

Full suite 5025 pass / 0 fail / 0 skip.
Coverage 94.22% statements, 90.18% branches, 97.44% functions, 94.65% lines.
SCHEDULING_COMPONENTS.md documented nine things — ScheduleCalendar, ScheduleItemCard,
AvailabilityEditor, five models and a scheduling service — none of which exist in the addon. A
reader following it would reach for components that do not resolve.

docs/components was a second copy of the component reference, 84 files that no longer agreed with
the published documentation and were linked from nowhere once the README started pointing at
fleetbase.io/docs/ui. The official site is the reference; the playground is where you try the
components against your own arguments.

Both remain in the history if any of it is wanted back.

docs/brand and docs/playground are untouched — the README's mark and the playground screenshots
still resolve.
DEFECTS.md is no longer in the repository, so 56 pointers to it across 47 files led nowhere. The
explanations around them are worth keeping — they record why a test exists or why a branch is
unreachable — so only the dangling reference is removed and the reasoning stays.

The pointers were not only in tests: addon source, the coverage scripts and testem.js carried them
too, and leaving those would have been the same dead pointer in more visible places. Changes to
addon/ are comment-only; no behaviour is touched.

Three `istanbul ignore next` reasons in addon/ mentioned the log. Those directives are load-bearing
for the 100% gate, so the `-- reason` form is preserved and the directive count is unchanged at 550.

Verified: lint clean, 5757 tests passing, and the coverage gate still reports 100% statements,
branches, functions and lines across all addon files.
The v0.4.1 slot is taken by the resource identity release (pills, one-line
identity cells, hover summaries and select options), which is smaller and
ships first. Move the playground, signature pad and coverage release to
v0.4.2 so release.yml tags it correctly when it merges.
The previous commit was created from a linked worktree whose parent
repository sets core.worktree, so git staged package.json and RELEASE.md
from a different checkout and reverted them to the 0.3.41 placeholder.
Put the real v0.4.1 manifest back and bump it to v0.4.2: the v0.4.1 slot
is taken by the smaller resource identity release (pills, one-line
identity cells, hover summaries and select options), which ships first.
release.yml tags on merge only when package.json and the first line of
RELEASE.md agree with the branch name.
The v0.4.2 slot is taken by the patch that restores the tooltip and popover
hidden state in production builds, which ships first. release.yml tags on
merge only when package.json and the first line of RELEASE.md agree with
the branch name, so both move to v0.4.3 with the branch.
Brings the v0.4.1 and v0.4.2 releases — the resource identity families, the
model-multiple and radio filters, the build-performance work and the tooltip
production-CSS fix — onto the release branch, and holds the whole result to
the 100% coverage gate this release introduces.

Conflicts, and how they were settled:

- package.json / RELEASE.md: the release manifest stays on v0.4.3. The v0.4.2
  notes shipped under their own tag and do not belong in these notes.
- .github/workflows/ci.yml: kept this branch's restructured CI (it carries the
  coverage gate, the playground artifact check and the Codecov upload) and
  folded in main's `release/v*` push and pull_request triggers.
- index.js: took main's `lib/postcss-options` refactor, which carries the
  nesting-flatten fix, and re-applied this branch's coverage babel plugin and
  the signature_pad ESM alias.
- attach/popover.js: kept main's `hidePending` behaviour (a popover stays open
  while the pointer crosses into it) without reinstating the dead `isOffset`
  clause this branch had removed. Both sides had added a `willDestroy`; the
  merge left two definitions, so they are folded into the one that also
  cancels the pending timers.
- layout/resource/tabular-actions.hbs: took main's `this.setColumns` (which
  stops the picker writing to a getter) and kept this branch's guard against
  rendering a picker with no columns, which its tests require.
- tests/dummy/app/router.js: the union of both route sets, with the catch-all
  left last.
- The conflicted test files keep both suites: this branch's coverage tests plus
  main's tests for the new behaviour.

Two defects the merge itself created, both fixed here:

- `setIsVisibleAfterDelay` re-enters itself from a requestAnimationFrame while
  the attachment is unrendered. Reading main's new `floatingPointerEvents`
  computed on a component destroyed in that window threw, which aborted the
  whole test run after 65 tests. It now returns early when destroyed.
- A cleared model filter was reported twice: this branch had taught ModelSelect
  to report its own clear through @onclear, and main had separately taught
  filter/model to treat a null change as a clear. The general fix is kept and
  the duplicate report removed.

Coverage: main's resource work arrived without the 100% gate that this release
adds, so it lands here with tests — filter/radio, resource/pill, summary,
select-option and hover-card, table/cell/identity, resource-identity,
resource-descriptors/core and generate-uuid. 6082 tests pass and the gate
reports 100% statements, branches, functions and lines.
The coverage job failed on ten tests, every one of them waiting for a hover
card that never appeared. `Resource::HoverCard` deliberately never arms on a
device that cannot hover, and a headless CI Chrome reports `(hover: none)` as
matching while a local windowed one does not — so these passed locally and
timed out in CI. The two tests that stub `matchMedia` themselves, including
the one covering the touch-device branch, passed in CI throughout, which is
what identified the cause.

These tests had never run in CI before: main's workflow ran
`test:ember --filter='!Integration'`, and the full suite only started running
when this release added the coverage job.

`setupPointerDevice(hooks)` answers `(hover: none)` with "this device can
hover" and delegates every other query to the real `matchMedia`, so unrelated
media queries — `prefers-reduced-motion` in sidebar/navigator, for one — keep
working. The tests that cover the touch-device branch stub `matchMedia`
themselves and are unaffected, since they restore what they replaced.

Verified by reproducing the CI environment locally: with `matchMedia` forced
to report `(hover: none)` at boot, the suite fails exactly as CI did without
this helper and passes with it.
Brings in v0.4.3 from main: btn-auth, OauthProviderButton/Logo, the keyless
OpenStreetMap tiles for CoordinatesInput, digits-only PhoneInput, and the CI
concurrency/timeout changes.

Conflict resolution:
- RELEASE.md: keep both sides' highlights.
- coordinates-input.js: take main's leafletTileSource logic, keep this branch's
  registerDestructor import and istanbul annotations.
- phone-input-test.js: keep this branch's suite and append main's digits-only
  tests (main's "renders a tel input" duplicated an existing assertion).

Follow-ups the merge needed to keep the suite green and the 100% gate intact:
- coordinates-input-test.js: assert the OpenStreetMap default instead of the
  CARTO/Stadia URLs main removed.
- tests/dummy/config/icons.js: register apple and github from the brands pack;
  main's OauthProviderLogo test for the GitHub icon could not pass without it
  (main's CI skips integration tests, so it never ran there).
- phone-input.js: annotate the caret fallback for inputs without a selection,
  which a tel input never takes.
…the console

Restore custom-field/form. The dead-code sweep on this branch missed a
string-resolved consumer: ember-core's custom-fields registry opens the
panel and modal with `content: 'custom-field/form'`. Its unreachable save
task is dropped; the meta setter does one write; a real test suite
replaces the placeholder.

resource-context-panel: the backdrop template called the service's
getActive as a helper, which runs without `this`. Read it through a
component getter instead, and make the test stub read through `this` so
an unbound call fails in tests the way it failed in the app.

signature-pad: add @ondone, which renders a Done button; the custom-field
input uploads only from it instead of after a debounce on every stroke.
Done is enabled only while the ink has changed since the last Done or an
outside value. A <:status> block renders at the left of the toolbar; the
input uses it for uploading / saved / download instead of a File chip,
which is unreadable for a transparent png in dark mode. A stored
signature hydrates the pad through the API's files/download (its storage
url does not answer CORS for a crossOrigin canvas image), keeps strokes
drawn before it lands, and is never destroyed by clear or re-sign.

custom-field/yield: a group with no stored grid size is one column, as
the manager stores and shows it, and the data-built grid classes are
safelisted for Tailwind.

custom-field/value: read the value live from the subject rather than
once at construction; prefer the persisted value record over the unsaved
twin a save leaves in the hasMany; resolve a `file:<uuid>` reference
through the store (a record edited in this session keeps the reference
after the parent saves, since Ember Data does not overwrite a changed
attribute from the parent's response); fill the column; offer download.

money-input: an outside @Currency change reformats without writing the
currency back to the model mid-render, and the initial default is
reported after render, so Ember's "already used in the same computation"
assertion no longer fires after a save.

New utils: fetch-file-as-data-url, file-sentinel-id,
find-custom-field-value, each with unit tests. The dummy fetch stub gains
host, namespace, credentials and headers; the dummy icons config registers
the brands pack.

Also carries the yield.js change that hyphenates the subject type so
fields saved as `<ext>:<model-name>` load.
The report builder could not answer "which products sold the most" or "what
did orders total this month": computed columns were never offered to the
group-by, sort or condition panels, a grouped report could not be sorted by
its aggregates, and a saved report lost its computed columns when reopened.

- Computed columns join the columns the group-by, sort and conditions panels
  pick from, identified by name (they have no joined `full` path).
- A grouped report can be sorted by the result of its groupings ("Sum of
  Quantity"), named the way the server aliases the aggregate.
- New Count Distinct aggregate.
- Summary columns from the schema (e.g. Total Orders, flagged `aggregate`) are
  kept out of group keys, aggregates and conditions, and no longer crash the
  sort labels.
- Loading a saved query restores its computed columns; changing the table or
  clearing the query resets them, and the computed-columns panel follows.
- The group-by and sort guards check the columns they actually offer, so a
  listed column can always be added (only a stale pick is refused).
- The computed column editor lists the JSON, DATE and DECIMAL helpers and shows
  how to read a JSON total, bucket by month and reference related columns.
…olumns

Order item quantity is no longer a schema column (meta has no fixed shape),
so the related-column example reads it from payload.entities.meta instead.
… report builder

- Typing a condition value (or either end of a range) re-rendered the whole
  condition group, because updating it replaced the group object the
  drag-sort list keys on; the input was rebuilt and lost focus after every
  key. Typed values are now stored in place; selects still re-render.
- Checkbox only re-synced when @value changed, so a column removed through a
  "Selected Fields" chip lost its highlight but stayed checked. It now follows
  @checked too.
- The column picker matches selected columns by name, so a column restored
  from a saved report is deselected instead of added a second time.
- Summary columns (e.g. Total Orders) are listed in their own "Summaries"
  section, explaining that they give one value across the matching rows, or
  one per group, rather than a value per row.
The default modal's own rules forced the dialog to the viewport height
(`h-full`) and centred the content in it, so a modal taller than the
viewport had its header pushed above the top edge and its footer below
the bottom, and lost its bottom margin. The dialog now has a minimum
height instead: a modal that fits is still centred, a taller one grows,
keeps 2.5rem above and below, and the overlay scrolls.

Focusing the dialog on open no longer scrolls it, so a tall modal cannot
open in the middle of its content.

The automatic body-scrolling added in the previous commit is removed: it
needed runtime measurement and a class change after the open animation,
which is what caused the jump. The opt-in @scrollable stays, and now
works for the default modal too, whose inline-block content had broken
it.
… the modal

The manager has declared `scrollable`, `position` and `size` among its
default options all along, but Modal::Default never passed them to
<Modal>, so `modalsManager.show(name, { scrollable: true })` was silently
ignored. They now reach the dialog: `scrollable` pins the header and
footer and scrolls the body, `position: 'center'` centres the dialog,
`size` picks its width.
Confirming the editor with the column name left empty passed validation
(which only checks the expression), but save() returned nothing and the
list saved it anyway. The empty entry had no name or label, and the edit,
delete, group-by and sort panels all threw on it.

- Only a column save() actually returns is added; otherwise the modal stays
  open and lists what is missing (a label, a name, an expression).
- The column name is optional: it defaults to the label, and whatever is
  typed is normalised to the identifier the server accepts
  ("Order Month" -> order_month). The placeholder shows the name it will get.
- Renaming a column while editing replaces it rather than adding a copy.
- Empty entries in a loaded report's computed columns are dropped, so a
  report saved with one still opens.
The default dashboard mixes default widgets from every extension, and the
grid auto-places them in order, so the layout depended on which extension
happened to boot first. A widget definition may now carry a numeric `order`:
ordered widgets come first, lowest first (ties keep registration order), and
widgets without one follow in registration order.
- Layout::Resource::Tabular bulk actions ignored bulkAction.permission, so
  users could run bulk delete/cancel/dispatch they were not allowed to. They
  now render disabled with the unauthorized hint, like action buttons.
- The header smart nav hides items (including extension shortcuts) whose own
  permission the user lacks.
fix(permissions): honour permission on bulk actions and header items
@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@0007223). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff            @@
##             main      #184   +/-   ##
========================================
  Coverage        ?   100.00%           
========================================
  Files           ?       324           
  Lines           ?      9137           
  Branches        ?         0           
========================================
  Hits            ?      9137           
  Misses          ?         0           
  Partials        ?         0           
Flag Coverage Δ
ember-ui 100.00% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
addon/components/activity-log.js 100.00% <100.00%> (ø)
addon/components/array-input.js 100.00% <ø> (ø)
addon/components/aside-item-scroller.js 100.00% <100.00%> (ø)
addon/components/attach/popover.js 100.00% <100.00%> (ø)
addon/components/autocomplete-input.js 100.00% <ø> (ø)
addon/components/basic-dropdown-hover.js 100.00% <ø> (ø)
addon/components/bulk-search-dropdown.js 100.00% <ø> (ø)
addon/components/button.js 100.00% <100.00%> (ø)
addon/components/chart.js 100.00% <100.00%> (ø)
addon/components/chat-tray.js 100.00% <100.00%> (ø)
... and 171 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roncodes
roncodes merged commit f6923a5 into main Sep 28, 2026
10 checks passed
@roncodes
roncodes deleted the release/v0.4.4 branch September 28, 2026 10:29
@roncodes roncodes mentioned this pull request Sep 28, 2026
6 tasks done
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.

1 participant