From e6a3903483f3f277d66ba2e57ce0a135ee1e4b95 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 18 Aug 2026 12:44:44 +0800 Subject: [PATCH] Delete confirmed dead code; scope the coverage gate to first-party source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the members and files confirmed unreachable during the coverage work. Four whole components go, along with 26 members across ten files. Whole components (no template, no re-export, or no consumer anywhere in the monorepo — verified by sweeping every package outside ember-ui): - schedule-calendar and schedule-item-card - custom-field/form - report-builder/results-table Members: - query-builder: toggleQueryPreview, showQueryPreview, exportQuery - query-builder/conditions: conditionsMessage, canAddConditions - event-calendar: changeView, today, refetchEvents, refetchResources - template-builder/properties-panel: the query-parameter editor - smart-nav-menu: reorderPinned (passed as @onReorder to a customizer that ignores it; the customizer's own copy is wired and stays) - smart-nav-menu/customizer: unpinnedItems - dashboard/widget-panel: hoveredWidget, onHover, onUnhover - custom-field/yield: toggleGroupEdit, resolveSubject - custom-field/options-input: addMetaOption - layout/resource/panel: save, saveButtonText, controller, onTabChange, onPressEdit, onViewDetails, and the now-orphaned resourceName and resourceType. The live save button is header-actions', driven by the caller's @saveTask; these were a second, unreachable implementation. KEPT after checking: query-builder's `get columns()`. It was listed as dead, but conditions.hbs gates its entire editor on `{{#if @columns}}` — deleting it would blank that panel. Only the four bindings that group-by and sort-by ignore are removed. Also scopes the coverage gate to this package. A pnpm workspace link (@fleetbase/ember-core) is instrumented by the same build and appeared in the report as `../ember-core/...`, adding 561 failure lines and dragging the reported global from 93.66% to 67.42%. check-coverage.js now skips files outside the package and recomputes the global from first-party entries rather than trusting istanbul's total. Self-test covers it (10 cases, up from 9). 4941 tests pass, 0 skips. First-party coverage 93.66% statements / 89.14% branches / 97.18% functions / 94.06% lines. --- .gitignore | 6 + addon/components/custom-field/form.hbs | 77 -------- addon/components/custom-field/form.js | 103 ---------- .../components/custom-field/options-input.js | 22 --- addon/components/custom-field/yield.js | 14 -- addon/components/dashboard/widget-panel.hbs | 2 - addon/components/dashboard/widget-panel.js | 9 - addon/components/event-calendar.js | 29 --- .../layout/header/smart-nav-menu.hbs | 1 - .../layout/header/smart-nav-menu.js | 10 - .../header/smart-nav-menu/customizer.js | 6 - addon/components/layout/resource/panel.js | 67 ------- addon/components/query-builder.hbs | 4 - addon/components/query-builder.js | 15 -- addon/components/query-builder/conditions.js | 16 -- .../report-builder/results-table.hbs | 73 ------- .../report-builder/results-table.js | 46 ----- addon/components/schedule-calendar.hbs | 37 ---- addon/components/schedule-calendar.js | 166 ---------------- addon/components/schedule-item-card.hbs | 39 ---- addon/components/schedule-item-card.js | 74 ------- .../template-builder/properties-panel.js | 32 --- app/components/custom-field/form.js | 1 - .../report-builder/results-table.js | 1 - app/components/schedule-calendar.js | 1 - app/components/schedule-item-card.js | 1 - config/coverage.js | 15 ++ scripts/check-coverage-test.js | 21 +- scripts/check-coverage.js | 34 +++- .../components/custom-field/form-test.js | 151 --------------- .../query-builder/conditions-test.js | 6 +- .../report-builder/results-table-test.js | 182 ------------------ .../components/schedule-item-card-test.js | 166 ---------------- 33 files changed, 68 insertions(+), 1359 deletions(-) delete mode 100644 addon/components/custom-field/form.hbs delete mode 100644 addon/components/custom-field/form.js delete mode 100644 addon/components/report-builder/results-table.hbs delete mode 100644 addon/components/report-builder/results-table.js delete mode 100644 addon/components/schedule-calendar.hbs delete mode 100644 addon/components/schedule-calendar.js delete mode 100644 addon/components/schedule-item-card.hbs delete mode 100644 addon/components/schedule-item-card.js delete mode 100644 app/components/custom-field/form.js delete mode 100644 app/components/report-builder/results-table.js delete mode 100644 app/components/schedule-calendar.js delete mode 100644 app/components/schedule-item-card.js create mode 100644 config/coverage.js delete mode 100644 tests/integration/components/custom-field/form-test.js delete mode 100644 tests/integration/components/report-builder/results-table-test.js delete mode 100644 tests/integration/components/schedule-item-card-test.js diff --git a/.gitignore b/.gitignore index 71ad79d0..4dbc8adb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ # broccoli-debug /DEBUG/ + +# Generated coverage output. `/coverage/` above covers the normal case; these two guard the +# reports istanbul writes for workspace-linked siblings, whose relative paths resolve out of +# the coverage folder and into the package root (see config/coverage.js). +/ember-core/ +/*/addon/**/*.js.html diff --git a/addon/components/custom-field/form.hbs b/addon/components/custom-field/form.hbs deleted file mode 100644 index dadcf9bb..00000000 --- a/addon/components/custom-field/form.hbs +++ /dev/null @@ -1,77 +0,0 @@ -
- - - - - - Field is Required - - - - - Field is Editable - - - - - - {{#if this.currentFieldMap.hasOptions}} - - {{/if}} - {{#if this.currentFieldMap.allowedModels}} - - - - {{/if}} - - -
-
- {{#each this.colSpanSizeOptions as |size|}} - -
-
- -
- {{size}} -
-
- {{#if (eq size @resource.meta.colSpan)}} - - {{/if}} -
-
- {{/each}} -
-
-
-
-
\ No newline at end of file diff --git a/addon/components/custom-field/form.js b/addon/components/custom-field/form.js deleted file mode 100644 index 7fb0c6ea..00000000 --- a/addon/components/custom-field/form.js +++ /dev/null @@ -1,103 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; -import { action } from '@ember/object'; -import { dasherize, camelize } from '@ember/string'; -import { next } from '@ember/runloop'; -import { task } from 'ember-concurrency'; -import isObject from '@fleetbase/ember-core/utils/is-object'; -import getCustomFieldTypeMap from '../../utils/get-custom-field-type-map'; - -export default class CustomFieldFormComponent extends Component { - @service notifications; - @tracked currentFieldMap; - @tracked colSpanSizeOptions = [1, 2, 3]; - customFieldTypeMap = getCustomFieldTypeMap(); - - constructor(owner, { resource }) { - super(...arguments); - next(() => this.selectFieldMap(resource.type)); - } - - /** - * A task for saving the custom field. It handles the save operation asynchronously, - * manages callbacks on success, and shows notifications on error. - * @task - */ - @task *save() { - try { - this.args.resource = yield this.args.resource.save(); - if (typeof this.onCustomFieldSaved === 'function') { - this.onCustomFieldSaved(this.args.resource); - } - } catch (error) { - this.notifications.serverError(error); - return; - } - } - - /** - * Action method to set the name of the custom field. Converts the name to a dasherized string. - * @param {Event} event - The event object containing the new field name. - * @action - */ - @action setCustomFieldName(event) { - const value = event.target.value; - this.args.resource.name = dasherize(value); - } - - /** - * Action method for selecting the custom field type. It updates the field type - * and selects the corresponding field map. - * @param {Event} event - The event object containing the selected field type. - * @action - */ - @action onSelectCustomFieldType(event) { - const value = event.target.value; - const type = dasherize(value); - this.args.resource.type = type; - this.selectFieldMap(type); - } - - /** - * Action method for selecting a model type for the custom field. - * @param {Event} event - The event object containing the selected model type. - * @action - */ - @action onSelectModelType(event) { - const value = event.target.value; - const modelName = dasherize(value); - this.setCustomFieldMetaProperty('modelName', modelName); - } - - /** - * Action method to set a metadata property for the custom field. - * Initializes the metadata object if it doesn't exist. - * @param {string} key - The key of the metadata property. - * @param {*} value - The value to set for the property. - * @action - */ - @action setCustomFieldMetaProperty(key, value) { - if (!isObject(this.args.resource.meta)) { - this.args.resource.set('meta', {}); - } - - const currentMeta = this.args.resource.meta ?? {}; - this.args.resource.set('meta', { ...currentMeta, [key]: value }); - } - - /** - * Selects the field map based on the given field type. - * Updates the current field map and the component for the custom field. - * @param {string} type - The type of the custom field. - */ - selectFieldMap(type) { - if (!type) return; - const fieldKey = camelize(type); - const fieldMap = this.customFieldTypeMap[fieldKey]; - if (fieldMap) { - this.currentFieldMap = fieldMap; - this.args.resource.component = fieldMap.component; - } - } -} diff --git a/addon/components/custom-field/options-input.js b/addon/components/custom-field/options-input.js index 342f5bc7..3d2443af 100644 --- a/addon/components/custom-field/options-input.js +++ b/addon/components/custom-field/options-input.js @@ -2,7 +2,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { isArray } from '@ember/array'; -import isObject from '@fleetbase/ember-core/utils/is-object'; export default class CustomFieldOptionsInputComponent extends Component { @tracked options = {}; @@ -130,25 +129,4 @@ export default class CustomFieldOptionsInputComponent extends Component { this.args.onChange(options, this.args.customField); } } - - /** - * Adds or updates a meta option with the given key and value. - * @param {string} key - The key of the meta option to add or update. - * @param {string} value - The value of the meta option. - */ - @action addMetaOption(key, value) { - if (!isObject(this.args.customField.meta)) { - this.args.customField.set('meta', {}); - } - - const meta = { - ...this.args.customField.meta, - [key]: value, - }; - - this.args.customField.meta = meta; - if (typeof this.args.onMetaChanged === 'function') { - this.args.onMetaChanged(meta, this.args.customField); - } - } } diff --git a/addon/components/custom-field/yield.js b/addon/components/custom-field/yield.js index a3c236f7..13cae561 100644 --- a/addon/components/custom-field/yield.js +++ b/addon/components/custom-field/yield.js @@ -49,7 +49,6 @@ export default class CustomFieldYieldComponent extends Component { } @task *loadCustomFields() { - // const subject = yield this.resolveSubject(); const owner = yield this.resolveOwner(); try { @@ -66,10 +65,6 @@ export default class CustomFieldYieldComponent extends Component { } } - @action toggleGroupEdit(group) { - group.isEditing = !group.isEditing; - } - @action cancelEditing(group) { group.isEditing = false; } @@ -84,15 +79,6 @@ export default class CustomFieldYieldComponent extends Component { } } - async resolveSubject() { - let subject = this.args.subject; - if (isThenable(subject)) { - subject = await subject; - } - - return subject; - } - async resolveOwner() { let owner = this.args.owner; if (isThenable(owner)) { diff --git a/addon/components/dashboard/widget-panel.hbs b/addon/components/dashboard/widget-panel.hbs index 38d6b380..3d425a64 100644 --- a/addon/components/dashboard/widget-panel.hbs +++ b/addon/components/dashboard/widget-panel.hbs @@ -72,8 +72,6 @@ @addedCount={{this.addedCount widget.id}} @isAdding={{this.addWidgetToDashboard.isRunning}} @onAdd={{perform this.addWidgetToDashboard widget}} - @onHover={{fn this.onHover widget}} - @onUnhover={{this.onUnhover}} /> {{/each}} diff --git a/addon/components/dashboard/widget-panel.js b/addon/components/dashboard/widget-panel.js index 606f6b1c..5a3c97ff 100644 --- a/addon/components/dashboard/widget-panel.js +++ b/addon/components/dashboard/widget-panel.js @@ -28,7 +28,6 @@ export default class DashboardWidgetPanelComponent extends Component { @tracked searchQuery = ''; @tracked activeTab = TAB_ALL; - @tracked hoveredWidget = null; get defaultDashboardId() { return this.args.widgetSourceDashboardId ?? this.args.defaultDashboardId ?? 'dashboard'; @@ -125,14 +124,6 @@ export default class DashboardWidgetPanelComponent extends Component { this.searchQuery = ''; } - @action onHover(widget) { - this.hoveredWidget = widget; - } - - @action onUnhover() { - this.hoveredWidget = null; - } - @action onPressClose() { if (typeof this.args.onClose === 'function') { this.args.onClose(); diff --git a/addon/components/event-calendar.js b/addon/components/event-calendar.js index 5de2bd97..57d59c93 100644 --- a/addon/components/event-calendar.js +++ b/addon/components/event-calendar.js @@ -158,35 +158,6 @@ export default class EventCalendarComponent extends Component { // Public API helpers (callable by parent via @onCalendarReady) // ------------------------------------------------------------------------- - /** - * Programmatically change the view type. - * @param {string} viewName e.g. 'resourceTimelineWeek' - */ - @action changeView(viewName) { - this._setOption('view', viewName); - } - - /** - * Navigate the calendar to today. - */ - @action today() { - this._setOption('date', new Date()); - } - - /** - * Refetch events from the events source. - */ - @action refetchEvents() { - this._setOption('events', this.args.events ?? []); - } - - /** - * Refetch resources from the resources source. - */ - @action refetchResources() { - this._setOption('resources', this.args.resources ?? []); - } - // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/addon/components/layout/header/smart-nav-menu.hbs b/addon/components/layout/header/smart-nav-menu.hbs index 94c81b20..2bfc8e50 100644 --- a/addon/components/layout/header/smart-nav-menu.hbs +++ b/addon/components/layout/header/smart-nav-menu.hbs @@ -76,6 +76,5 @@ @maxVisible={{this.maxVisible}} @onApply={{this.applyCustomization}} @onClose={{this.closeCustomizer}} - @onReorder={{this.reorderPinned}} /> {{/if}} diff --git a/addon/components/layout/header/smart-nav-menu.js b/addon/components/layout/header/smart-nav-menu.js index 27504f0f..9f241fad 100644 --- a/addon/components/layout/header/smart-nav-menu.js +++ b/addon/components/layout/header/smart-nav-menu.js @@ -547,14 +547,4 @@ export default class LayoutHeaderSmartNavMenuComponent extends Component { this._distributeFromAllItems(); later(this, this._recalculate, 50); } - /** - * Reorder handler for drag-sort within the customiser. - * Kept here so the customiser sub-component stays stateless. - */ - @action reorderPinned({ sourceList, sourceIndex, targetList, targetIndex }) { - if (sourceList === targetList && sourceIndex === targetIndex) return; - const item = sourceList[sourceIndex]; - sourceList.splice(sourceIndex, 1); - targetList.splice(targetIndex, 0, item); - } } diff --git a/addon/components/layout/header/smart-nav-menu/customizer.js b/addon/components/layout/header/smart-nav-menu/customizer.js index 24e39c85..61aaa3dc 100644 --- a/addon/components/layout/header/smart-nav-menu/customizer.js +++ b/addon/components/layout/header/smart-nav-menu/customizer.js @@ -34,12 +34,6 @@ export default class LayoutHeaderSmartNavMenuCustomizerComponent extends Compone // ─── Computed ───────────────────────────────────────────────────────────── - /** Items that are NOT in the working pinned list. */ - get unpinnedItems() { - const pinnedIds = this.workingPinned.map((i) => i.id); - return (this.args.allItems ?? []).filter((i) => !pinnedIds.includes(i.id)); - } - /** True when the user has reached the maximum allowed pinned items. */ get atPinnedLimit() { return this.workingPinned.length >= (this.args.maxVisible ?? 5); diff --git a/addon/components/layout/resource/panel.js b/addon/components/layout/resource/panel.js index 844af555..4bf4ee4e 100644 --- a/addon/components/layout/resource/panel.js +++ b/addon/components/layout/resource/panel.js @@ -2,12 +2,8 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { capitalize } from '@ember/string'; -import { task } from 'ember-concurrency'; import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback'; import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments'; -import getModelName from '@fleetbase/ember-core/utils/get-model-name'; -import titleize from 'ember-cli-string-helpers/utils/titleize'; export default class LayoutResourcePanelComponent extends Component { @service store; @@ -24,10 +20,6 @@ export default class LayoutResourcePanelComponent extends Component { return this.args.resource; } - get controller() { - return this.args.controller; - } - get width() { return this.args.width ?? '600px'; } @@ -40,28 +32,6 @@ export default class LayoutResourcePanelComponent extends Component { return this.args.authSchema ?? 'fleet-ops'; } - get resourceName() { - return this.resource?.name ?? this.resource?.displayName ?? this.resource?.display_name; - } - - get resourceType() { - const modelName = getModelName(this.resource); - // `titleize(undefined)` returns '' — not nullish — so `??` never reaches the fallback. - return titleize(modelName) || 'Resource'; - } - - get saveButtonText() { - if (this.args.saveButtonText) { - return this.args.saveButtonText; - } - - if (this.resource.isNew) { - return `Create new ${this.resourceType}`; - } - - return 'Save Changes'; - } - constructor() { super(...arguments); applyContextComponentArguments(this); @@ -73,33 +43,10 @@ export default class LayoutResourcePanelComponent extends Component { contextComponentCallback(this, 'onOverlayReady', ...arguments); } - @action onViewDetails() { - // `this.vendor` does not exist on this component — every sibling action uses - // `this.resource`, and both calls here were silently passing undefined. - const isActionOverrided = contextComponentCallback(this, 'onViewDetails', this.resource); - - if (!isActionOverrided) { - this.contextPanel.focus(this.resource, 'viewing'); - } - } - - @action onTabChange(tabName = 'Details') { - const actionCallback = `onView${capitalize(tabName)}`; - const isActionOverrided = contextComponentCallback(this, actionCallback, this.resource); - - if (!isActionOverrided) { - this.contextPanel.focus(this.resource, tabName?.toLowerCase() || 'viewing'); - } - } - @action onPressCancel() { return contextComponentCallback(this, 'onPressCancel', this.resource); } - @action onPressEdit() { - return contextComponentCallback(this, 'onPressEdit', this.resource); - } - @action onOpen() { return contextComponentCallback(this, 'onOpen', { resource: this.resource, panel: this.context }); } @@ -107,18 +54,4 @@ export default class LayoutResourcePanelComponent extends Component { @action onClose() { return contextComponentCallback(this, 'onClose', { resource: this.resource, panel: this.context }); } - - @task *save() { - contextComponentCallback(this, 'onBeforeSave', this.resource); - - try { - this.resource = yield this.resource.save(); - } catch (error) { - this.notifications.serverError(error); - return; - } - - this.notifications.success(`${this.resourceType} ${this.resourceName ? `(${this.resourceName})` : ''} saved successfully.`); - contextComponentCallback(this, 'onAfterSave', this.resource); - } } diff --git a/addon/components/query-builder.hbs b/addon/components/query-builder.hbs index 89c4c43d..e23d61b9 100644 --- a/addon/components/query-builder.hbs +++ b/addon/components/query-builder.hbs @@ -12,7 +12,6 @@ group=(component "query-builder/group-by" groupBy=this.groupBy - columns=this.columns selectedColumns=this.selectedColumns allSelectedColumns=this.allSelectedColumns onChange=(fn this.callbackChange "groupBy") @@ -20,7 +19,6 @@ sort=(component "query-builder/sort-by" sortBy=this.sortBy - columns=this.columns selectedColumns=this.selectedColumns allSelectedColumns=this.allSelectedColumns onChange=(fn this.callbackChange "sortBy") @@ -60,14 +58,12 @@ /> 0; - } - - get conditionsMessage() { - if (!this.args.selectedColumns?.length && !this.args.joins?.length) { - return 'Select columns first to enable filtering'; - } - - if (!this.canAddConditions) { - return 'No selected columns available for filtering'; - } - - return null; - } - get booleanOptions() { return [ { value: true, label: 'True' }, diff --git a/addon/components/report-builder/results-table.hbs b/addon/components/report-builder/results-table.hbs deleted file mode 100644 index 6ea71c52..00000000 --- a/addon/components/report-builder/results-table.hbs +++ /dev/null @@ -1,73 +0,0 @@ -
- {{#if this.isRunning}} -
-
-
- Executing query... -
-
- {{else if this.error}} -
-
- - - {{this.error.message}} - -
-
- {{else if (eq this.pagination.total 0)}} -
-
- -

No results found

-

Try adjusting your query filters

-
-
- {{else}} -
-
- - - - {{#each this.columns as |column|}} - - {{/each}} - - - - {{#each this.data as |row|}} - - {{#each this.columns as |column|}} - - {{/each}} - - {{/each}} - -
-
- {{or column.label column.name}} - -
-
- {{#if (get row column.name)}} - - {{get row column.name}} - - {{else}} - - {{/if}} -
-
- - {{#if (gt this.pagination.total this.pagination.limit)}} -
- -
- {{/if}} -
- {{/if}} -
\ No newline at end of file diff --git a/addon/components/report-builder/results-table.js b/addon/components/report-builder/results-table.js deleted file mode 100644 index eae68158..00000000 --- a/addon/components/report-builder/results-table.js +++ /dev/null @@ -1,46 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; - -export default class ReportBuilderResultsTableComponent extends Component { - get data() { - return this.args.data ?? []; - } - - get columns() { - return this.args.columns ?? []; - } - - get isRunning() { - return Boolean(this.args.isRunning); - } - - get error() { - return this.args.error; - } - - get pagination() { - return this.args.pagination ?? { page: 1, limit: 100, total: this.data.length }; - } - - /** - * `Pagination` speaks `@meta` + `@currentPage`, not the `{ page, limit, total }` shape the - * report builder passes around. Translate rather than handing it arguments it ignores. - */ - get paginationMeta() { - const { page, limit, total } = this.pagination; - const lastPage = limit > 0 ? Math.ceil(total / limit) : 1; - const from = total === 0 ? 0 : (page - 1) * limit + 1; - - return { - current_page: page, - last_page: lastPage, - from, - to: Math.min(page * limit, total), - total, - }; - } - - @action sortBy(colName) { - this.args.onSort?.(colName); - } -} diff --git a/addon/components/schedule-calendar.hbs b/addon/components/schedule-calendar.hbs deleted file mode 100644 index 1fb3b0da..00000000 --- a/addon/components/schedule-calendar.hbs +++ /dev/null @@ -1,37 +0,0 @@ -
- {{!-- - This is a placeholder for FullCalendar integration. - In production, this would use @fullcalendar/web-component or a similar integration. - - The actual implementation would require: - 1. Installing @fullcalendar/core and required plugins - 2. Creating a FullCalendar instance with the calendarOptions - 3. Rendering the calendar in this container - --}} - -
- {{#if (has-block "header")}} -
- {{yield to="header"}} -
- {{/if}} - -
- {{!-- FullCalendar will be rendered here --}} -
-
- - {{#if (has-block "footer")}} - - {{/if}} -
- - {{!-- Custom event rendering via named block --}} - {{#if (has-block "item")}} - - {{/if}} -
diff --git a/addon/components/schedule-calendar.js b/addon/components/schedule-calendar.js deleted file mode 100644 index 36e55c15..00000000 --- a/addon/components/schedule-calendar.js +++ /dev/null @@ -1,166 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; - -/** - * ScheduleCalendar Component - * - * A reusable calendar component for displaying and managing schedules. - * Uses FullCalendar for rendering and supports drag-and-drop, resource views, - * and custom event rendering. - * - * @example - * - * <:item as |item|> - *
- * {{item.title}} - *
- * - *
- */ -export default class ScheduleCalendarComponent extends Component { - @service scheduling; - @service notifications; - - @tracked calendarApi = null; - @tracked selectedDate = null; - @tracked selectedItem = null; - - /** - * Get calendar options - */ - get calendarOptions() { - return { - initialView: this.args.view || 'resourceTimeline', - headerToolbar: { - left: 'prev,next today', - center: 'title', - right: 'resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth', - }, - editable: this.args.editable !== false, - droppable: this.args.droppable !== false, - resources: this.resources, - events: this.events, - eventClick: this.handleEventClick, - eventDrop: this.handleEventDrop, - dateClick: this.handleDateClick, - ...this.args.calendarOptions, - }; - } - - /** - * Transform resources for FullCalendar - */ - get resources() { - if (!this.args.resources) { - return []; - } - - return this.args.resources.map((resource) => ({ - id: resource.id, - title: resource.name || resource.title, - extendedProps: resource, - })); - } - - /** - * Transform schedule items to FullCalendar events - */ - get events() { - if (!this.args.items) { - return []; - } - - return this.args.items.map((item) => ({ - id: item.id, - resourceId: item.assignee_uuid, - title: item.title || 'Scheduled Item', - start: item.start_at, - end: item.end_at, - backgroundColor: this.getEventColor(item), - extendedProps: item, - })); - } - - /** - * Get event color based on status - */ - getEventColor(item) { - const statusColors = { - pending: '#FFA500', - confirmed: '#4CAF50', - in_progress: '#2196F3', - completed: '#9E9E9E', - cancelled: '#F44336', - no_show: '#FF5722', - }; - - return statusColors[item.status] || '#4CAF50'; - } - - /** - * Handle event click - */ - @action - handleEventClick(info) { - this.selectedItem = info.event.extendedProps; - - if (this.args.onItemClick) { - this.args.onItemClick(info.event.extendedProps); - } - } - - /** - * Handle event drop (drag and drop) - */ - @action - async handleEventDrop(info) { - const item = info.event.extendedProps; - const newStart = info.event.start; - const newEnd = info.event.end; - const newResourceId = info.event.getResources()[0]?.id; - - try { - if (this.args.onItemDrop) { - await this.args.onItemDrop(item, { - start_at: newStart, - end_at: newEnd, - assignee_uuid: newResourceId, - }); - } - } catch (error) { - info.revert(); - this.notifications.error('Failed to update schedule item'); - } - } - - /** - * Handle date click - */ - @action - handleDateClick(info) { - this.selectedDate = info.date; - - if (this.args.onDateClick) { - this.args.onDateClick(info.date, info.resource); - } - } - - /** - * Refresh calendar - */ - @action - refresh() { - if (this.calendarApi) { - this.calendarApi.refetchEvents(); - } - } -} diff --git a/addon/components/schedule-item-card.hbs b/addon/components/schedule-item-card.hbs deleted file mode 100644 index 81ac2451..00000000 --- a/addon/components/schedule-item-card.hbs +++ /dev/null @@ -1,39 +0,0 @@ -
-
-
- {{#if (has-block "content")}} - {{yield (hash item=@item) to="content"}} - {{else}} -
- - {{capitalize @item.status}} - -
- -
- {{@item.title}} -
- -
- {{this.timeRange}} -
- - {{#if @item.duration}} -
- Duration: {{@item.duration}} min -
- {{/if}} - {{/if}} -
- - {{#if (has-block "actions")}} -
- {{yield (hash item=@item) to="actions"}} -
- {{/if}} -
-
diff --git a/addon/components/schedule-item-card.js b/addon/components/schedule-item-card.js deleted file mode 100644 index 72354b53..00000000 --- a/addon/components/schedule-item-card.js +++ /dev/null @@ -1,74 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; - -/** - * ScheduleItemCard Component - * - * Displays a schedule item in a card format with customizable content via named blocks. - * - * @example - * - * <:content> - *
- * {{@item.title}} - *
- * - * <:actions> - * - * - * - *
- */ -export default class ScheduleItemCardComponent extends Component { - /** - * Get status badge color - */ - get statusBadgeColor() { - const colors = { - pending: 'yellow', - confirmed: 'green', - in_progress: 'blue', - completed: 'gray', - cancelled: 'red', - no_show: 'orange', - }; - - return colors[this.args.item?.status] || 'gray'; - } - - /** - * Get formatted time range - */ - get timeRange() { - const item = this.args.item; - if (!item?.start_at || !item?.end_at) { - return ''; - } - - const start = new Date(item.start_at); - const end = new Date(item.end_at); - - return `${this.formatTime(start)} - ${this.formatTime(end)}`; - } - - /** - * Format time - */ - formatTime(date) { - return date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - } - - /** - * Handle card click - */ - @action - handleClick(event) { - if (this.args.onClick) { - this.args.onClick(this.args.item, event); - } - } -} diff --git a/addon/components/template-builder/properties-panel.js b/addon/components/template-builder/properties-panel.js index e82bcaa5..da01baba 100644 --- a/addon/components/template-builder/properties-panel.js +++ b/addon/components/template-builder/properties-panel.js @@ -223,38 +223,6 @@ export default class TemplateBuilderPropertiesPanelComponent extends Component { // ── Query data source helpers ──────────────────────────────────────────── - get queryParams() { - return this.element?.query_params ?? []; - } - - @action - addQueryParam() { - if (!this.args.onUpdateElement || !this.element) return; - const params = [...this.queryParams, { key: '', value: '' }]; - this.args.onUpdateElement(this.element.uuid, { query_params: params }); - } - - @action - removeQueryParam(index) { - if (!this.args.onUpdateElement || !this.element) return; - const params = this.queryParams.filter((_, i) => i !== index); - this.args.onUpdateElement(this.element.uuid, { query_params: params }); - } - - @action - updateQueryParamKey(index, event) { - if (!this.args.onUpdateElement || !this.element) return; - const params = this.queryParams.map((p, i) => (i === index ? { ...p, key: event.target.value } : p)); - this.args.onUpdateElement(this.element.uuid, { query_params: params }); - } - - @action - updateQueryParamValue(index, event) { - if (!this.args.onUpdateElement || !this.element) return; - const params = this.queryParams.map((p, i) => (i === index ? { ...p, value: event.target.value } : p)); - this.args.onUpdateElement(this.element.uuid, { query_params: params }); - } - @action addColumn() { if (!this.args.onUpdateElement || !this.element) return; diff --git a/app/components/custom-field/form.js b/app/components/custom-field/form.js deleted file mode 100644 index 4dc23bf1..00000000 --- a/app/components/custom-field/form.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-ui/components/custom-field/form'; diff --git a/app/components/report-builder/results-table.js b/app/components/report-builder/results-table.js deleted file mode 100644 index 786b19f2..00000000 --- a/app/components/report-builder/results-table.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-ui/components/report-builder/results-table'; diff --git a/app/components/schedule-calendar.js b/app/components/schedule-calendar.js deleted file mode 100644 index f238a903..00000000 --- a/app/components/schedule-calendar.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-ui/components/schedule-calendar'; diff --git a/app/components/schedule-item-card.js b/app/components/schedule-item-card.js deleted file mode 100644 index 99f3005a..00000000 --- a/app/components/schedule-item-card.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-ui/components/schedule-item-card'; diff --git a/config/coverage.js b/config/coverage.js new file mode 100644 index 00000000..4741f303 --- /dev/null +++ b/config/coverage.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + excludes: [ + '*/mirage/**/*', + + // A pnpm workspace link (e.g. `@fleetbase/ember-core` symlinked for local development) + // is compiled by this package's build, so istanbul instruments it too. That has two bad + // consequences: a sibling package's files are held to this package's coverage threshold, + // and the HTML reporter writes one page per file at `coverage/../ember-core/...`, which + // resolves OUTSIDE the gitignored coverage folder and into the package root. + '../**/*', + '*/ember-core/**/*', + ], +}; diff --git a/scripts/check-coverage-test.js b/scripts/check-coverage-test.js index 23d59a82..c9bd0fba 100644 --- a/scripts/check-coverage-test.js +++ b/scripts/check-coverage-test.js @@ -68,10 +68,11 @@ withFixture((root) => { assert.strictEqual(result.ok, true, `expected pass, got failures: ${result.failures.join('; ')}`); }); -// 2. Global totals below 100% → fails and names each low metric. +// 2. Global totals are RECOMPUTED from first-party files, so a partial file drives the global +// failure and istanbul's own `total` (which counts workspace siblings too) is ignored. withFixture((root) => { - const result = runCase(root, { total: partialEntry(), 'addon/utils/covered.js': fullEntry() }); - assert.strictEqual(result.ok, false, 'expected partial global coverage to fail'); + const result = runCase(root, { total: fullEntry(), 'addon/utils/covered.js': partialEntry() }); + assert.strictEqual(result.ok, false, 'expected a partial first-party file to fail the global check'); assert.ok( result.failures.some((failure) => failure.includes('global statements coverage is 50%')), `expected a global statements failure, got: ${result.failures.join('; ')}` @@ -141,4 +142,16 @@ withFixture((root) => { assert.strictEqual(result.ok, false, 'a partial file still fails alongside empty ones'); }); -console.log('check-coverage self-test passed (9 cases).'); +// 10. A workspace-linked sibling package (`../ember-core/...`) is instrumented by the same +// build. It must not be gated, and must not pollute the recomputed global total. +withFixture((root) => { + const result = runCase(root, { + total: partialEntry(), + 'addon/utils/covered.js': fullEntry(), + '../ember-core/addon/abilities/dynamic.js': partialEntry(), + }); + assert.strictEqual(result.ok, true, 'a partial sibling package neither fails the gate nor drags the global total down'); + assert.strictEqual(result.failures.filter((f) => f.includes('ember-core')).length, 0, 'and it is never named in the failures'); +}); + +console.log('check-coverage self-test passed (10 cases).'); diff --git a/scripts/check-coverage.js b/scripts/check-coverage.js index 22b31338..720172ad 100644 --- a/scripts/check-coverage.js +++ b/scripts/check-coverage.js @@ -73,18 +73,38 @@ function checkCoverage({ summaryPath, sourceRoot, projectRoot }) { return { ok: false, failures }; } - for (const metric of METRICS) { - if (!isFullyCovered(total[metric])) { - failures.push(`global ${metric} coverage is ${total[metric].pct}% (${total[metric].covered}/${total[metric].total}) — must be 100%`); - } - } - const reported = new Map(); for (const [key, entry] of Object.entries(summary)) { if (key === 'total') { continue; } - reported.set(normalize(key, projectRoot), entry); + + const relative = normalize(key, projectRoot); + + // Only gate on THIS package's own source. A pnpm workspace link (e.g. @fleetbase/ember-core) + // is instrumented by the same build and lands in the report as `../ember-core/...`; holding + // a sibling package to this package's threshold buries the real signal in hundreds of + // foreign failures. + if (relative.startsWith('../')) { + continue; + } + + reported.set(relative, entry); + } + + // Global totals recomputed from first-party entries only. `summary.total` is istanbul's, + // which sums every instrumented file including workspace-linked siblings. + for (const metric of METRICS) { + let covered = 0; + let count = 0; + for (const entry of reported.values()) { + covered += entry[metric].covered; + count += entry[metric].total; + } + const pct = count === 0 ? 100 : Math.round((covered / count) * 10000) / 100; + if (covered !== count) { + failures.push(`global ${metric} coverage is ${pct}% (${covered}/${count}) — must be 100%`); + } } for (const [file, entry] of reported) { diff --git a/tests/integration/components/custom-field/form-test.js b/tests/integration/components/custom-field/form-test.js deleted file mode 100644 index 4db0593b..00000000 --- a/tests/integration/components/custom-field/form-test.js +++ /dev/null @@ -1,151 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, fillIn, findAll } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -function createResource(attributes = {}) { - return { - name: null, - label: null, - description: null, - help_text: null, - type: null, - component: null, - required: false, - editable: false, - options: [], - meta: {}, - set(key, value) { - this[key] = value; - }, - save() { - return Promise.resolve(this); - }, - ...attributes, - }; -} - -module('Integration | Component | custom-field/form', function (hooks) { - setupRenderingTest(hooks); - - test('it renders the form inputs and every available custom field type as an option', async function (assert) { - this.set('resource', createResource()); - - await render(hbs``); - - assert.dom('input[placeholder="Field Label"]').exists(); - assert.dom('input[placeholder="Field Description"]').exists(); - assert.dom('input[placeholder="Field Help Text"]').exists(); - assert.dom('[role="checkbox"]').exists({ count: 2 }); - - const optionValues = findAll('select.form-select option') - .map((option) => option.getAttribute('value')) - .filter(Boolean); - assert.deepEqual(optionValues, ['input', 'phone-input', 'money-input', 'date-time-input', 'date-picker', 'radio-button', 'select', 'file-upload']); - }); - - test('it selects the field map for the resource type on initial render', async function (assert) { - this.set('resource', createResource({ type: 'select' })); - - await render(hbs``); - - assert.strictEqual(this.resource.component, 'select', 'resource component is set from the field type map'); - assert.dom('select.form-select').hasValue('select', 'the resource type is preselected'); - assert.dom(this.element).containsText('Field Options', 'options editor is rendered for field types with options'); - }); - - test('it does not render the options editor for field types without options', async function (assert) { - this.set('resource', createResource({ type: 'input' })); - - await render(hbs``); - - assert.strictEqual(this.resource.component, 'input'); - assert.dom(this.element).doesNotContainText('Field Options'); - }); - - test('choosing a field type updates the resource and shows the options editor', async function (assert) { - this.set('resource', createResource()); - - await render(hbs``); - assert.dom(this.element).doesNotContainText('Field Options'); - - await fillIn('select.form-select', 'radio-button'); - - assert.strictEqual(this.resource.type, 'radio-button', 'resource type is updated'); - assert.strictEqual(this.resource.component, 'radio-button-select', 'resource component is mapped from the type'); - assert.dom(this.element).containsText('Field Options', 'options editor appears for option based field types'); - }); - - test('typing a field label dasherizes it into the resource name', async function (assert) { - this.set('resource', createResource()); - - await render(hbs``); - await fillIn('input[placeholder="Field Label"]', 'Delivery Instructions'); - - assert.strictEqual(this.resource.name, 'delivery-instructions'); - }); - - test('toggling required updates the resource', async function (assert) { - this.set('resource', createResource()); - - await render(hbs``); - await click('[role="checkbox"]'); - - assert.true(this.resource.required, 'resource is flagged as required after toggling'); - }); - module('the column span meta property', function () { - async function openColumnSpanMenu() { - const trigger = findAll('.ember-basic-dropdown-trigger').find((node) => node.textContent.includes('Column Span Size')); - await click(trigger); - } - - function spanOption(size) { - return findAll('.next-dd-item').find((item) => item.textContent.trim().startsWith(String(size))); - } - - test('choosing a size writes it into the resource meta', async function (assert) { - const resource = createResource(); - this.set('resource', resource); - - await render(hbs``); - await openColumnSpanMenu(); - await click(spanOption(2)); - - assert.deepEqual(resource.meta, { colSpan: 2 }, 'the meta object is replaced rather than mutated in place'); - }); - - test('the existing meta is preserved when a size is chosen', async function (assert) { - const resource = createResource({ meta: { modelName: 'driver' } }); - this.set('resource', resource); - - await render(hbs``); - await openColumnSpanMenu(); - await click(spanOption(3)); - - assert.deepEqual(resource.meta, { modelName: 'driver', colSpan: 3 }); - }); - - // A custom field that has never carried metadata arrives with `meta` unset, and the - // component has to create it before it can spread it. - test('a resource with no meta at all gets one', async function (assert) { - const resource = createResource({ meta: null }); - this.set('resource', resource); - - await render(hbs``); - await openColumnSpanMenu(); - await click(spanOption(1)); - - assert.deepEqual(resource.meta, { colSpan: 1 }); - }); - }); - - test('an unrecognised field type leaves the component unassigned', async function (assert) { - const resource = createResource({ type: 'space-invader' }); - this.set('resource', resource); - - await render(hbs``); - - assert.strictEqual(resource.component, null, 'no component is picked for a type the map does not know'); - assert.dom('.custom-field-options-input').doesNotExist('and no options editor is offered'); - }); -}); diff --git a/tests/integration/components/query-builder/conditions-test.js b/tests/integration/components/query-builder/conditions-test.js index d7489d6b..a21d32fc 100644 --- a/tests/integration/components/query-builder/conditions-test.js +++ b/tests/integration/components/query-builder/conditions-test.js @@ -362,9 +362,9 @@ module('Integration | Component | query-builder/conditions', function (hooks) { } module('the available columns', function () { - // DEFECT (see DEFECTS.md #93): the component exposes `conditionsMessage` and - // `canAddConditions` getters with their own wording, but the template hard-codes its - // own empty-state copy and never reads either getter. + // The template hard-codes its own empty-state copy. The component used to carry + // `conditionsMessage` and `canAddConditions` getters with competing wording that nothing + // read; both were deleted (DEFECTS.md #93). test('with no columns at all it explains what to do first', async function (assert) { this.setProperties({ columns: undefined, allSelectedColumns: undefined }); diff --git a/tests/integration/components/report-builder/results-table-test.js b/tests/integration/components/report-builder/results-table-test.js deleted file mode 100644 index 43a8747c..00000000 --- a/tests/integration/components/report-builder/results-table-test.js +++ /dev/null @@ -1,182 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, find, findAll } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -const COLUMNS = [{ name: 'public_id', label: 'ID' }, { name: 'status' }]; - -const ROWS = [ - { public_id: 'ord_1', status: 'pending' }, - { public_id: 'ord_2', status: null }, -]; - -function headerLabels() { - return findAll('thead th').map((th) => th.textContent.trim()); -} - -function bodyCells() { - return findAll('tbody td').map((td) => td.textContent.trim()); -} - -module('Integration | Component | report-builder/results-table', function (hooks) { - setupRenderingTest(hooks); - - let sorts; - - hooks.beforeEach(function () { - sorts = []; - this.set('columns', COLUMNS); - this.set('data', ROWS); - this.set('pagination', { page: 1, limit: 100, total: 2 }); - this.set('onSort', (name) => sorts.push(name)); - }); - - const TEMPLATE = hbs` - - `; - - module('states', function () { - test('a running query shows a progress message', async function (assert) { - this.set('isRunning', true); - - await render(TEMPLATE); - - assert.dom('.results-table').containsText('Executing query...'); - assert.strictEqual(find('table'), null, 'no results are shown yet'); - }); - - test('a failed query shows the error message', async function (assert) { - this.set('error', { message: 'Unknown column "foo"' }); - - await render(TEMPLATE); - - assert.dom('.results-table').containsText('Unknown column "foo"'); - assert.dom('.results-table svg').hasClass('fa-triangle-exclamation'); - assert.strictEqual(find('table'), null); - }); - - test('a running query wins over an error', async function (assert) { - this.setProperties({ isRunning: true, error: { message: 'boom' } }); - - await render(TEMPLATE); - - assert.dom('.results-table').containsText('Executing query...'); - assert.dom('.results-table').doesNotContainText('boom'); - }); - - test('an empty result set explains itself', async function (assert) { - this.setProperties({ data: [], pagination: { page: 1, limit: 100, total: 0 } }); - - await render(TEMPLATE); - - assert.dom('.results-table').containsText('No results found'); - assert.dom('.results-table').containsText('Try adjusting your query filters'); - assert.strictEqual(find('table'), null); - }); - }); - - module('the results', function () { - test('it renders a column per definition, labelled or named', async function (assert) { - await render(TEMPLATE); - - assert.deepEqual(headerLabels(), ['ID', 'status'], 'a column without a label falls back to its name'); - }); - - test('every header offers to sort', async function (assert) { - await render(TEMPLATE); - - assert.strictEqual(findAll('thead th svg.fa-sort').length, 2); - }); - - test('it renders a row per record', async function (assert) { - await render(TEMPLATE); - - assert.strictEqual(findAll('tbody tr').length, 2); - assert.deepEqual(bodyCells(), ['ord_1', 'pending', 'ord_2', '—'], 'an empty cell renders a dash'); - }); - - test('clicking a header reports the sort', async function (assert) { - await render(TEMPLATE); - await click(findAll('thead th')[1]); - - assert.deepEqual(sorts, ['status'], 'the column name is reported, not its label'); - }); - - test('a result set with no columns renders empty rows', async function (assert) { - this.set('columns', []); - - await render(TEMPLATE); - - assert.deepEqual(headerLabels(), []); - assert.strictEqual(findAll('tbody tr').length, 2, 'the rows still render'); - assert.deepEqual(bodyCells(), []); - }); - - test('clicking a header without an onSort handler is a no-op', async function (assert) { - await render(hbs` - - `); - - await click(findAll('thead th')[1]); - - assert.strictEqual(findAll('tbody tr').length, 2, 'the table survives the click'); - }); - }); - - module('pagination', function () { - test('no pagination is shown while everything fits on one page', async function (assert) { - await render(TEMPLATE); - - assert.strictEqual(find('#fleetbase-pagination'), null); - }); - - test('a second page renders a pager describing the range', async function (assert) { - this.set('pagination', { page: 1, limit: 2, total: 5 }); - - await render(TEMPLATE); - - assert.dom('#fleetbase-pagination').exists('the pager renders instead of throwing'); - assert.dom('.fleetbase-pagination').containsText('5', 'the total is reported'); - assert.deepEqual( - findAll('.page-item').map((button) => button.textContent.trim()), - ['1', '2', '3'], - 'three pages of two are offered' - ); - }); - - test('the pager reports the page the reader chose', async function (assert) { - const pages = []; - this.set('pagination', { page: 1, limit: 2, total: 5 }); - this.set('onPageChange', (page) => pages.push(page)); - - await render(hbs` - - `); - - await click(findAll('.page-item')[2]); - - assert.deepEqual(pages, [3]); - }); - - test('the pager starts on the page the caller asked for', async function (assert) { - this.set('pagination', { page: 2, limit: 2, total: 5 }); - - await render(TEMPLATE); - - assert.dom(findAll('.page-item')[1]).hasClass('active', 'page two is the active one'); - }); - }); -}); diff --git a/tests/integration/components/schedule-item-card-test.js b/tests/integration/components/schedule-item-card-test.js deleted file mode 100644 index 80b33430..00000000 --- a/tests/integration/components/schedule-item-card-test.js +++ /dev/null @@ -1,166 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, find } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -const CARD = '.schedule-item-card'; - -function item(overrides = {}) { - return { - id: 'item_1', - title: 'Collect the pallet', - status: 'confirmed', - start_at: '2024-03-04T09:00:00Z', - end_at: '2024-03-04T10:30:00Z', - ...overrides, - }; -} - -// The card formats its own times with `toLocaleTimeString`, so the expectation has to be built the -// same way rather than hard-coded — the test runner's timezone is not ours to assume. -function localTime(value) { - return new Date(value).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); -} - -module('Integration | Component | schedule-item-card', function (hooks) { - setupRenderingTest(hooks); - - hooks.beforeEach(function () { - this.set('item', item()); - }); - - module('the default presentation', function () { - test('it renders the title, the humanised status and the time range', async function (assert) { - await render(hbs``); - - assert.dom(CARD).exists(); - assert.dom(CARD).includesText('Collect the pallet'); - assert.dom(CARD).includesText('Confirmed', 'the status is capitalised'); - assert.dom(CARD).includesText(`${localTime('2024-03-04T09:00:00Z')} - ${localTime('2024-03-04T10:30:00Z')}`); - }); - - test('a duration is shown when the item carries one', async function (assert) { - this.set('item', item({ duration: 90 })); - - await render(hbs``); - - assert.dom(CARD).includesText('Duration: 90 min'); - }); - - test('no duration line is rendered without one', async function (assert) { - await render(hbs``); - - assert.dom(CARD).doesNotIncludeText('Duration:'); - }); - - test('it forwards splattributes', async function (assert) { - await render(hbs``); - - assert.dom('[data-test-card="yes"]').exists(); - }); - }); - - // Each status maps to a tailwind colour word that is interpolated into the badge's classes. - module('the status badge', function () { - const TONES = [ - ['pending', 'yellow'], - ['confirmed', 'green'], - ['in_progress', 'blue'], - ['completed', 'gray'], - ['cancelled', 'red'], - ['no_show', 'orange'], - ]; - - TONES.forEach(([status, colour]) => { - test(`${status} is toned ${colour}`, async function (assert) { - this.set('item', item({ status })); - - await render(hbs``); - - assert.dom(`${CARD} span`).hasClass(`bg-${colour}-100`); - assert.dom(`${CARD} span`).hasClass(`text-${colour}-800`); - }); - }); - - test('an unrecognised status falls back to grey', async function (assert) { - this.set('item', item({ status: 'invented' })); - - await render(hbs``); - - assert.dom(`${CARD} span`).hasClass('bg-gray-100'); - }); - - test('an item with no status at all falls back to grey', async function (assert) { - await render(hbs``); - - assert.dom(`${CARD} span`).hasClass('bg-gray-100', 'a missing item is not a crash'); - }); - }); - - module('the time range', function () { - test('an item missing its end time shows no range', async function (assert) { - this.set('item', item({ end_at: null })); - - await render(hbs``); - - assert.dom(CARD).doesNotIncludeText(' - ', 'a half-open range is not rendered'); - }); - - test('an item missing its start time shows no range', async function (assert) { - this.set('item', item({ start_at: null })); - - await render(hbs``); - - assert.dom(CARD).doesNotIncludeText(' - '); - }); - }); - - module('named blocks', function () { - test('a content block replaces the default body and is given the item', async function (assert) { - await render(hbs` - - <:content as |ctx|> -
{{ctx.item.title}} in a block
- -
- `); - - assert.dom('.custom-content').hasText('Collect the pallet in a block'); - assert.dom(CARD).doesNotIncludeText('Confirmed', 'the default badge is not rendered as well'); - }); - - test('an actions block renders alongside the default body', async function (assert) { - await render(hbs` - - <:actions as |ctx|> - - - - `); - - assert.dom('.edit-action').hasText('Edit item_1'); - assert.dom(CARD).includesText('Collect the pallet', 'the default body is kept'); - }); - }); - - module('activating the card', function () { - test('clicking reports the item and the event', async function (assert) { - const clicks = []; - this.set('onClick', (clicked, event) => clicks.push({ clicked, type: event?.type })); - - await render(hbs``); - await click(CARD); - - assert.strictEqual(clicks.length, 1, 'the handler runs once'); - assert.strictEqual(clicks[0].clicked, this.item, 'it is handed the item'); - assert.strictEqual(clicks[0].type, 'click', 'alongside the original event'); - }); - - test('clicking without a handler is inert', async function (assert) { - await render(hbs``); - await click(CARD); - - assert.ok(find(CARD), 'the card survives a click it has nothing to do with'); - }); - }); -});