From b2d4b5a3b8e4f4283b0166ef70854c3fc8ada9a5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 11 Oct 2026 16:21:13 +0800 Subject: [PATCH 01/13] feat(widget): run the report widget live instead of showing stored results --- addon/components/widget/report.hbs | 40 +++- addon/components/widget/report.js | 128 ++++++++++++- addon/utils/report-widget-result.js | 159 ++++++++++++++++ app/utils/report-widget-result.js | 1 + .../components/widget/report-test.js | 173 +++++++++++++++++- tests/unit/utils/report-widget-result-test.js | 166 +++++++++++++++++ 6 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 addon/utils/report-widget-result.js create mode 100644 app/utils/report-widget-result.js create mode 100644 tests/unit/utils/report-widget-result-test.js 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/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/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/tests/integration/components/widget/report-test.js b/tests/integration/components/widget/report-test.js index 4ab496ea..c96a8b62 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,170 @@ 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 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.'); + }); +}); From e6587bfe4a50dd460c7bccc94157546ef610385a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 00:13:56 +0800 Subject: [PATCH 02/13] feat(skeleton): skeleton loading components and opaque dark tiers for cards, buttons and inputs Adds a Skeleton family (Skeleton, Skeleton::Text, Table, Card, CardsGrid, Panel, Form, StatTiles, PageHeader and the Skeleton::Page composite for route loading templates) with a shimmer that respects reduced motion and a single role=status announcement. Dark palette: the resource card gets hook classes and an opaque surface with hairline inner borders and a raised footer instead of a half-transparent fill; the default button border is one step lighter than its fill so it has an edge on every surface; inputs are inset (darker than the surface) with a visible border. Version 0.4.6. --- addon/components/layout/resource/card.js | 9 +- addon/components/layout/resource/card/body.js | 2 +- .../components/layout/resource/card/footer.js | 2 +- .../components/layout/resource/card/header.js | 2 +- addon/components/skeleton.hbs | 1 + addon/components/skeleton.js | 19 + addon/components/skeleton/card.hbs | 22 + addon/components/skeleton/card.js | 12 + addon/components/skeleton/cards-grid.hbs | 5 + addon/components/skeleton/cards-grid.js | 13 + addon/components/skeleton/form.hbs | 8 + addon/components/skeleton/form.js | 13 + addon/components/skeleton/page-header.hbs | 21 + addon/components/skeleton/page-header.js | 11 + addon/components/skeleton/page.hbs | 51 ++ addon/components/skeleton/page.js | 16 + addon/components/skeleton/panel.hbs | 30 ++ addon/components/skeleton/panel.js | 19 + addon/components/skeleton/stat-tiles.hbs | 9 + addon/components/skeleton/stat-tiles.js | 8 + addon/components/skeleton/table.hbs | 37 ++ addon/components/skeleton/table.js | 33 ++ addon/components/skeleton/text.hbs | 5 + addon/components/skeleton/text.js | 9 + addon/styles/addon.css | 1 + addon/styles/components/button.css | 2 +- addon/styles/components/cards.css | 18 + addon/styles/components/input.css | 4 +- addon/styles/components/skeleton.css | 476 ++++++++++++++++++ addon/utils/skeleton-base-component.js | 16 + app/components/skeleton.js | 1 + app/components/skeleton/card.js | 1 + app/components/skeleton/cards-grid.js | 1 + app/components/skeleton/form.js | 1 + app/components/skeleton/page-header.js | 1 + app/components/skeleton/page.js | 1 + app/components/skeleton/panel.js | 1 + app/components/skeleton/stat-tiles.js | 1 + app/components/skeleton/table.js | 1 + app/components/skeleton/text.js | 1 + package.json | 2 +- tests/integration/components/skeleton-test.js | 122 +++++ 42 files changed, 997 insertions(+), 11 deletions(-) create mode 100644 addon/components/skeleton.hbs create mode 100644 addon/components/skeleton.js create mode 100644 addon/components/skeleton/card.hbs create mode 100644 addon/components/skeleton/card.js create mode 100644 addon/components/skeleton/cards-grid.hbs create mode 100644 addon/components/skeleton/cards-grid.js create mode 100644 addon/components/skeleton/form.hbs create mode 100644 addon/components/skeleton/form.js create mode 100644 addon/components/skeleton/page-header.hbs create mode 100644 addon/components/skeleton/page-header.js create mode 100644 addon/components/skeleton/page.hbs create mode 100644 addon/components/skeleton/page.js create mode 100644 addon/components/skeleton/panel.hbs create mode 100644 addon/components/skeleton/panel.js create mode 100644 addon/components/skeleton/stat-tiles.hbs create mode 100644 addon/components/skeleton/stat-tiles.js create mode 100644 addon/components/skeleton/table.hbs create mode 100644 addon/components/skeleton/table.js create mode 100644 addon/components/skeleton/text.hbs create mode 100644 addon/components/skeleton/text.js create mode 100644 addon/styles/components/skeleton.css create mode 100644 addon/utils/skeleton-base-component.js create mode 100644 app/components/skeleton.js create mode 100644 app/components/skeleton/card.js create mode 100644 app/components/skeleton/cards-grid.js create mode 100644 app/components/skeleton/form.js create mode 100644 app/components/skeleton/page-header.js create mode 100644 app/components/skeleton/page.js create mode 100644 app/components/skeleton/panel.js create mode 100644 app/components/skeleton/stat-tiles.js create mode 100644 app/components/skeleton/table.js create mode 100644 app/components/skeleton/text.js create mode 100644 tests/integration/components/skeleton-test.js 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..7888886d --- /dev/null +++ b/addon/components/skeleton/card.hbs @@ -0,0 +1,22 @@ + diff --git a/addon/components/skeleton/card.js b/addon/components/skeleton/card.js new file mode 100644 index 00000000..8accb1c3 --- /dev/null +++ b/addon/components/skeleton/card.js @@ -0,0 +1,12 @@ +import Component from '@glimmer/component'; + +/** + * A resource card while its model loads: title row, media region, two body lines and a + * footer with a badge and meta values — the `Layout::Resource::Card` anatomy. + * `@mediaHeight` (px or CSS length) sizes the media region; `@hideMedia` drops it. + */ +export default class SkeletonCardComponent extends Component { + get mediaHeight() { + return this.args.mediaHeight ?? 160; + } +} diff --git a/addon/components/skeleton/cards-grid.hbs b/addon/components/skeleton/cards-grid.hbs new file mode 100644 index 00000000..0fa801fe --- /dev/null +++ b/addon/components/skeleton/cards-grid.hbs @@ -0,0 +1,5 @@ + 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..d2b2a3bd --- /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..218f06ee --- /dev/null +++ b/addon/components/skeleton/page.hbs @@ -0,0 +1,51 @@ +
+ + {{#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}} +
+
diff --git a/addon/components/skeleton/page.js b/addon/components/skeleton/page.js new file mode 100644 index 00000000..5ba0c8ee --- /dev/null +++ b/addon/components/skeleton/page.js @@ -0,0 +1,16 @@ +import Component from '@glimmer/component'; + +export const SKELETON_PAGE_VIEWS = ['table', 'cards', 'form', 'dashboard', 'panel']; + +/** + * 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`. + */ +export default class SkeletonPageComponent extends Component { + get view() { + const { view } = this.args; + return SKELETON_PAGE_VIEWS.includes(view) ? view : 'table'; + } +} 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..7fadcca8 --- /dev/null +++ b/addon/components/skeleton/table.js @@ -0,0 +1,33 @@ +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; + } + + get hasIdentityColumn() { + return this.args.identityColumn !== false; + } + + get columns() { + return this.range(this.args.columns, 5).map((index) => ({ + index, + isIdentity: this.hasIdentityColumn && index === 0, + 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/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/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/button.css b/addon/styles/components/button.css index f0842175..03879480 100644 --- a/addon/styles/components/button.css +++ b/addon/styles/components/button.css @@ -406,7 +406,7 @@ button.integrated-vendor-button:visited, body[data-theme='dark'] .ui-full-calendar .fc-button.btn-default, body[data-theme='dark'] .fc-button.btn-default, body[data-theme='dark'] .btn.btn-default { - @apply text-gray-300 bg-gray-700 border-gray-900 shadow; + @apply text-gray-300 bg-gray-700 border-gray-600 shadow; } body[data-theme='dark'] .ui-full-calendar .fc-button.btn-default:hover:not(:disabled), 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/input.css b/addon/styles/components/input.css index 7307b232..220e9707 100644 --- a/addon/styles/components/input.css +++ b/addon/styles/components/input.css @@ -37,7 +37,7 @@ select.form-select.has--placeholder { .fleetbase-console .form-textarea, .fleetbase-console .form-select, .fleetbase-console .form-multiselect { - @apply border border-gray-300 dark:border-gray-900; + @apply border border-gray-300 dark:border-gray-600; } body[data-theme='dark'] .fleetbase-console .form-input, @@ -106,7 +106,7 @@ select.form-select, body[data-theme='dark'] .form-input.ember-power-select-trigger, body[data-theme='dark'] .form-input, body[data-theme='dark'] .form-select { - @apply text-sm text-white bg-gray-700 border border-gray-900 shadow-sm shadow-gray-900 outline-0; + @apply text-sm text-white bg-gray-900 border border-gray-600 shadow-sm shadow-gray-900 outline-0; } body[data-theme='dark'] .form-input.ember-power-select-trigger:active, diff --git a/addon/styles/components/skeleton.css b/addon/styles/components/skeleton.css new file mode 100644 index 00000000..e2f94008 --- /dev/null +++ b/addon/styles/components/skeleton.css @@ -0,0 +1,476 @@ +/* + * 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; +} + +/* ── Cards grid: 4-up at 1440, 2-up at 1024, 1-up on phones ── */ +.ui-skeleton-cards-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.ui-skeleton-cards-grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.ui-skeleton-cards-grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ui-skeleton-cards-grid-cols-3 { + grid-template-columns: repeat(3, 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 (max-width: 1279px) { + .ui-skeleton-cards-grid-cols-3, + .ui-skeleton-cards-grid-cols-4, + .ui-skeleton-cards-grid-cols-5, + .ui-skeleton-cards-grid-cols-6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767px) { + .ui-skeleton-cards-grid { + grid-template-columns: repeat(1, 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-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; +} 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/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/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/skeleton-test.js b/tests/integration/components/skeleton-test.js new file mode 100644 index 00000000..cbd989c1 --- /dev/null +++ b/tests/integration/components/skeleton-test.js @@ -0,0 +1,122 @@ +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(); + }); +}); From ba18b8fc934b43332011e8b9cd6b46f8c734c602 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 01:44:29 +0800 Subject: [PATCH 03/13] revert(input): restore the dark-mode input fill and border The inset dark inputs did not look right in the console; inputs go back to exactly what 0.4.5 shipped. The skeleton components, card tiers and button edge stay. --- addon/styles/components/input.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addon/styles/components/input.css b/addon/styles/components/input.css index 220e9707..7307b232 100644 --- a/addon/styles/components/input.css +++ b/addon/styles/components/input.css @@ -37,7 +37,7 @@ select.form-select.has--placeholder { .fleetbase-console .form-textarea, .fleetbase-console .form-select, .fleetbase-console .form-multiselect { - @apply border border-gray-300 dark:border-gray-600; + @apply border border-gray-300 dark:border-gray-900; } body[data-theme='dark'] .fleetbase-console .form-input, @@ -106,7 +106,7 @@ select.form-select, body[data-theme='dark'] .form-input.ember-power-select-trigger, body[data-theme='dark'] .form-input, body[data-theme='dark'] .form-select { - @apply text-sm text-white bg-gray-900 border border-gray-600 shadow-sm shadow-gray-900 outline-0; + @apply text-sm text-white bg-gray-700 border border-gray-900 shadow-sm shadow-gray-900 outline-0; } body[data-theme='dark'] .form-input.ember-power-select-trigger:active, From f289b6edd47d588415f9e5893496ee2649f069ac Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 10:56:50 +0800 Subject: [PATCH 04/13] revert(button): restore the dark-mode default button border Button styling waits for the platform-wide ember-ui pass; 0.4.5's default button is back. --- addon/styles/components/button.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/styles/components/button.css b/addon/styles/components/button.css index 03879480..f0842175 100644 --- a/addon/styles/components/button.css +++ b/addon/styles/components/button.css @@ -406,7 +406,7 @@ button.integrated-vendor-button:visited, body[data-theme='dark'] .ui-full-calendar .fc-button.btn-default, body[data-theme='dark'] .fc-button.btn-default, body[data-theme='dark'] .btn.btn-default { - @apply text-gray-300 bg-gray-700 border-gray-600 shadow; + @apply text-gray-300 bg-gray-700 border-gray-900 shadow; } body[data-theme='dark'] .ui-full-calendar .fc-button.btn-default:hover:not(:disabled), From 0695ae81f36fcad19b0f1e7c289d5108febf1703 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 13:42:00 +0800 Subject: [PATCH 05/13] feat(skeleton): page, table card, card and tab strip skeletons follow the page they stand in for --- addon/components/skeleton/card.hbs | 77 +++++++++++++++---- addon/components/skeleton/card.js | 36 +++++++-- addon/components/skeleton/cards-grid.hbs | 15 +++- addon/components/skeleton/page-header.hbs | 2 +- addon/components/skeleton/page.hbs | 35 +++++---- addon/components/skeleton/page.js | 28 ++++++- addon/components/skeleton/table.js | 14 +++- addon/components/skeleton/tabs.hbs | 10 +++ addon/components/skeleton/tabs.js | 13 ++++ addon/styles/components/skeleton.css | 57 ++++++++++++++ app/components/skeleton/tabs.js | 1 + tests/integration/components/skeleton-test.js | 50 ++++++++++++ 12 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 addon/components/skeleton/tabs.hbs create mode 100644 addon/components/skeleton/tabs.js create mode 100644 app/components/skeleton/tabs.js diff --git a/addon/components/skeleton/card.hbs b/addon/components/skeleton/card.hbs index 7888886d..353e6888 100644 --- a/addon/components/skeleton/card.hbs +++ b/addon/components/skeleton/card.hbs @@ -1,22 +1,69 @@