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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@ + 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 @@
{{#if this.report}} -
-
+ {{#if this.runError}} + + {{/if}}
- +
+ {{#if this.result.truncated}} +
+ Showing first + {{this.result.data.length}} + of + {{this.result.total_rows}} + rows +
+ {{/if}} {{else if this.loadReport.isRunning}}
@@ -23,4 +55,4 @@
{{/if}} -
+ \ 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.'); + }); +});