diff --git a/RELEASE.md b/RELEASE.md
index 80a0bb39..a9518a87 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,19 +1,17 @@
-> v0.4.5 ~ "Tables and panels render extension-registered columns, actions and buttons"
+> v0.4.6 ~ "Skeleton loading components and a report widget that runs live"
---
## Highlights
-- **Resource view registries.** Layout components merge what extensions register when given `@registry`, a surface prefix such as `ledger:table:invoice`. This pairs with fleetbase/ember-core v0.3.25.
- - `Layout::Resource::Tabular`: columns, row actions, bulk actions and toolbar buttons.
- - `Layout::Resource::TabularActions`: toolbar buttons and bulk actions.
- - `Layout::Resource::Panel` and `resourceContextPanel.open({ registry })`: header buttons, plus "…" menu items.
- - Table cells, filters and action-button components may be `ExtensionComponent`s, rendered from the extension's own engine.
- - With an older ember-core, views render exactly as before.
-- **Shared header components.** The three copies of the header-button and bulk-action markup are now `Layout::Resource::ActionButtons` and `Layout::Resource::BulkActions`.
-- **Tabular arguments.** Tabular forwards `@searchDisabled` and takes a `<:header>` block for extra header controls.
-- **Fix: Tabular ignored column changes after first render.** It copied `@columns` once. It now derives them, and the column picker's choices survive recomputes.
-- **Fix: Tabular's bulk actions ignored `permission`.** They now honour it.
-- **Fix: a dropdown item without a handler threw from `dropdown-fn`.** It now renders and does nothing.
+- **Skeleton loading components.** `Skeleton` draws text, title, circle, avatar, rect, button, badge and input shapes, and `Skeleton::Text`, `Skeleton::Table`, `Skeleton::Card`, `Skeleton::CardsGrid`, `Skeleton::Panel`, `Skeleton::Form`, `Skeleton::StatTiles`, `Skeleton::PageHeader` and `Skeleton::Tabs` build on it. `Skeleton::Page` is the composite for route `loading.hbs` templates: its `@view` is table, cards, form, dashboard or panel, or a block replaces the body. Boxes are `aria-hidden`, the page announces once through `role="status" aria-busy="true"` with `@label` (falls back to `common.loading`), and the shimmer honours `prefers-reduced-motion`. ([#189](https://github.com/fleetbase/ember-ui/pull/189))
+ - Skeletons follow the page they stand in for. The table card takes `@tabs`, `@toolbar`, `@checkbox`, `@footer` and `@identityColumn`; the header takes `@borderlessHeader`; cards take `@mediaFirst`, `@avatar`, `@subtitle`, `@status`, `@lines`, `@footerActions`, `@footerBadge` and `@footerLines`; the cards grid uses the `Layout::Resource::CardsGrid` breakpoints so it never shows fewer columns than the page. Every argument is optional and the defaults draw what they drew before.
+ - Loading substates in an engine only render when the console's application route lets the `loading` event bubble. fleetbase/fleetbase v0.7.69 does.
+- **Dark resource card tier.** `Layout::Resource::Card` gets hook classes (`resource-card`, `-header`, `-body`, `-footer`) and an opaque surface with hairline inner borders and a raised footer, replacing the half-transparent fill. Buttons and inputs are unchanged from 0.4.5.
+- **The report widget runs the report.** The dashboard `Widget::Report` showed the results stored on the report record, so the tile went stale. It now runs the report through `POST reports/{id}/execute` after loading it, shows "Running report…" then "Last run", and has a Refresh button. Results are capped at 100 rows with a note. A failed run shows an inline error and keeps the last good result. Polling is off by default; `options.refreshInterval` (minutes, minimum 5) turns it on and stops when the widget is destroyed. ([#188](https://github.com/fleetbase/ember-ui/pull/188))
+- **Fix: notes on contacts save.** The comment thread sends `subject_uuid` for a subject keyed by uuid; the console's comment serializer drops `subject_id`, so those notes were inserted without a subject.
+- **Fix: the date picker always shows the value in its date format.** A consumer that stored the picked `Date` saw its full `toString()` in the field.
+- **Fix: `CoordinatesInput` treats a null value as empty** instead of throwing while the component is created.
+- **Badges and buttons.** A `delivery` badge status uses the violet tone, and the dark-mode success button hover is green-600, so it reads as a hover.
---
## Need help?
diff --git a/addon/components/comment-thread.js b/addon/components/comment-thread.js
index 8b718a68..f4b41dce 100644
--- a/addon/components/comment-thread.js
+++ b/addon/components/comment-thread.js
@@ -5,6 +5,8 @@ import { task } from 'ember-concurrency';
import getWithDefault from '@fleetbase/ember-core/utils/get-with-default';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
/**
* Component to handle a thread of comments.
*/
@@ -84,8 +86,18 @@ export default class CommentThreadComponent extends Component {
return this.args.comments ?? this.comments;
}
+ /**
+ * The subject's uuid: its `uuid` field, or its `id` when that is a uuid (records whose
+ * serializer keys them by uuid have no separate `uuid` attribute).
+ */
get subjectUuid() {
- return this.subject?.uuid;
+ const subject = this.subject;
+
+ if (subject?.uuid) {
+ return subject.uuid;
+ }
+
+ return typeof subject?.id === 'string' && UUID_PATTERN.test(subject.id) ? subject.id : undefined;
}
get subjectPublicId() {
@@ -104,9 +116,12 @@ export default class CommentThreadComponent extends Component {
if (typeof this.args.onPublishComment === 'function') {
yield this.args.onPublishComment(this.input, this.subject);
} else {
+ // The console's comment serializer drops `subject_id`, so the uuid is what reaches
+ // the API; without it the comment is inserted with no subject and fails.
let comment = this.store.createRecord('comment', {
content: this.input,
subject_id: this.subjectPublicId,
+ subject_uuid: this.subjectUuid,
subject_type: this.subjectType,
});
diff --git a/addon/components/coordinates-input.js b/addon/components/coordinates-input.js
index 388a3e10..0da1ea87 100644
--- a/addon/components/coordinates-input.js
+++ b/addon/components/coordinates-input.js
@@ -73,7 +73,8 @@ export default class CoordinatesInputComponent extends Component {
* @memberof CoordinatesInputComponent
*/
isPoint(point) {
- return typeof point === 'object' && !isBlank(point.type) && point.type === 'Point' && isArray(point.coordinates);
+ // typeof null is "object", so null has to be ruled out before reading .type
+ return point !== null && typeof point === 'object' && !isBlank(point.type) && point.type === 'Point' && isArray(point.coordinates);
}
/**
diff --git a/addon/components/date-picker.hbs b/addon/components/date-picker.hbs
index 8ddf1a58..b4e9f612 100644
--- a/addon/components/date-picker.hbs
+++ b/addon/components/date-picker.hbs
@@ -1,6 +1,7 @@
-
format(date, this.dateFormat))
+ .join(separator);
+ }
+
@action setupComponent(node) {
this.nodeRef = node;
this.airDatePickerRef = new AirDatepicker(node, this.getOptions());
@@ -73,8 +85,8 @@ export default class DatePickerComponent extends Component {
return;
}
- // `
` echoes typed text back through `@value` on every keystroke; while the field has
- // focus the text is still being typed and `handleTypedValue` settles it on change.
+ // While the field has focus the text may still be being typed; `handleTypedValue` settles it
+ // on change.
if (document.activeElement === element) {
return;
}
diff --git a/addon/components/layout/resource/card.js b/addon/components/layout/resource/card.js
index 20b2d1e5..1fe376aa 100644
--- a/addon/components/layout/resource/card.js
+++ b/addon/components/layout/resource/card.js
@@ -10,22 +10,23 @@ export default class LayoutResourceCardComponent extends Component {
}
get cardClass() {
- const baseClasses = 'bg-gray-50 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden';
+ const baseClasses =
+ 'resource-card bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden';
return this.args.class ? `${baseClasses} ${this.args.class}` : baseClasses;
}
get headerClass() {
- const baseClasses = 'px-2 py-1.5 border-b border-gray-200 dark:border-gray-700';
+ const baseClasses = 'resource-card-header px-2 py-1.5 border-b border-gray-200 dark:border-gray-700';
return this.args.headerClass ? `${baseClasses} ${this.args.headerClass}` : baseClasses;
}
get bodyClass() {
- const baseClasses = '';
+ const baseClasses = 'resource-card-body';
return this.args.bodyClass ? `${baseClasses} ${this.args.bodyClass}` : baseClasses;
}
get footerClass() {
- const baseClasses = 'px-2 py-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50';
+ const baseClasses = 'resource-card-footer px-2 py-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800';
return this.args.footerClass ? `${baseClasses} ${this.args.footerClass}` : baseClasses;
}
}
diff --git a/addon/components/layout/resource/card/body.js b/addon/components/layout/resource/card/body.js
index c6ae3252..771a56be 100644
--- a/addon/components/layout/resource/card/body.js
+++ b/addon/components/layout/resource/card/body.js
@@ -6,7 +6,7 @@ export default class LayoutResourceCardBodyComponent extends Component {
}
get bodyClass() {
- const baseClasses = '';
+ const baseClasses = 'resource-card-body';
return this.args.class ? `${baseClasses} ${this.args.class}` : baseClasses;
}
}
diff --git a/addon/components/layout/resource/card/footer.js b/addon/components/layout/resource/card/footer.js
index eb1f7950..73f5a29a 100644
--- a/addon/components/layout/resource/card/footer.js
+++ b/addon/components/layout/resource/card/footer.js
@@ -6,7 +6,7 @@ export default class LayoutResourceCardFooterComponent extends Component {
}
get footerClass() {
- const baseClasses = 'px-2 py-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50';
+ const baseClasses = 'resource-card-footer px-2 py-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800';
return this.args.class ? `${baseClasses} ${this.args.class}` : baseClasses;
}
}
diff --git a/addon/components/layout/resource/card/header.js b/addon/components/layout/resource/card/header.js
index f0a22f3b..1bf0b210 100644
--- a/addon/components/layout/resource/card/header.js
+++ b/addon/components/layout/resource/card/header.js
@@ -6,7 +6,7 @@ export default class LayoutResourceCardHeaderComponent extends Component {
}
get headerClass() {
- const baseClasses = 'border-b border-gray-200 dark:border-gray-700';
+ const baseClasses = 'resource-card-header border-b border-gray-200 dark:border-gray-700';
return this.args.class ? `${baseClasses} ${this.args.class}` : baseClasses;
}
}
diff --git a/addon/components/skeleton.hbs b/addon/components/skeleton.hbs
new file mode 100644
index 00000000..967075bd
--- /dev/null
+++ b/addon/components/skeleton.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/skeleton.js b/addon/components/skeleton.js
new file mode 100644
index 00000000..a2ab0544
--- /dev/null
+++ b/addon/components/skeleton.js
@@ -0,0 +1,19 @@
+import Component from '@glimmer/component';
+
+/**
+ * The block every skeleton is drawn with. One grey shimmering box whose shape is the
+ * shape of the content it stands in for, so a loading page keeps the layout of the page
+ * it becomes. Composites (`Skeleton::Table`, `Skeleton::Card`, `Skeleton::Page`, …)
+ * arrange these; this one only draws.
+ *
+ * Variants: `text` (one line of body copy), `title`, `circle`, `avatar` (rounded square),
+ * `rect`, `button`, `badge`, `input`.
+ */
+export const SKELETON_VARIANTS = ['text', 'title', 'circle', 'avatar', 'rect', 'button', 'badge', 'input'];
+
+export default class SkeletonComponent extends Component {
+ get variant() {
+ const { variant } = this.args;
+ return SKELETON_VARIANTS.includes(variant) ? variant : 'text';
+ }
+}
diff --git a/addon/components/skeleton/card.hbs b/addon/components/skeleton/card.hbs
new file mode 100644
index 00000000..353e6888
--- /dev/null
+++ b/addon/components/skeleton/card.hbs
@@ -0,0 +1,69 @@
+
+ {{#if this.showMediaFirst}}
+
+
+
+ {{/if}}
+ {{#unless @hideHeader}}
+
+ {{/unless}}
+ {{#if this.showMediaAfterHeader}}
+
+
+
+ {{/if}}
+ {{#if this.lines.length}}
+
+ {{#each this.lines as |line|}}
+
+ {{/each}}
+
+ {{/if}}
+
+
diff --git a/addon/components/skeleton/card.js b/addon/components/skeleton/card.js
new file mode 100644
index 00000000..219596d3
--- /dev/null
+++ b/addon/components/skeleton/card.js
@@ -0,0 +1,38 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+const LINE_WIDTHS = ['70%', '45%', '60%', '35%', '55%'];
+
+/**
+ * One resource card while it loads, in the card anatomy: header, media, body lines and a
+ * footer. It follows the card it stands in for: `@mediaFirst` when the image leads the card,
+ * `@hideMedia` without one, `@avatar` for a header that is an identity (avatar, name and a
+ * subtitle), `@subtitle` for a title with a muted line under it, `@hideHeader` when the title
+ * sits in the body, `@status` as "badge", a dot (the default) or false, `@lines` body lines
+ * (2 by default), `@footerActions` icon buttons in the footer, led by a badge when
+ * `@footerBadge` is set, and `@footerLines` meta lines under that row.
+ */
+export default class SkeletonCardComponent extends SkeletonBaseComponent {
+ get mediaHeight() {
+ return this.args.mediaHeight ?? 160;
+ }
+
+ get showMediaFirst() {
+ return !this.args.hideMedia && Boolean(this.args.mediaFirst);
+ }
+
+ get showMediaAfterHeader() {
+ return !this.args.hideMedia && !this.args.mediaFirst;
+ }
+
+ get lines() {
+ return this.range(this.args.lines, 2).map((index) => ({ index, width: LINE_WIDTHS[index % LINE_WIDTHS.length] }));
+ }
+
+ get footerActions() {
+ return this.range(this.args.footerActions, 0);
+ }
+
+ get footerLines() {
+ return this.range(this.args.footerLines, 0).map((index) => ({ index, width: LINE_WIDTHS[(index + 2) % LINE_WIDTHS.length] }));
+ }
+}
diff --git a/addon/components/skeleton/cards-grid.hbs b/addon/components/skeleton/cards-grid.hbs
new file mode 100644
index 00000000..11b2a388
--- /dev/null
+++ b/addon/components/skeleton/cards-grid.hbs
@@ -0,0 +1,18 @@
+
+ {{#each this.items as |item|}}
+
+ {{/each}}
+
diff --git a/addon/components/skeleton/cards-grid.js b/addon/components/skeleton/cards-grid.js
new file mode 100644
index 00000000..09425562
--- /dev/null
+++ b/addon/components/skeleton/cards-grid.js
@@ -0,0 +1,13 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/** `@count` card skeletons in a `@columns`-up grid (4 by default, 2 under 1280px, 1 on phones). */
+export default class SkeletonCardsGridComponent extends SkeletonBaseComponent {
+ get items() {
+ return this.range(this.args.count, 8);
+ }
+
+ get columns() {
+ const columns = parseInt(this.args.columns, 10);
+ return Number.isFinite(columns) && columns >= 1 && columns <= 6 ? columns : 4;
+ }
+}
diff --git a/addon/components/skeleton/form.hbs b/addon/components/skeleton/form.hbs
new file mode 100644
index 00000000..08031538
--- /dev/null
+++ b/addon/components/skeleton/form.hbs
@@ -0,0 +1,8 @@
+
diff --git a/addon/components/skeleton/form.js b/addon/components/skeleton/form.js
new file mode 100644
index 00000000..1b627213
--- /dev/null
+++ b/addon/components/skeleton/form.js
@@ -0,0 +1,13 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/** A form while its record loads: `@fields` label + input pairs, in `@columns` columns. */
+export default class SkeletonFormComponent extends SkeletonBaseComponent {
+ get fields() {
+ return this.range(this.args.fields, 6);
+ }
+
+ get columns() {
+ const columns = parseInt(this.args.columns, 10);
+ return Number.isFinite(columns) && columns >= 1 && columns <= 3 ? columns : 1;
+ }
+}
diff --git a/addon/components/skeleton/page-header.hbs b/addon/components/skeleton/page-header.hbs
new file mode 100644
index 00000000..a7088c64
--- /dev/null
+++ b/addon/components/skeleton/page-header.hbs
@@ -0,0 +1,21 @@
+
diff --git a/addon/components/skeleton/page-header.js b/addon/components/skeleton/page-header.js
new file mode 100644
index 00000000..1c86b179
--- /dev/null
+++ b/addon/components/skeleton/page-header.js
@@ -0,0 +1,11 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/**
+ * The console's section header (57px, same gutters) while the page loads: identity,
+ * title and meta line on the left; a search box and `@actions` buttons on the right.
+ */
+export default class SkeletonPageHeaderComponent extends SkeletonBaseComponent {
+ get actions() {
+ return this.range(this.args.actions, 2);
+ }
+}
diff --git a/addon/components/skeleton/page.hbs b/addon/components/skeleton/page.hbs
new file mode 100644
index 00000000..51d6b3f4
--- /dev/null
+++ b/addon/components/skeleton/page.hbs
@@ -0,0 +1,54 @@
+
+
+ {{#if @label}}
+ {{@label}}
+ {{else}}
+ {{t "common.loading"}}
+ {{/if}}
+
+ {{#unless @hideHeader}}
+
+ {{/unless}}
+
+ {{#if (has-block)}}
+ {{yield}}
+ {{else if (eq this.view "cards")}}
+
+ {{else if (eq this.view "form")}}
+
+
+
+ {{else if (eq this.view "panel")}}
+
+ {{else if (eq this.view "dashboard")}}
+
+
+ {{else}}
+
+ {{#if this.showToolbar}}
+
+
+ {{#each this.tabs as |tab|}}
+
+ {{/each}}
+
+
+
+ {{/if}}
+
+ {{#if this.showFooter}}
+
+ {{/if}}
+
+ {{/if}}
+
+
diff --git a/addon/components/skeleton/page.js b/addon/components/skeleton/page.js
new file mode 100644
index 00000000..068b39b6
--- /dev/null
+++ b/addon/components/skeleton/page.js
@@ -0,0 +1,40 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+export const SKELETON_PAGE_VIEWS = ['table', 'cards', 'form', 'dashboard', 'panel'];
+
+// Tab labels differ in length; cycling widths keeps a strip from reading as a ruler.
+const TAB_WIDTHS = ['2.5rem', '3.5rem', '3rem', '4rem', '3.25rem', '2.75rem'];
+
+/**
+ * A whole page while its route model loads, for `loading.hbs` templates. Draws the
+ * section header and then the body for `@view`: a table card (default), a cards grid,
+ * a form, a dashboard (KPI tiles over a table) or a details panel; a block replaces the
+ * body. The region is announced to assistive tech once, as busy, with `@label`.
+ *
+ * The table card follows the page it stands in for: `@tabs` status tabs in its toolbar
+ * (0 for none), `@toolbar={{false}}` when the table has no toolbar, `@checkbox={{false}}`
+ * when rows are not selectable, `@footer={{false}}` without pagination, and
+ * `@identityColumn` as the index of the column that holds an avatar and name.
+ */
+export default class SkeletonPageComponent extends SkeletonBaseComponent {
+ get view() {
+ const { view } = this.args;
+ return SKELETON_PAGE_VIEWS.includes(view) ? view : 'table';
+ }
+
+ get showToolbar() {
+ return this.args.toolbar !== false;
+ }
+
+ get tabs() {
+ return this.range(this.args.tabs, 4).map((index) => ({ index, width: TAB_WIDTHS[index % TAB_WIDTHS.length] }));
+ }
+
+ get showCheckbox() {
+ return this.args.checkbox !== false;
+ }
+
+ get showFooter() {
+ return this.args.footer !== false;
+ }
+}
diff --git a/addon/components/skeleton/panel.hbs b/addon/components/skeleton/panel.hbs
new file mode 100644
index 00000000..e09a196c
--- /dev/null
+++ b/addon/components/skeleton/panel.hbs
@@ -0,0 +1,30 @@
+
+
+ {{#if this.tabs.length}}
+
+ {{#each this.tabs as |tab|}}
+
+ {{/each}}
+
+ {{/if}}
+ {{#each this.sections as |section|}}
+
+
+
+ {{#each this.fields as |field|}}
+
+
+
+
+ {{/each}}
+
+
+ {{/each}}
+
diff --git a/addon/components/skeleton/panel.js b/addon/components/skeleton/panel.js
new file mode 100644
index 00000000..87950f90
--- /dev/null
+++ b/addon/components/skeleton/panel.js
@@ -0,0 +1,19 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/**
+ * A details panel while its resource loads: identity strip, optional tab row, then
+ * `@sections` titled sections of `@fieldsPerSection` label / value rows.
+ */
+export default class SkeletonPanelComponent extends SkeletonBaseComponent {
+ get sections() {
+ return this.range(this.args.sections, 3);
+ }
+
+ get fields() {
+ return this.range(this.args.fieldsPerSection, 3);
+ }
+
+ get tabs() {
+ return this.range(this.args.tabs, 0);
+ }
+}
diff --git a/addon/components/skeleton/stat-tiles.hbs b/addon/components/skeleton/stat-tiles.hbs
new file mode 100644
index 00000000..55c85a7f
--- /dev/null
+++ b/addon/components/skeleton/stat-tiles.hbs
@@ -0,0 +1,9 @@
+
+ {{#each this.tiles as |tile|}}
+
+
+
+
+
+ {{/each}}
+
diff --git a/addon/components/skeleton/stat-tiles.js b/addon/components/skeleton/stat-tiles.js
new file mode 100644
index 00000000..966fa224
--- /dev/null
+++ b/addon/components/skeleton/stat-tiles.js
@@ -0,0 +1,8 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/** A row of `@count` KPI tiles: label, value, delta line. */
+export default class SkeletonStatTilesComponent extends SkeletonBaseComponent {
+ get tiles() {
+ return this.range(this.args.count, 4);
+ }
+}
diff --git a/addon/components/skeleton/table.hbs b/addon/components/skeleton/table.hbs
new file mode 100644
index 00000000..ffc1e964
--- /dev/null
+++ b/addon/components/skeleton/table.hbs
@@ -0,0 +1,37 @@
+
+
+ {{#if this.showHeader}}
+
+
+ {{#if @checkbox}}
+
+ {{/if}}
+ {{#each this.columns as |column|}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#each this.rows as |row|}}
+
+ {{#if @checkbox}}
+
+ {{/if}}
+ {{#each this.columns as |column|}}
+
+ {{#if column.isIdentity}}
+
+
+
+
+ {{else}}
+
+ {{/if}}
+
+ {{/each}}
+
+ {{/each}}
+
+
+
diff --git a/addon/components/skeleton/table.js b/addon/components/skeleton/table.js
new file mode 100644
index 00000000..644a49e9
--- /dev/null
+++ b/addon/components/skeleton/table.js
@@ -0,0 +1,41 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+// Cell widths cycle through these so the rows read as data, not as a grid of equal bars.
+const CELL_WIDTHS = ['70%', '45%', '60%', '35%', '55%', '40%'];
+const HEADER_WIDTHS = ['50%', '35%', '45%', '30%', '40%', '35%'];
+
+/**
+ * A table of `@rows` × `@columns` placeholder cells. The first column is drawn as an
+ * identity (avatar + name) unless `@identityColumn={{false}}`; `@checkbox={{true}}`
+ * adds the selection column; `@header={{false}}` drops the heading row.
+ */
+export default class SkeletonTableComponent extends SkeletonBaseComponent {
+ get showHeader() {
+ return this.args.header !== false;
+ }
+
+ /** The column holding an avatar and a name: the first by default, `@identityColumn` as an index, none when false. */
+ get identityIndex() {
+ const { identityColumn } = this.args;
+
+ if (identityColumn === false) {
+ return -1;
+ }
+
+ const index = parseInt(identityColumn, 10);
+ return Number.isFinite(index) && index >= 0 ? index : 0;
+ }
+
+ get columns() {
+ return this.range(this.args.columns, 5).map((index) => ({
+ index,
+ isIdentity: index === this.identityIndex,
+ width: CELL_WIDTHS[index % CELL_WIDTHS.length],
+ headerWidth: HEADER_WIDTHS[index % HEADER_WIDTHS.length],
+ }));
+ }
+
+ get rows() {
+ return this.range(this.args.rows, 8);
+ }
+}
diff --git a/addon/components/skeleton/tabs.hbs b/addon/components/skeleton/tabs.hbs
new file mode 100644
index 00000000..346d0aea
--- /dev/null
+++ b/addon/components/skeleton/tabs.hbs
@@ -0,0 +1,10 @@
+
+ {{#each this.tabs as |tab|}}
+
+
+ {{#if @counts}}
+
+ {{/if}}
+
+ {{/each}}
+
diff --git a/addon/components/skeleton/tabs.js b/addon/components/skeleton/tabs.js
new file mode 100644
index 00000000..0bbcaa49
--- /dev/null
+++ b/addon/components/skeleton/tabs.js
@@ -0,0 +1,13 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+const TAB_WIDTHS = ['2.5rem', '4rem', '3.25rem', '4.5rem', '3rem', '3.75rem'];
+
+/**
+ * An underlined tab strip, for pages whose tabs sit between the header and the content:
+ * `@count` tabs, each with a count pill when `@counts` is set.
+ */
+export default class SkeletonTabsComponent extends SkeletonBaseComponent {
+ get tabs() {
+ return this.range(this.args.count, 4).map((index) => ({ index, width: TAB_WIDTHS[index % TAB_WIDTHS.length] }));
+ }
+}
diff --git a/addon/components/skeleton/text.hbs b/addon/components/skeleton/text.hbs
new file mode 100644
index 00000000..8194aee3
--- /dev/null
+++ b/addon/components/skeleton/text.hbs
@@ -0,0 +1,5 @@
+
+ {{#each this.lines as |line|}}
+
+ {{/each}}
+
diff --git a/addon/components/skeleton/text.js b/addon/components/skeleton/text.js
new file mode 100644
index 00000000..03985ac7
--- /dev/null
+++ b/addon/components/skeleton/text.js
@@ -0,0 +1,9 @@
+import SkeletonBaseComponent from '../../utils/skeleton-base-component';
+
+/** A paragraph: `@lines` lines of text, the last one short. */
+export default class SkeletonTextComponent extends SkeletonBaseComponent {
+ get lines() {
+ const lines = this.range(this.args.lines, 3);
+ return lines.map((index) => ({ index, isLast: lines.length > 1 && index === lines.length - 1 }));
+ }
+}
diff --git a/addon/components/widget/report.hbs b/addon/components/widget/report.hbs
index d6aa009e..832fb7d4 100644
--- a/addon/components/widget/report.hbs
+++ b/addon/components/widget/report.hbs
@@ -1,11 +1,43 @@
\ No newline at end of file
diff --git a/addon/components/widget/report.js b/addon/components/widget/report.js
index 825dc298..c33a4462 100644
--- a/addon/components/widget/report.js
+++ b/addon/components/widget/report.js
@@ -2,16 +2,35 @@ import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
-import { task } from 'ember-concurrency';
+import { task, timeout } from 'ember-concurrency';
+import formatDate from '../../utils/format-date';
+import {
+ REPORT_WIDGET_ROW_LIMIT,
+ mapReportExecution,
+ resolveReportWidgetResult,
+ isFailedExecution,
+ resolveRefreshInterval,
+ reportExecutionErrorMessage,
+} from '../../utils/report-widget-result';
export default class WidgetReportComponent extends Component {
@service store;
@service modalsManager;
@service notifications;
+ @service fetch;
@tracked reportId = this.args.options?.reportId ?? null;
@tracked report = null;
+ /** The latest successful live run, mapped for `Report::Data`. */
+ @tracked liveResult = null;
+
+ /** When the latest live run finished. */
+ @tracked lastRunAt = null;
+
+ /** The message of the latest failed run, cleared by the next successful one. */
+ @tracked runError = null;
+
constructor() {
super(...arguments);
this.loadReport.perform();
@@ -24,6 +43,38 @@ export default class WidgetReportComponent extends Component {
return this.report ? [this.report] : [];
}
+ /**
+ * What the widget renders: the latest live run, or the results stored on the record when
+ * no live run has succeeded yet (for instance because the run failed).
+ */
+ get result() {
+ return resolveReportWidgetResult(this.liveResult, this.report, REPORT_WIDGET_ROW_LIMIT);
+ }
+
+ get isShowingStoredResult() {
+ return this.result?.source === 'stored';
+ }
+
+ get lastRunDisplay() {
+ return this.lastRunAt ? formatDate(this.lastRunAt, 'PP p') : null;
+ }
+
+ /** The polling interval in milliseconds; 0 when polling is off (the default). */
+ get refreshInterval() {
+ return resolveRefreshInterval(this.args.options);
+ }
+
+ @action refresh() {
+ return this.runReport.perform();
+ }
+
+ resetRun() {
+ this.runReport.cancelAll();
+ this.liveResult = null;
+ this.lastRunAt = null;
+ this.runError = null;
+ }
+
@action selectReport() {
this.modalsManager.show('modals/find-select-report', {
title: 'Select report',
@@ -31,6 +82,7 @@ export default class WidgetReportComponent extends Component {
limit: 1,
selected: this.selectedReports,
onChange: ([selectedReport] = []) => {
+ this.resetRun();
this.report = selectedReport ?? null;
this.reportId = selectedReport?.id ?? null;
},
@@ -40,6 +92,7 @@ export default class WidgetReportComponent extends Component {
try {
await this.saveWidget.perform();
modal.done();
+ this.startRunning();
} catch (err) {
this.notifications.serverError(err);
}
@@ -57,6 +110,79 @@ export default class WidgetReportComponent extends Component {
} catch (err) {
this.report = null;
this.notifications.serverError(err);
+ return;
+ }
+
+ this.startRunning();
+ }
+
+ /**
+ * Run the current report once, then keep polling if the widget's options ask for it.
+ */
+ startRunning() {
+ if (!this.report) {
+ return;
+ }
+
+ this.runReport.perform();
+
+ if (this.refreshInterval > 0) {
+ this.pollReport.perform();
+ } else {
+ this.pollReport.cancelAll();
+ }
+ }
+
+ /**
+ * Run the report live through core-api (`POST reports/{id}/execute`). The result is kept on
+ * the component rather than filled onto the record, so the record is never dirtied. A
+ * failed run is shown inline and the widget keeps the last good (or stored) result.
+ */
+ @task({ restartable: true }) *runReport() {
+ const report = this.report;
+ if (!report?.id) {
+ return;
+ }
+
+ try {
+ const response = typeof report.execute === 'function' ? yield report.execute() : yield this.fetch.post(`reports/${report.id}/execute`);
+
+ if (isFailedExecution(response)) {
+ throw response;
+ }
+
+ this.liveResult = mapReportExecution(response, REPORT_WIDGET_ROW_LIMIT);
+ this.lastRunAt = new Date();
+ this.runError = null;
+ } catch (err) {
+ this.runError = reportExecutionErrorMessage(err);
+ }
+ }
+
+ /**
+ * Wait out one polling interval.
+ *
+ * @param {Number} interval milliseconds
+ */
+ pollDelay(interval) {
+ return timeout(interval);
+ }
+
+ /**
+ * Re-run the report on the configured interval. ember-concurrency cancels this task when
+ * the component is destroyed, which stops the polling.
+ */
+ @task({ restartable: true }) *pollReport() {
+ const interval = this.refreshInterval;
+ /* istanbul ignore next -- startRunning only performs this task with a positive interval */
+ if (interval <= 0) {
+ return;
+ }
+
+ for (;;) {
+ yield this.pollDelay(interval);
+ // Not yielded: a manual refresh restarts runReport, and that must not end the polling.
+ this.runReport.perform();
}
}
diff --git a/addon/styles/addon.css b/addon/styles/addon.css
index c99254d1..7397763d 100644
--- a/addon/styles/addon.css
+++ b/addon/styles/addon.css
@@ -62,6 +62,7 @@
@import 'components/template-builder.css';
@import 'components/fleetbase-attribution.css';
@import 'components/signature-pad.css';
+@import 'components/skeleton.css';
.fleetbase-docs-panel-body {
position: relative;
diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css
index e20ee5c7..f202cf77 100644
--- a/addon/styles/components/badge.css
+++ b/addon/styles/components/badge.css
@@ -148,12 +148,14 @@
.status-badge.duplicate-status-badge > span,
.status-badge.backlogged-status-badge > span,
+.status-badge.delivery-status-badge > span,
.status-badge.violet-status-badge > span {
@apply bg-violet-800 border-violet-700 text-violet-100;
}
.status-badge.duplicate-status-badge > span svg,
.status-badge.backlogged-status-badge > span svg,
+.status-badge.delivery-status-badge > span svg,
.status-badge.violet-status-badge > span svg {
@apply text-violet-300;
}
diff --git a/addon/styles/components/button.css b/addon/styles/components/button.css
index f0842175..d72ac3aa 100644
--- a/addon/styles/components/button.css
+++ b/addon/styles/components/button.css
@@ -157,6 +157,14 @@ a.btn.btn-success:hover:not(:disabled),
@apply bg-green-400;
}
+/* Dark mode: a lighter green washes the white label out; green-600 keeps the contrast. */
+body[data-theme='dark'] .btn.fb-button-success:hover:not(:disabled),
+body[data-theme='dark'] a.btn.fb-button-success:hover:not(:disabled),
+body[data-theme='dark'] .btn.btn-success:hover:not(:disabled),
+body[data-theme='dark'] a.btn.btn-success:hover:not(:disabled) {
+ @apply bg-green-600;
+}
+
.btn.fb-button-success:focus,
a.btn.fb-button-success:focus,
.fc-button.fb-button-success:focus,
diff --git a/addon/styles/components/cards.css b/addon/styles/components/cards.css
index 7340e925..572d13c3 100644
--- a/addon/styles/components/cards.css
+++ b/addon/styles/components/cards.css
@@ -49,3 +49,21 @@ body[data-theme='dark'] .order-schedule-card .card-title {
body[data-theme='dark'] .order-schedule-card .card-content {
@apply bg-gray-900;
}
+
+/*
+ * Resource card, dark: an opaque surface with a clear outer edge, quiet inner hairlines
+ * and a footer one tier up, so the card reads the same over the page and over a panel.
+ */
+body[data-theme='dark'] .resource-card {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .resource-card .resource-card-header {
+ border-color: #2d3748;
+}
+
+body[data-theme='dark'] .resource-card .resource-card-footer {
+ border-color: #2d3748;
+ background-color: #273244;
+}
diff --git a/addon/styles/components/skeleton.css b/addon/styles/components/skeleton.css
new file mode 100644
index 00000000..9afa7a4d
--- /dev/null
+++ b/addon/styles/components/skeleton.css
@@ -0,0 +1,525 @@
+/*
+ * Skeleton loading states. One shimmering block (`.ui-skeleton`) in a few shapes, and the
+ * composites that arrange it into a table, a card, a form, a panel or a whole page. Every
+ * box is `aria-hidden`; the page composite announces itself once through `role="status"`.
+ * Surfaces are opaque in both themes so a skeleton reads the same wherever it sits.
+ */
+
+.ui-skeleton {
+ position: relative;
+ display: inline-block;
+ flex: 0 0 auto;
+ width: 100%;
+ height: 0.875rem;
+ max-width: 100%;
+ overflow: hidden;
+ vertical-align: middle;
+ border-radius: 0.25rem;
+ background-color: #e5e7eb;
+}
+
+.ui-skeleton::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(90deg, transparent, rgb(255 255 255 / 55%), transparent);
+ animation: ui-skeleton-shimmer 1.6s ease-in-out infinite;
+}
+
+body[data-theme='dark'] .ui-skeleton {
+ background-color: #374151;
+}
+
+body[data-theme='dark'] .ui-skeleton::after {
+ background: linear-gradient(90deg, transparent, rgb(255 255 255 / 7%), transparent);
+}
+
+@keyframes ui-skeleton-shimmer {
+ 100% {
+ transform: translateX(100%);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ui-skeleton::after {
+ animation: none;
+ }
+}
+
+.ui-skeleton-title {
+ height: 1.125rem;
+}
+
+.ui-skeleton-circle {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 9999px;
+}
+
+.ui-skeleton-avatar {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+}
+
+.ui-skeleton-rect {
+ height: 5rem;
+}
+
+.ui-skeleton-button {
+ width: 5.5rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+}
+
+.ui-skeleton-badge {
+ width: 3.75rem;
+ height: 1.125rem;
+ border-radius: 9999px;
+}
+
+.ui-skeleton-input {
+ height: 2.125rem;
+ border-radius: 0.375rem;
+}
+
+.ui-skeleton-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+/* ── Text ── */
+.ui-skeleton-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+/* ── Identity (avatar + name) ── */
+.ui-skeleton-identity {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+/* ── Table ── */
+.ui-skeleton-table-wrapper {
+ width: 100%;
+ overflow: hidden;
+}
+
+.ui-skeleton-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.ui-skeleton-table th,
+.ui-skeleton-table td {
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.ui-skeleton-table th {
+ height: 2.25rem;
+ background-color: #f9fafb;
+}
+
+.ui-skeleton-table td {
+ height: 2.75rem;
+}
+
+.ui-skeleton-table .ui-skeleton-table-check {
+ width: 2.25rem;
+ padding-right: 0;
+}
+
+body[data-theme='dark'] .ui-skeleton-table th,
+body[data-theme='dark'] .ui-skeleton-table td {
+ border-color: #2d3748;
+}
+
+body[data-theme='dark'] .ui-skeleton-table th {
+ background-color: #111827;
+}
+
+/* ── Table card (toolbar, table, footer) ── */
+.ui-skeleton-table-card {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #fff;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
+}
+
+body[data-theme='dark'] .ui-skeleton-table-card {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+.ui-skeleton-table-card-toolbar,
+.ui-skeleton-table-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ min-height: 2.625rem;
+ padding: 0.5rem 0.75rem;
+}
+
+.ui-skeleton-table-card-toolbar {
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.ui-skeleton-table-card-footer {
+ border-top: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-table-card-toolbar,
+body[data-theme='dark'] .ui-skeleton-table-card-footer {
+ border-color: #2d3748;
+}
+
+.ui-skeleton-table-card-tabs {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+.ui-skeleton-table-card .ui-skeleton-table th:last-child,
+.ui-skeleton-table-card .ui-skeleton-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.ui-skeleton-table-card .ui-skeleton-table th {
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-table-card .ui-skeleton-table th {
+ border-color: #2d3748;
+}
+
+.ui-skeleton-form-card {
+ padding: 1.25rem;
+}
+
+/* ── Card ── */
+.ui-skeleton-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
+}
+
+body[data-theme='dark'] .ui-skeleton-card {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+.ui-skeleton-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-card-header {
+ border-color: #2d3748;
+}
+
+.ui-skeleton-card-media {
+ width: 100%;
+}
+
+.ui-skeleton-card-media .ui-skeleton {
+ display: block;
+ border-radius: 0;
+}
+
+.ui-skeleton-card-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.625rem 0.5rem;
+}
+
+.ui-skeleton-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.375rem 0.5rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-card-footer {
+ border-color: #2d3748;
+}
+
+.ui-skeleton-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.ui-skeleton-card-footer-row {
+ display: contents;
+}
+
+.ui-skeleton-card-footer.ui-skeleton-card-footer-stacked {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+
+.ui-skeleton-card-footer-stacked .ui-skeleton-card-footer-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.ui-skeleton-card-identity {
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.ui-skeleton-card-identity-text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.375rem;
+ min-width: 0;
+}
+
+/* ── Cards grid: the same breakpoints as Layout::Resource::CardsGrid, so a skeleton never
+ shows fewer columns than the grid it stands in for. One column on phones; from 768px two
+ for @columns=2 and 3 (three from 1024px), and the full count for 4 to 6. ── */
+.ui-skeleton-cards-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+}
+
+@media (min-width: 768px) {
+ .ui-skeleton-cards-grid-cols-2,
+ .ui-skeleton-cards-grid-cols-3 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ui-skeleton-cards-grid-cols-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .ui-skeleton-cards-grid-cols-5 {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+
+ .ui-skeleton-cards-grid-cols-6 {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1024px) {
+ .ui-skeleton-cards-grid-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+/* ── Panel ── */
+.ui-skeleton-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding: 1rem;
+}
+
+.ui-skeleton-panel-identity {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.ui-skeleton-panel-identity-text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.ui-skeleton-panel-tabs {
+ display: flex;
+ gap: 1.25rem;
+ padding-bottom: 0.625rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-panel-tabs {
+ border-color: #2d3748;
+}
+
+.ui-skeleton-panel-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.ui-skeleton-panel-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.ui-skeleton-field {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+/* ── Form ── */
+.ui-skeleton-form {
+ display: grid;
+ gap: 1rem 1.5rem;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+}
+
+.ui-skeleton-form-cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.ui-skeleton-form-cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.ui-skeleton-form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+/* ── Stat tiles ── */
+.ui-skeleton-stat-tiles {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+}
+
+.ui-skeleton-stat-tile {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0.75rem 0.875rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #fff;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
+}
+
+body[data-theme='dark'] .ui-skeleton-stat-tile {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+/* ── Page header (the console section header, 57px) ── */
+.ui-skeleton-page-header {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ width: 100%;
+ min-height: 57px;
+ padding: 0.5rem 1.5rem 0.5rem 2.25rem;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .ui-skeleton-page-header {
+ border-color: #374151;
+ background-color: #111827;
+}
+
+.ui-skeleton-page-header.ui-skeleton-page-header-borderless {
+ border-bottom-color: transparent;
+}
+
+.ui-skeleton-page-header-left {
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.ui-skeleton-page-header-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.ui-skeleton-page-header-actions {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* ── Page ── */
+.ui-skeleton-page {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-height: 100%;
+}
+
+.ui-skeleton-page-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1.25rem;
+}
+
+/* ── Tabs (an underlined strip between the header and the content) ── */
+.ui-skeleton-tabs {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 1.5rem;
+ min-height: 2.25rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .ui-skeleton-tabs {
+ border-color: #374151;
+}
+
+.ui-skeleton-tabs-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+}
diff --git a/addon/utils/report-widget-result.js b/addon/utils/report-widget-result.js
new file mode 100644
index 00000000..0bf1011b
--- /dev/null
+++ b/addon/utils/report-widget-result.js
@@ -0,0 +1,159 @@
+/**
+ * Helpers for the dashboard report widget, which runs its report live and renders the
+ * result through `Report::Data` (an object with `result_columns` and `data`).
+ */
+
+/** The most rows the widget renders from one run. */
+export const REPORT_WIDGET_ROW_LIMIT = 100;
+
+/**
+ * The shortest polling interval, in minutes. Every execute writes usage stats, a cache entry
+ * and an execution-log row on the server, so the widget never polls faster than this.
+ */
+export const REPORT_WIDGET_MIN_REFRESH_MINUTES = 5;
+
+/** Polling is off unless the widget's options ask for it. */
+export const REPORT_WIDGET_DEFAULT_REFRESH_MINUTES = 0;
+
+/**
+ * Normalize the columns of a report run into `{ name, label }` objects. Plain strings become
+ * a column named and labelled by the string; entries without a usable name are dropped.
+ *
+ * @param {Array} columns
+ * @return {Array<{name: string, label: string}>}
+ */
+export function normalizeReportColumns(columns) {
+ if (!Array.isArray(columns)) {
+ return [];
+ }
+
+ return columns
+ .map((column) => {
+ if (typeof column === 'string') {
+ return { name: column, label: column };
+ }
+
+ if (!column || typeof column !== 'object') {
+ return null;
+ }
+
+ const name = column.name ?? column.key ?? column.alias ?? null;
+ if (name === null || name === undefined || name === '') {
+ return null;
+ }
+
+ return { ...column, name, label: column.label ?? name };
+ })
+ .filter(Boolean);
+}
+
+/**
+ * Cap a list of rows and describe how much was left out.
+ *
+ * @param {Array} rows
+ * @param {Number} totalRows the server's count, when it reports one
+ * @param {Number} limit
+ */
+function capRows(rows, totalRows, limit) {
+ const list = Array.isArray(rows) ? rows : [];
+ const total = Number.isFinite(totalRows) && totalRows >= list.length ? totalRows : list.length;
+ const data = list.slice(0, limit);
+
+ return { data, total_rows: total, truncated: total > data.length };
+}
+
+/**
+ * Map the response of `POST reports/{id}/execute` (`{ success, results, columns, meta }`) into
+ * the shape `Report::Data` renders, capped at `limit` rows.
+ *
+ * @param {Object} response
+ * @param {Number} [limit]
+ * @return {{result_columns: Array, data: Array, meta: Object, total_rows: Number, truncated: Boolean, source: String}}
+ */
+export function mapReportExecution(response, limit = REPORT_WIDGET_ROW_LIMIT) {
+ const meta = response?.meta && typeof response.meta === 'object' ? response.meta : {};
+ const rows = response?.results ?? response?.data ?? [];
+
+ return {
+ result_columns: normalizeReportColumns(response?.columns),
+ ...capRows(rows, meta.total_rows, limit),
+ meta,
+ source: 'live',
+ };
+}
+
+/**
+ * The results stored on the report record, capped and shaped like a live run. This is what
+ * the widget shows when a live run fails.
+ *
+ * @param {Object} report
+ * @param {Number} [limit]
+ */
+export function storedReportResult(report, limit = REPORT_WIDGET_ROW_LIMIT) {
+ if (!report) {
+ return null;
+ }
+
+ const meta = report.meta && typeof report.meta === 'object' ? report.meta : {};
+
+ return {
+ result_columns: normalizeReportColumns(report.result_columns),
+ ...capRows(report.data, meta.total_rows ?? report.row_count, limit),
+ meta,
+ source: 'stored',
+ };
+}
+
+/**
+ * Choose what the widget renders: the latest live run when there is one, otherwise the
+ * results stored on the record.
+ *
+ * @param {Object|null} liveResult
+ * @param {Object|null} report
+ * @param {Number} [limit]
+ */
+export function resolveReportWidgetResult(liveResult, report, limit = REPORT_WIDGET_ROW_LIMIT) {
+ return liveResult ?? storedReportResult(report, limit);
+}
+
+/**
+ * Whether a response from the execute endpoint is a failed run.
+ *
+ * @param {Object} response
+ */
+export function isFailedExecution(response) {
+ return !response || typeof response !== 'object' || response.success === false;
+}
+
+/**
+ * The polling interval in milliseconds from the widget's options (`refreshInterval`, in
+ * minutes). Missing, zero, negative or non-numeric values turn polling off (0); positive
+ * values are raised to the minimum.
+ *
+ * @param {Object} options
+ * @return {Number}
+ */
+export function resolveRefreshInterval(options) {
+ const minutes = Number(options?.refreshInterval ?? REPORT_WIDGET_DEFAULT_REFRESH_MINUTES);
+ if (!Number.isFinite(minutes) || minutes <= 0) {
+ return 0;
+ }
+
+ return Math.max(minutes, REPORT_WIDGET_MIN_REFRESH_MINUTES) * 60 * 1000;
+}
+
+/**
+ * The message to show inline when a run fails.
+ *
+ * @param {*} error
+ */
+export function reportExecutionErrorMessage(error) {
+ if (typeof error === 'string' && error) {
+ return error;
+ }
+
+ const message = error?.message ?? error?.error ?? error?.errors?.[0];
+ return typeof message === 'string' && message ? message : 'The report could not be run.';
+}
+
+export default mapReportExecution;
diff --git a/addon/utils/skeleton-base-component.js b/addon/utils/skeleton-base-component.js
new file mode 100644
index 00000000..0a87c70d
--- /dev/null
+++ b/addon/utils/skeleton-base-component.js
@@ -0,0 +1,16 @@
+import Component from '@glimmer/component';
+
+/**
+ * Shared by the composite skeletons: turns a count argument into an iterable, with a
+ * default and a ceiling so a bad argument never draws thousands of boxes.
+ */
+export default class SkeletonBaseComponent extends Component {
+ static MAX_ITEMS = 100;
+
+ range(value, fallback) {
+ const count = parseInt(value, 10);
+ const length = Number.isFinite(count) && count >= 0 ? Math.min(count, SkeletonBaseComponent.MAX_ITEMS) : fallback;
+
+ return Array.from({ length }, (_, index) => index);
+ }
+}
diff --git a/app/components/skeleton.js b/app/components/skeleton.js
new file mode 100644
index 00000000..4ecf28a1
--- /dev/null
+++ b/app/components/skeleton.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton';
diff --git a/app/components/skeleton/card.js b/app/components/skeleton/card.js
new file mode 100644
index 00000000..17bf5ad2
--- /dev/null
+++ b/app/components/skeleton/card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/card';
diff --git a/app/components/skeleton/cards-grid.js b/app/components/skeleton/cards-grid.js
new file mode 100644
index 00000000..aa1e3526
--- /dev/null
+++ b/app/components/skeleton/cards-grid.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/cards-grid';
diff --git a/app/components/skeleton/form.js b/app/components/skeleton/form.js
new file mode 100644
index 00000000..38c3ab31
--- /dev/null
+++ b/app/components/skeleton/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/form';
diff --git a/app/components/skeleton/page-header.js b/app/components/skeleton/page-header.js
new file mode 100644
index 00000000..050e5021
--- /dev/null
+++ b/app/components/skeleton/page-header.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/page-header';
diff --git a/app/components/skeleton/page.js b/app/components/skeleton/page.js
new file mode 100644
index 00000000..03e238c7
--- /dev/null
+++ b/app/components/skeleton/page.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/page';
diff --git a/app/components/skeleton/panel.js b/app/components/skeleton/panel.js
new file mode 100644
index 00000000..23d7c7da
--- /dev/null
+++ b/app/components/skeleton/panel.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/panel';
diff --git a/app/components/skeleton/stat-tiles.js b/app/components/skeleton/stat-tiles.js
new file mode 100644
index 00000000..4f55f9fa
--- /dev/null
+++ b/app/components/skeleton/stat-tiles.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/stat-tiles';
diff --git a/app/components/skeleton/table.js b/app/components/skeleton/table.js
new file mode 100644
index 00000000..ecaec5d9
--- /dev/null
+++ b/app/components/skeleton/table.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/table';
diff --git a/app/components/skeleton/tabs.js b/app/components/skeleton/tabs.js
new file mode 100644
index 00000000..b0a15535
--- /dev/null
+++ b/app/components/skeleton/tabs.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/tabs';
diff --git a/app/components/skeleton/text.js b/app/components/skeleton/text.js
new file mode 100644
index 00000000..85d39580
--- /dev/null
+++ b/app/components/skeleton/text.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/components/skeleton/text';
diff --git a/app/utils/report-widget-result.js b/app/utils/report-widget-result.js
new file mode 100644
index 00000000..e48f4ea0
--- /dev/null
+++ b/app/utils/report-widget-result.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-ui/utils/report-widget-result';
diff --git a/package.json b/package.json
index 81ca09c4..c11a8741 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ember-ui",
- "version": "0.4.5",
+ "version": "0.4.6",
"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/tests/integration/components/comment-thread-test.js b/tests/integration/components/comment-thread-test.js
index 0f5d6931..47b9db28 100644
--- a/tests/integration/components/comment-thread-test.js
+++ b/tests/integration/components/comment-thread-test.js
@@ -300,6 +300,18 @@ module('Integration | Component | comment-thread', function (hooks) {
return store.calls.filter((call) => call.method === method);
}
+ test('a subject keyed by uuid sends that uuid with the comment', async function (assert) {
+ this.set('subject', { id: '2a470034-ef02-45a2-a3b6-b13d0fb7004a', public_id: 'contact_test_1' });
+
+ await render(TEMPLATE);
+ await fillIn('textarea', 'A note on a contact');
+ await click('button');
+
+ const [created] = storeCalls(this.store, 'createRecord');
+ assert.strictEqual(created.args[1].subject_uuid, '2a470034-ef02-45a2-a3b6-b13d0fb7004a', 'the id is the uuid the API needs');
+ assert.strictEqual(created.args[1].subject_id, 'contact_test_1');
+ });
+
test('publishing with no handler creates the comment and reloads the thread', async function (assert) {
await render(TEMPLATE);
await fillIn('textarea', 'A comment with no handler');
@@ -307,7 +319,11 @@ module('Integration | Component | comment-thread', function (hooks) {
const [created] = storeCalls(this.store, 'createRecord');
assert.strictEqual(created.args[0], 'comment', 'a comment record is created');
- assert.deepEqual(created.args[1], { content: 'A comment with no handler', subject_id: 'order_test_1', subject_type: 'order' }, 'carrying the subject identity');
+ assert.deepEqual(
+ created.args[1],
+ { content: 'A comment with no handler', subject_id: 'order_test_1', subject_uuid: undefined, subject_type: 'order' },
+ 'carrying the subject identity'
+ );
const [queried] = storeCalls(this.store, 'query');
assert.strictEqual(queried.args[0], 'comment', 'the thread is reloaded afterwards');
@@ -330,7 +346,11 @@ module('Integration | Component | comment-thread', function (hooks) {
await click('button');
const [created] = storeCalls(this.store, 'createRecord');
- assert.deepEqual(created.args[1], { content: 'A comment on an untyped subject', subject_id: 'order_uuid_only', subject_type: null }, 'the record carries a null type');
+ assert.deepEqual(
+ created.args[1],
+ { content: 'A comment on an untyped subject', subject_id: 'order_uuid_only', subject_uuid: 'order_uuid_only', subject_type: null },
+ 'the record carries a null type'
+ );
const [queried] = storeCalls(this.store, 'query');
assert.strictEqual(queried.args[1].subject_uuid, 'order_uuid_only', 'queried by uuid');
diff --git a/tests/integration/components/coordinates-input-test.js b/tests/integration/components/coordinates-input-test.js
index cd18bed5..657f6c3b 100644
--- a/tests/integration/components/coordinates-input-test.js
+++ b/tests/integration/components/coordinates-input-test.js
@@ -24,6 +24,15 @@ module('Integration | Component | coordinates-input', function (hooks) {
assert.dom('.coordinates-input input[aria-label="Longitude"]').hasValue('103.8198');
});
+ test('it renders empty inputs for a null value', async function (assert) {
+ this.set('value', null);
+
+ await render(hbs`
`);
+
+ assert.dom('.coordinates-input input[aria-label="Latitude"]').hasValue('');
+ assert.dom('.coordinates-input input[aria-label="Longitude"]').hasValue('');
+ });
+
test('it ignores a zero-zero point value', async function (assert) {
this.set('value', { type: 'Point', coordinates: [0, 0] });
diff --git a/tests/integration/components/date-picker-test.js b/tests/integration/components/date-picker-test.js
index a8b3b073..ee0fac9e 100644
--- a/tests/integration/components/date-picker-test.js
+++ b/tests/integration/components/date-picker-test.js
@@ -93,6 +93,14 @@ module('Integration | Component | date-picker', function (hooks) {
assert.dom('.air-datepicker-cell.-selected-').exists('the day is marked in the calendar');
});
+ test('a Date value is shown in the picker format, not as a datetime string', async function (assert) {
+ this.set('value', new Date(2026, 2, 12, 15, 45));
+
+ await render(TEMPLATE);
+
+ assert.dom(INPUT).hasValue('2026-03-12');
+ });
+
// A picker that is not a range (or multi-date) picker keeps only one selected day,
// so these cases pass @range through to prove both parsed dates arrive.
const RANGE_TEMPLATE = hbs`
`;
@@ -239,6 +247,20 @@ module('Integration | Component | date-picker', function (hooks) {
});
});
+ module('a consumer that stores the picked Date', function () {
+ test('the field keeps showing the formatted date after a pick', async function (assert) {
+ this.set('value', null);
+ this.set('store', (date) => this.set('value', date));
+
+ await render(hbs`
`);
+ await click(INPUT);
+ await click(days()[9]);
+
+ assert.true(this.value instanceof Date, 'the consumer holds a Date');
+ assert.true(/^\d{4}-\d{2}-10$/.test(find(INPUT).value), `shown as yyyy-MM-dd, got "${find(INPUT).value}"`);
+ });
+ });
+
module('a value that changes later', function () {
test('the new date is shown and selected without being reported', async function (assert) {
this.set('value', '2026-03-12');
diff --git a/tests/integration/components/filter/date-test.js b/tests/integration/components/filter/date-test.js
index ba3663a0..2ef7da45 100644
--- a/tests/integration/components/filter/date-test.js
+++ b/tests/integration/components/filter/date-test.js
@@ -47,7 +47,7 @@ module('Integration | Component | filter/date', function (hooks) {
await render(TEMPLATE);
- assert.dom(INPUT).hasValue('2026-01-05,2026-01-09');
+ assert.dom(INPUT).hasValue('2026-01-05, 2026-01-09', 'shown the way the picker writes a range');
});
test('the field opens a calendar', async function (assert) {
diff --git a/tests/integration/components/skeleton-test.js b/tests/integration/components/skeleton-test.js
new file mode 100644
index 00000000..81f8062a
--- /dev/null
+++ b/tests/integration/components/skeleton-test.js
@@ -0,0 +1,172 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | skeleton', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it draws one hidden block in the requested shape and falls back to text', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('[data-test-block]').hasClass('ui-skeleton');
+ assert.dom('[data-test-block]').hasClass('ui-skeleton-circle');
+ assert.dom('[data-test-block]').hasAttribute('aria-hidden', 'true');
+ assert.dom('[data-test-block]').hasStyle({ width: '24px', height: '24px' });
+
+ await render(hbs`
`);
+ assert.dom('[data-test-block]').hasClass('ui-skeleton-text', 'an unknown variant renders as a text line');
+ });
+
+ test('text renders the requested number of lines with a short last line', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-text .ui-skeleton').exists({ count: 4 });
+ assert.dom('.ui-skeleton-text .ui-skeleton:last-child').hasAttribute('style', /width:\s*60%/);
+ });
+
+ test('table renders rows and columns with a heading row, a selection column and an identity first cell', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-table thead th').exists({ count: 5 }, 'four columns plus the checkbox column');
+ assert.dom('.ui-skeleton-table tbody tr').exists({ count: 3 });
+ assert.dom('.ui-skeleton-table tbody tr:first-child td').exists({ count: 5 });
+ assert.dom('.ui-skeleton-table tbody tr:first-child .ui-skeleton-identity').exists({ count: 1 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-table thead').doesNotExist();
+ assert.dom('.ui-skeleton-identity').doesNotExist();
+ });
+
+ test('table defaults and caps its counts', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-table tbody tr').exists({ count: 8 });
+ assert.dom('.ui-skeleton-table thead th').exists({ count: 5 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-table tbody tr').exists({ count: 100 }, 'rows are capped');
+ });
+
+ test('cards grid renders count cards in the card anatomy', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-cards-grid').hasClass('ui-skeleton-cards-grid-cols-3');
+ assert.dom('.ui-skeleton-card').exists({ count: 6 });
+ assert.dom('.ui-skeleton-card:first-child .ui-skeleton-card-header').exists();
+ assert.dom('.ui-skeleton-card:first-child .ui-skeleton-card-media').hasStyle({ height: '120px' });
+ assert.dom('.ui-skeleton-card:first-child .ui-skeleton-card-footer .ui-skeleton-badge').exists();
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card-media').doesNotExist();
+ });
+
+ test('panel renders the identity strip, tabs and sections of fields', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-panel-identity .ui-skeleton-avatar').exists();
+ assert.dom('.ui-skeleton-panel-tabs .ui-skeleton').exists({ count: 4 });
+ assert.dom('.ui-skeleton-panel-section').exists({ count: 2 });
+ assert.dom('[data-skeleton-section="0"] .ui-skeleton-field').exists({ count: 3 });
+ });
+
+ test('form and stat tiles render their counts', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-form').hasClass('ui-skeleton-form-cols-2');
+ assert.dom('.ui-skeleton-form-field').exists({ count: 5 });
+ assert.dom('.ui-skeleton-form-field .ui-skeleton-input').exists({ count: 5 });
+ assert.dom('.ui-skeleton-stat-tile').exists({ count: 3 });
+ });
+
+ test('page announces itself once and draws the header and the view', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.ui-skeleton-page').hasAttribute('role', 'status');
+ assert.dom('.ui-skeleton-page').hasAttribute('aria-busy', 'true');
+ assert.dom('.ui-skeleton-page').hasAttribute('data-skeleton-view', 'table');
+ assert.dom('.ui-skeleton-page .ui-skeleton-sr-only').hasText('Loading orders');
+ assert.dom('.ui-skeleton-page-header').exists({ count: 1 });
+ assert.dom('.ui-skeleton-page-header .ui-skeleton-page-header-search').exists();
+ assert.dom('.ui-skeleton-page-header .ui-skeleton-button').exists({ count: 2 });
+ assert.dom('.ui-skeleton-table-card .ui-skeleton-table-card-toolbar').exists();
+ assert.dom('.ui-skeleton-table-card .ui-skeleton-table tbody tr').exists({ count: 4 });
+ assert.dom('.ui-skeleton-table-card .ui-skeleton-table thead th').exists({ count: 7 }, 'six columns plus the checkbox column');
+ assert.dom('.ui-skeleton-table-card .ui-skeleton-table-card-footer').exists();
+ assert.dom('.ui-skeleton-page [aria-hidden="true"]').exists('the boxes themselves are hidden from assistive tech');
+ });
+
+ test('page switches body by view and takes a block', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-page').hasAttribute('data-skeleton-view', 'cards');
+ assert.dom('.ui-skeleton-card').exists({ count: 4 });
+ assert.dom('.ui-skeleton-page-header-search').doesNotExist();
+ assert.dom('.ui-skeleton-page-header .ui-skeleton-avatar').doesNotExist();
+ assert.dom('.ui-skeleton-page-header .ui-skeleton-button').exists({ count: 1 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-stat-tile').exists({ count: 4 });
+ assert.dom('.ui-skeleton-table').exists();
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-form-field').exists({ count: 3 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-page-header').doesNotExist();
+ assert.dom('.ui-skeleton-panel').exists();
+
+ await render(hbs`
custom
`);
+ assert.dom('.ui-skeleton-page').hasAttribute('data-skeleton-view', 'table', 'an unknown view falls back to the table');
+ assert.dom('.ui-skeleton-page-body [data-test-custom]').exists('a block replaces the body');
+ assert.dom('.ui-skeleton-table').doesNotExist();
+ });
+
+ test('page shapes its table card to the page: tabs, toolbar, checkbox, footer and identity column', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-table-card-toolbar [data-skeleton-tab]').exists({ count: 6 });
+ assert.dom('.ui-skeleton-table tbody tr:first-child td:nth-child(3) .ui-skeleton-identity').exists('the second column (after the checkbox) holds the identity');
+ assert.dom('.ui-skeleton-table tbody tr:first-child td:nth-child(2) .ui-skeleton-identity').doesNotExist();
+ assert.dom('.ui-skeleton-page-header').hasClass('ui-skeleton-page-header-borderless');
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-table-card-toolbar').doesNotExist();
+ assert.dom('.ui-skeleton-table-card-footer').doesNotExist();
+ assert.dom('.ui-skeleton-table thead th').exists({ count: 4 }, 'no checkbox column');
+ assert.dom('.ui-skeleton-identity').doesNotExist();
+ });
+
+ test('tabs draw an underlined strip with optional count pills', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-tabs').hasAttribute('aria-hidden', 'true');
+ assert.dom('.ui-skeleton-tabs-item').exists({ count: 3 });
+ assert.dom('.ui-skeleton-tabs-item .ui-skeleton-badge').doesNotExist();
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-tabs-item .ui-skeleton-badge').exists({ count: 2 });
+ });
+
+ test('card follows the card it stands in for', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card > :first-child').hasClass('ui-skeleton-card-media', 'the image leads the card');
+ assert.dom('.ui-skeleton-card-header').doesNotExist();
+ assert.dom('.ui-skeleton-card-body [data-skeleton-line]').exists({ count: 4 });
+ assert.dom('.ui-skeleton-card-footer [data-skeleton-card-action]').exists({ count: 2 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card-header .ui-skeleton-card-identity .ui-skeleton-avatar').exists();
+ assert.dom('.ui-skeleton-card-header > .ui-skeleton-badge').exists();
+ assert.dom('.ui-skeleton-card-media').doesNotExist();
+ assert.dom('.ui-skeleton-card-footer-row > .ui-skeleton-badge').exists('the footer leads with a badge');
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card-header .ui-skeleton-card-identity-text .ui-skeleton').exists({ count: 2 }, 'a title and its subtitle');
+ assert.dom('.ui-skeleton-card-footer').hasClass('ui-skeleton-card-footer-stacked');
+ assert.dom('.ui-skeleton-card-footer [data-skeleton-footer-line]').exists({ count: 3 });
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card-body').doesNotExist('no empty body without lines');
+
+ await render(hbs`
`);
+ assert.dom('.ui-skeleton-card-header .ui-skeleton-circle').doesNotExist();
+ assert.dom('.ui-skeleton-card-media').exists('media after the header by default');
+ });
+});
diff --git a/tests/integration/components/widget/report-test.js b/tests/integration/components/widget/report-test.js
index 4ab496ea..c4a52204 100644
--- a/tests/integration/components/widget/report-test.js
+++ b/tests/integration/components/widget/report-test.js
@@ -1,7 +1,8 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { click, render, settled, waitUntil } from '@ember/test-helpers';
+import { click, clearRender, render, settled, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+import WidgetReportComponent from '@fleetbase/ember-ui/components/widget/report';
class StoreStub {
report = null;
@@ -19,6 +20,22 @@ class StoreStub {
}
}
+class FetchStub {
+ posts = [];
+ response = { success: true, results: [], columns: [], meta: {} };
+ error = null;
+
+ async post(url, data) {
+ this.posts.push({ url, data });
+
+ if (this.error) {
+ throw this.error;
+ }
+
+ return this.response;
+ }
+}
+
class ModalsManagerStub {
shown = [];
@@ -42,10 +59,12 @@ module('Integration | Component | widget/report', function (hooks) {
this.store = new StoreStub();
this.modalsManager = new ModalsManagerStub();
this.notifications = new NotificationsStub();
+ this.fetch = new FetchStub();
this.owner.register('service:store', this.store, { instantiate: false });
this.owner.register('service:modals-manager', this.modalsManager, { instantiate: false });
this.owner.register('service:notifications', this.notifications, { instantiate: false });
+ this.owner.register('service:fetch', this.fetch, { instantiate: false });
});
test('it renders an empty state when no report is selected', async function (assert) {
@@ -56,22 +75,191 @@ module('Integration | Component | widget/report', function (hooks) {
assert.strictEqual(this.store.findRecordCalls.length, 0, 'does not load without a report id');
});
- test('it loads and renders a selected report', async function (assert) {
+ test('it loads a selected report and runs it live', async function (assert) {
this.store.report = {
id: 'report-1',
title: 'Revenue',
result_columns: [{ label: 'Total', name: 'total' }],
data: [{ total: '$42.00' }],
};
+ this.fetch.response = { success: true, results: [{ total: '$99.00' }], columns: [{ name: 'total', label: 'Live Total' }], meta: { total_rows: 1 } };
this.set('options', { reportId: 'report-1' });
await render(hbs`
`);
await waitUntil(() => this.store.findRecordCalls.length === 1);
+ await settled();
assert.deepEqual(this.store.findRecordCalls[0], { modelName: 'report', id: 'report-1' });
+ assert.deepEqual(this.fetch.posts, [{ url: 'reports/report-1/execute', data: undefined }], 'executes the report');
assert.dom('table').exists();
- assert.dom().includesText('Total');
+ assert.dom().includesText('Live Total');
+ assert.dom().includesText('$99.00');
+ assert.dom().doesNotIncludeText('$42.00', 'the stored results are not shown');
+ assert.dom('[data-test-widget-report-last-run]').exists('shows when it last ran');
+ assert.dom('[data-test-widget-report-error]').doesNotExist();
+ });
+
+ test('a record that can execute itself is run through its own method', async function (assert) {
+ let executions = 0;
+ this.store.report = {
+ id: 'report-1',
+ async execute() {
+ executions++;
+ return { success: true, results: [{ n: 7 }], columns: ['n'] };
+ },
+ };
+ this.set('options', { reportId: 'report-1' });
+
+ await render(hbs`
`);
+
+ assert.strictEqual(executions, 1);
+ assert.strictEqual(this.fetch.posts.length, 0, 'fetch is not called directly');
+ assert.dom('table').includesText('7');
+ });
+
+ test('a failed run is shown inline and falls back to the stored results', async function (assert) {
+ this.store.report = {
+ id: 'report-1',
+ result_columns: [{ label: 'Total', name: 'total' }],
+ data: [{ total: '$42.00' }],
+ };
+ this.fetch.error = new Error('Query timed out');
+ this.set('options', { reportId: 'report-1' });
+
+ await render(hbs`
`);
+
+ assert.dom('[data-test-widget-report-error]').includesText('Query timed out');
+ assert.dom('[data-test-widget-report-error]').includesText('Showing saved results');
assert.dom().includesText('$42.00');
+ assert.dom('[data-test-widget-report-stored]').exists();
+ assert.deepEqual(this.notifications.errors, [], 'the failure is not a toast');
+
+ this.fetch.error = null;
+ this.fetch.response = { success: true, results: [{ total: '$50.00' }], columns: [{ name: 'total', label: 'Total' }] };
+ await click('[data-test-widget-report-refresh]');
+
+ assert.strictEqual(this.fetch.posts.length, 2, 'refresh runs the report again');
+ assert.dom('[data-test-widget-report-error]').doesNotExist('a good run clears the error');
+ assert.dom().includesText('$50.00');
+ });
+
+ test('a live result over the row cap is truncated', async function (assert) {
+ this.store.report = { id: 'report-1' };
+ this.fetch.response = {
+ success: true,
+ results: Array.from({ length: 150 }, (_, i) => ({ n: i })),
+ columns: ['n'],
+ };
+ this.set('options', { reportId: 'report-1' });
+
+ await render(hbs`
`);
+
+ assert.dom('tbody tr').exists({ count: 100 });
+ assert.dom('[data-test-widget-report-truncated]').includesText('Showing first 100 of 150 rows');
+ });
+
+ test('an unsuccessful run keeps the last good result and says so', async function (assert) {
+ this.store.report = { id: 'report-1' };
+ this.fetch.response = { success: true, results: [{ total: '$10.00' }], columns: [{ name: 'total', label: 'Total' }] };
+ this.set('options', { reportId: 'report-1' });
+
+ await render(hbs`
`);
+
+ assert.dom().includesText('$10.00');
+
+ this.fetch.response = { success: false, error: 'Report is invalid' };
+ await click('[data-test-widget-report-refresh]');
+
+ assert.dom('[data-test-widget-report-error]').includesText('Report is invalid');
+ assert.dom('[data-test-widget-report-error]').includesText('Showing the last successful run');
+ assert.dom().includesText('$10.00', 'the previous live result stays on screen');
+ });
+
+ test('polling is off by default', async function (assert) {
+ let instance;
+ this.owner.register(
+ 'component:widget/report',
+ class extends WidgetReportComponent {
+ constructor() {
+ super(...arguments);
+ instance = this;
+ }
+ }
+ );
+ this.store.report = { id: 'report-1' };
+ this.set('options', { reportId: 'report-1' });
+
+ await render(hbs`
`);
+
+ assert.strictEqual(instance.refreshInterval, 0);
+ assert.false(instance.pollReport.isRunning, 'no polling task is started');
+ assert.strictEqual(this.fetch.posts.length, 1, 'the report runs once');
+ });
+
+ test('a configured interval polls no faster than five minutes and stops when destroyed', async function (assert) {
+ let instance;
+ const delays = [];
+ this.owner.register(
+ 'component:widget/report',
+ class extends WidgetReportComponent {
+ constructor() {
+ super(...arguments);
+ instance = this;
+ }
+
+ pollDelay(interval) {
+ delays.push(interval);
+ // Let two intervals pass immediately, then wait forever.
+ return delays.length <= 2 ? Promise.resolve() : new Promise(() => {});
+ }
+ }
+ );
+ this.store.report = { id: 'report-1' };
+ this.set('options', { reportId: 'report-1', refreshInterval: 1 });
+
+ await render(hbs`
`);
+ await waitUntil(() => this.fetch.posts.length === 3 && delays.length === 3);
+
+ assert.deepEqual(delays, [300000, 300000, 300000], 'the interval is raised to five minutes');
+ assert.true(instance.pollReport.isRunning, 'the polling task is waiting for the next interval');
+
+ await clearRender();
+ assert.false(instance.pollReport.isRunning, 'destroying the widget stops the polling');
+ });
+
+ test('a widget that has not run has no error, and the polling delay waits out the interval', async function (assert) {
+ let instance;
+ this.owner.register(
+ 'component:widget/report',
+ class extends WidgetReportComponent {
+ constructor() {
+ super(...arguments);
+ instance = this;
+ }
+ }
+ );
+
+ await render(hbs`
`);
+
+ assert.strictEqual(instance.runError, null, 'a widget that has not run has no run error');
+
+ const startedAt = Date.now();
+ await instance.pollDelay(10);
+ assert.true(Date.now() - startedAt >= 10, 'resolves once the interval has passed');
+ });
+
+ test('a selection without an id is not run', async function (assert) {
+ this.set('widget', { options: {} });
+
+ await render(hbs`
`);
+ await click('[data-test-widget-report-select]');
+
+ const modalOptions = this.modalsManager.shown[0].options;
+ modalOptions.onChange([{ title: 'Unsaved' }]);
+ await modalOptions.confirm({ startLoading() {}, done() {} });
+ await settled();
+
+ assert.strictEqual(this.fetch.posts.length, 0, 'nothing is executed without an id');
});
test('a loaded report can be changed, with the current one preselected', async function (assert) {
diff --git a/tests/unit/utils/report-widget-result-test.js b/tests/unit/utils/report-widget-result-test.js
new file mode 100644
index 00000000..1abd614c
--- /dev/null
+++ b/tests/unit/utils/report-widget-result-test.js
@@ -0,0 +1,166 @@
+import mapReportExecutionDefault, {
+ REPORT_WIDGET_ROW_LIMIT,
+ REPORT_WIDGET_MIN_REFRESH_MINUTES,
+ REPORT_WIDGET_DEFAULT_REFRESH_MINUTES,
+ normalizeReportColumns,
+ mapReportExecution,
+ storedReportResult,
+ resolveReportWidgetResult,
+ isFailedExecution,
+ resolveRefreshInterval,
+ reportExecutionErrorMessage,
+} from '@fleetbase/ember-ui/utils/report-widget-result';
+import { module, test } from 'qunit';
+
+function rows(count) {
+ return Array.from({ length: count }, (_, i) => ({ id: i + 1 }));
+}
+
+module('Unit | Utility | report-widget-result', function () {
+ test('the defaults cap at 100 rows and leave polling off', function (assert) {
+ assert.strictEqual(REPORT_WIDGET_ROW_LIMIT, 100);
+ assert.strictEqual(REPORT_WIDGET_MIN_REFRESH_MINUTES, 5);
+ assert.strictEqual(REPORT_WIDGET_DEFAULT_REFRESH_MINUTES, 0);
+ assert.strictEqual(mapReportExecutionDefault, mapReportExecution, 'the default export maps an execution');
+ });
+
+ module('normalizeReportColumns', function () {
+ test('it shapes strings and objects into name/label columns', function (assert) {
+ assert.deepEqual(normalizeReportColumns(['total', { name: 'count', label: 'Count', type: 'int' }, { key: 'k' }, { alias: 'a', label: 'A' }]), [
+ { name: 'total', label: 'total' },
+ { name: 'count', label: 'Count', type: 'int' },
+ { key: 'k', name: 'k', label: 'k' },
+ { alias: 'a', name: 'a', label: 'A' },
+ ]);
+ });
+
+ test('it drops entries without a name and tolerates non-arrays', function (assert) {
+ assert.deepEqual(normalizeReportColumns([null, 3, { label: 'No name' }, { name: '' }]), []);
+ assert.deepEqual(normalizeReportColumns(undefined), []);
+ assert.deepEqual(normalizeReportColumns({ name: 'x' }), []);
+ });
+ });
+
+ module('mapReportExecution', function () {
+ test('it maps results and columns into the Report::Data shape', function (assert) {
+ const result = mapReportExecution({
+ success: true,
+ results: [{ total: 42 }],
+ columns: [{ name: 'total', label: 'Total' }],
+ meta: { total_rows: 1, execution_time_ms: 12 },
+ });
+
+ assert.deepEqual(result, {
+ result_columns: [{ name: 'total', label: 'Total' }],
+ data: [{ total: 42 }],
+ total_rows: 1,
+ truncated: false,
+ meta: { total_rows: 1, execution_time_ms: 12 },
+ source: 'live',
+ });
+ });
+
+ test('it caps the rows at the limit and reports the total', function (assert) {
+ const result = mapReportExecution({ success: true, results: rows(250), columns: ['id'] });
+
+ assert.strictEqual(result.data.length, 100);
+ assert.deepEqual(result.data[99], { id: 100 });
+ assert.strictEqual(result.total_rows, 250);
+ assert.true(result.truncated);
+ });
+
+ test('a server total beyond the returned rows still marks the result truncated', function (assert) {
+ const result = mapReportExecution({ results: rows(10), columns: ['id'], meta: { total_rows: 5000 } }, 10);
+
+ assert.strictEqual(result.data.length, 10);
+ assert.strictEqual(result.total_rows, 5000);
+ assert.true(result.truncated);
+ });
+
+ test('a server total below the returned rows is ignored', function (assert) {
+ const result = mapReportExecution({ results: rows(3), columns: ['id'], meta: { total_rows: 1 } });
+
+ assert.strictEqual(result.total_rows, 3);
+ assert.false(result.truncated);
+ });
+
+ test('it accepts `data` in place of `results` and survives an empty response', function (assert) {
+ assert.deepEqual(mapReportExecution({ data: [{ a: 1 }], columns: ['a'] }).data, [{ a: 1 }]);
+
+ const empty = mapReportExecution(undefined);
+ assert.deepEqual(empty.result_columns, []);
+ assert.deepEqual(empty.data, []);
+ assert.deepEqual(empty.meta, {});
+ assert.false(empty.truncated);
+
+ assert.deepEqual(mapReportExecution({ results: 'nope', meta: 'nope' }).data, []);
+ });
+ });
+
+ module('storedReportResult', function () {
+ test('it shapes and caps the results stored on the record', function (assert) {
+ const result = storedReportResult({ result_columns: [{ name: 'id', label: 'ID' }], data: rows(120), row_count: 120 });
+
+ assert.strictEqual(result.source, 'stored');
+ assert.deepEqual(result.result_columns, [{ name: 'id', label: 'ID' }]);
+ assert.strictEqual(result.data.length, 100);
+ assert.strictEqual(result.total_rows, 120);
+ assert.true(result.truncated);
+ });
+
+ test('it prefers the stored meta total and handles a record without results', function (assert) {
+ assert.strictEqual(storedReportResult({ data: rows(2), meta: { total_rows: 9 }, row_count: 4 }).total_rows, 9);
+
+ const bare = storedReportResult({});
+ assert.deepEqual(bare.result_columns, []);
+ assert.deepEqual(bare.data, []);
+ assert.strictEqual(bare.total_rows, 0);
+ });
+
+ test('there is nothing stored without a record', function (assert) {
+ assert.strictEqual(storedReportResult(null), null);
+ });
+ });
+
+ module('resolveReportWidgetResult', function () {
+ test('a live result wins over the stored one', function (assert) {
+ const live = mapReportExecution({ results: [{ id: 1 }], columns: ['id'] });
+ assert.strictEqual(resolveReportWidgetResult(live, { data: rows(5) }), live);
+ });
+
+ test('without a live result it falls back to the stored results', function (assert) {
+ const fallback = resolveReportWidgetResult(null, { result_columns: ['id'], data: rows(150) });
+
+ assert.strictEqual(fallback.source, 'stored');
+ assert.strictEqual(fallback.data.length, 100);
+ assert.strictEqual(resolveReportWidgetResult(null, null), null);
+ });
+ });
+
+ test('isFailedExecution flags missing and unsuccessful responses', function (assert) {
+ assert.true(isFailedExecution(null));
+ assert.true(isFailedExecution('oops'));
+ assert.true(isFailedExecution({ success: false, error: 'bad' }));
+ assert.false(isFailedExecution({ success: true, results: [] }));
+ assert.false(isFailedExecution({ results: [] }), 'a response without a success flag is treated as a result');
+ });
+
+ test('resolveRefreshInterval is off by default and never faster than five minutes', function (assert) {
+ assert.strictEqual(resolveRefreshInterval(undefined), 0);
+ assert.strictEqual(resolveRefreshInterval({}), 0);
+ assert.strictEqual(resolveRefreshInterval({ refreshInterval: 0 }), 0);
+ assert.strictEqual(resolveRefreshInterval({ refreshInterval: -3 }), 0);
+ assert.strictEqual(resolveRefreshInterval({ refreshInterval: 'soon' }), 0);
+ assert.strictEqual(resolveRefreshInterval({ refreshInterval: 1 }), 5 * 60 * 1000, 'raised to the minimum');
+ assert.strictEqual(resolveRefreshInterval({ refreshInterval: '15' }), 15 * 60 * 1000);
+ });
+
+ test('reportExecutionErrorMessage picks a readable message', function (assert) {
+ assert.strictEqual(reportExecutionErrorMessage('Timed out'), 'Timed out');
+ assert.strictEqual(reportExecutionErrorMessage(new Error('Boom')), 'Boom');
+ assert.strictEqual(reportExecutionErrorMessage({ success: false, error: 'Bad query' }), 'Bad query');
+ assert.strictEqual(reportExecutionErrorMessage({ errors: ['Not allowed'] }), 'Not allowed');
+ assert.strictEqual(reportExecutionErrorMessage(null), 'The report could not be run.');
+ assert.strictEqual(reportExecutionErrorMessage(''), 'The report could not be run.');
+ });
+});