diff --git a/.eslintrc.js b/.eslintrc.js index 22db5fe1..aa4d87bb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,6 +44,8 @@ module.exports = { '@tailwindcss/forms', // Build-time only: required from index.js when COVERAGE=true. 'ember-cli-code-coverage', + // Dev-only: scripts/build-playground.js resolves ember-cli's bin from this package. + 'ember-cli', ], }, ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 930174f2..4625a019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,10 @@ jobs: - name: Verify LCOV report exists run: test -s coverage/lcov.info || (echo 'coverage/lcov.info missing or empty' && exit 1) + # The coverage gate above is what blocks a merge; Codecov only reports. A Codecov outage (its + # CLI download host refusing TLS, say) shows up as a failed step without failing the job. - name: Upload coverage to Codecov + continue-on-error: true uses: codecov/codecov-action@v5 with: files: coverage/lcov.info diff --git a/RELEASE.md b/RELEASE.md index aae03327..80a0bb39 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,41 +1,19 @@ -> v0.4.4 ~ "An interactive component playground, signature pad, verified 100% test coverage, and a report builder for order reporting" +> v0.4.5 ~ "Tables and panels render extension-registered columns, actions and buttons" --- ## Highlights -- **Interactive component playground** — every component documented at - [fleetbase.io/docs/ui](https://fleetbase.io/docs/ui) now has a page where you can change its - arguments and watch the real component react, plus a minimal view the documentation site embeds - in an iframe. Argument state travels in the URL, so a configured example is a shareable link. - Built from the addon's own dummy application, so the previews are the real components with the - real styles — see [PLAYGROUND.md](PLAYGROUND.md). -- **Signature pad** — a new `` component and a matching custom field - type for capturing signatures. -- **Verified 100% test coverage** — 5,400 tests now cover every statement, branch, - function and line in the addon, enforced by a coverage gate in CI with Codecov - upload. Every remaining `istanbul ignore` carries a reason naming the specific - thing that makes its code unreachable. -- **Defects fixed along the way** — the coverage work surfaced and fixed real bugs, - including: popover arrows are now actually positioned against their target, - popover's `hide()` no longer spins requestAnimationFrame forever, the conditions - panel in the query builder opens only when there are selected columns to filter - on, sidebar search results from a `@searchProvider` can open nested sections via - `result.path`, report widgets can change their report after the first pick, - kanban card update/delete actions fire, array inputs no longer write the - KeyboardEvent into the array, and `@openDelay={{0}}` means zero. -- **Documentation refreshed** — the README now points at the official documentation site and the - playground instead of 79 links to files that no longer existed, and reports the correct - AGPL-3.0-or-later licence. -- **Report builder for order reporting** — goes with fleetbase/core-api v1.6.64 and fleetbase/fleetops v0.6.70: - - Group By, Sort and Conditions offer computed columns, so a report can group by a month bucket or sort by a calculated value. - - A grouped report can be sorted by its aggregates, such as "Sum of Quantity", and Count Distinct is a new aggregate. - - Summary columns such as Total Orders get their own "Summaries" section in the column picker, with a note on what they return. - - The computed column editor lists the JSON, `DATE` and `DECIMAL` helpers, with examples for reading a value from `meta`, bucketing by month and referencing a related column. The column name defaults to the label. -- **Default dashboard order** — widgets on the default dashboard are laid out by a numeric `order` on the widget definition (lowest first), so extensions can place their widgets together without depending on registration order. -- **Fix: report builder condition values lost focus after every keystroke**, including both ends of a range. -- **Fix: a field removed through a "Selected Fields" chip stayed checked**, because `` did not follow `@checked`. A field loaded from a saved report was also added twice when toggled. -- **Fix: the computed column editor added an empty column** when the name was blank, and renaming a column while editing added a copy. Saved reports now keep their computed columns when reopened. -- **Fix: tall modals** can grow and the overlay scrolls with its margins intact, and the modals manager forwards its size, position and scrollable options to the modal. +- **Resource view registries.** Layout components merge what extensions register when given `@registry`, a surface prefix such as `ledger:table:invoice`. This pairs with fleetbase/ember-core v0.3.25. + - `Layout::Resource::Tabular`: columns, row actions, bulk actions and toolbar buttons. + - `Layout::Resource::TabularActions`: toolbar buttons and bulk actions. + - `Layout::Resource::Panel` and `resourceContextPanel.open({ registry })`: header buttons, plus "…" menu items. + - Table cells, filters and action-button components may be `ExtensionComponent`s, rendered from the extension's own engine. + - With an older ember-core, views render exactly as before. +- **Shared header components.** The three copies of the header-button and bulk-action markup are now `Layout::Resource::ActionButtons` and `Layout::Resource::BulkActions`. +- **Tabular arguments.** Tabular forwards `@searchDisabled` and takes a `<:header>` block for extra header controls. +- **Fix: Tabular ignored column changes after first render.** It copied `@columns` once. It now derives them, and the column picker's choices survive recomputes. +- **Fix: Tabular's bulk actions ignored `permission`.** They now honour it. +- **Fix: a dropdown item without a handler threw from `dropdown-fn`.** It now renders and does nothing. --- ## Need help? diff --git a/addon/components/date-picker.hbs b/addon/components/date-picker.hbs index b1536622..8ddf1a58 100644 --- a/addon/components/date-picker.hbs +++ b/addon/components/date-picker.hbs @@ -1,3 +1,12 @@
- +
\ No newline at end of file diff --git a/addon/components/date-picker.js b/addon/components/date-picker.js index d11a2bc6..355a24b5 100644 --- a/addon/components/date-picker.js +++ b/addon/components/date-picker.js @@ -4,6 +4,7 @@ import localeEn from 'air-datepicker/locale/en'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { isArray } from '@ember/array'; +import { parse, isValid } from 'date-fns'; export default class DatePickerComponent extends Component { @tracked nodeRef; @@ -15,11 +16,84 @@ export default class DatePickerComponent extends Component { dateFormat: 'yyyy-MM-dd', }; + get dateFormat() { + return this.args.dateFormat || this.defaultOptions.dateFormat; + } + @action setupComponent(node) { this.nodeRef = node; this.airDatePickerRef = new AirDatepicker(node, this.getOptions()); } + /** + * The field accepts typing as well as picking. A complete, valid date in the picker's format + * is selected and reported exactly like a click in the calendar; an empty field clears the + * selection; anything else is reverted so the field never shows text the picker does not hold. + */ + @action handleTypedValue(event) { + const picker = this.airDatePickerRef; + /* istanbul ignore if -- the listener is attached to the element the picker was created on */ + if (!picker) { + return; + } + + const text = event.target.value.trim(); + + // The picker writes its own selection into the field, so leaving the field after picking in + // the calendar fires `change` with text the picker already holds. That is not a new choice. + if (text === this.selectionText(picker)) { + return; + } + + // Reaching here with an empty field means there was a selection to clear. + if (text === '') { + picker.clear({ silent: true }); + this.report({ date: undefined, formattedDate: '', datepicker: picker }); + return; + } + + const date = this.parseText(text); + if (date) { + picker.setViewDate(date); + picker.selectDate(date); + return; + } + + event.target.value = this.selectionText(picker); + } + + /** + * Keeps the picker in step with a value that changes after the field was set up (a record that + * finishes loading, a reset). Silent: the change came from the consumer, so it is not reported. + */ + @action syncValue(element, [value]) { + const picker = this.airDatePickerRef; + /* istanbul ignore if -- `did-update` only runs after `did-insert` created the picker */ + if (!picker) { + return; + } + + // `` echoes typed text back through `@value` on every keystroke; while the field has + // focus the text is still being typed and `handleTypedValue` settles it on change. + if (document.activeElement === element) { + return; + } + + const dates = this.toDates(value); + const current = picker.selectedDates; + const same = dates.length === current.length && dates.every((date, index) => date.getTime() === current[index].getTime()); + if (same) { + return; + } + + picker.clear({ silent: true }); + if (dates.length) { + picker.setViewDate(dates[0]); + picker.selectDate(dates, { silent: true }); + } + element.value = dates.map((date) => this.format(date)).join(', '); + } + getOptions() { const options = this.defaultOptions; const { value, dateFormat, inline } = this.args; @@ -33,7 +107,7 @@ export default class DatePickerComponent extends Component { } if (value) { - options.selectedDates = this.parseValue(value); + options.selectedDates = this.toDates(value); } /* istanbul ignore next -- `nodeRef` is assigned by the insert hook before the picker is @@ -42,20 +116,6 @@ export default class DatePickerComponent extends Component { options.container = this.nodeRef.parentNode; } - const onSelect = function (selection) { - if (typeof this.args.onSelect === 'function') { - this.args.onSelect(...arguments); - } - - if (typeof this.args.onChange === 'function') { - this.args.onChange(selection.date, ...arguments); - } - - if (typeof this.args.onDateChanged === 'function') { - this.args.onDateChanged(selection.formattedDate, ...arguments); - } - }; - Object.keys(this.args).forEach((key) => { if (key === 'value' || key === 'onSelect') { return; @@ -66,20 +126,78 @@ export default class DatePickerComponent extends Component { } }); - options.onSelect = onSelect.bind(this); + options.onSelect = (selection) => this.report(selection); return options; } + /** Reports a selection through every handler the consumer gave, as the calendar does. */ + report(selection, ...rest) { + if (typeof this.args.onSelect === 'function') { + this.args.onSelect(selection, ...rest); + } + + if (typeof this.args.onChange === 'function') { + this.args.onChange(selection.date, selection, ...rest); + } + + if (typeof this.args.onDateChanged === 'function') { + this.args.onDateChanged(selection.formattedDate, selection, ...rest); + } + } + parseValue(value) { if (isArray(value)) { return value; } if (typeof value === 'string' && value.includes(',')) { - value = value.split(',').map((date) => new Date(date)); + return value.split(','); } return [value]; } + + /** The incoming value as Date objects: Dates, timestamps, strings in the picker's format or ISO. */ + toDates(value) { + return this.parseValue(value) + .map((item) => this.toDate(item)) + .filter(Boolean); + } + + toDate(value) { + if (value instanceof Date) { + return isValid(value) ? value : null; + } + + if (typeof value === 'number') { + return new Date(value); + } + + if (typeof value === 'string' && value.trim() !== '') { + return this.parseText(value.trim()) ?? this.nativeDate(value); + } + + return null; + } + + nativeDate(value) { + const date = new Date(value); + return isValid(date) ? date : null; + } + + /** Text in the picker's format -> Date, or null when it is not a complete valid date. */ + parseText(text) { + const date = parse(text, this.dateFormat, new Date()); + return isValid(date) ? date : null; + } + + /** The selection as the picker writes it into the field. */ + selectionText(picker) { + return picker.selectedDates.map((date) => this.format(date)).join(picker.opts.multipleDatesSeparator); + } + + format(date) { + return this.airDatePickerRef.formatDate(date, this.dateFormat); + } } diff --git a/addon/components/filters-picker.hbs b/addon/components/filters-picker.hbs index c2f1491e..caa43b5d 100644 --- a/addon/components/filters-picker.hbs +++ b/addon/components/filters-picker.hbs @@ -33,8 +33,9 @@ {{#unless filter.noFilterLabel}} {{/unless}} + {{! An ExtensionComponent filter comes from an extension's own engine. }} {{component - filter.filterComponent + (if (is-object filter.filterComponent) (lazy-engine-component filter.filterComponent) filter.filterComponent) value=filter.filterValue filter=filter param=(or filter.filterParam filter.valuePath) diff --git a/addon/components/layout/resource/action-buttons.hbs b/addon/components/layout/resource/action-buttons.hbs new file mode 100644 index 00000000..1b2e7dcb --- /dev/null +++ b/addon/components/layout/resource/action-buttons.hbs @@ -0,0 +1,95 @@ +{{#each @buttons as |actionButton|}} + {{#if actionButton.items}} + +
+ {{#each actionButton.items as |item|}} + {{#if item.separator}} +
+ {{else}} + + {{/if}} + {{/each}} +
+
+ {{else if actionButton.component}} + {{#let (if (is-object actionButton.component) (lazy-engine-component actionButton.component) actionButton.component) as |buttonComponent|}} + {{#if buttonComponent}} + {{component + buttonComponent + icon=actionButton.icon + iconPrefix=actionButton.iconPrefix + iconComponent=actionButton.iconComponent + size=actionButton.size + text=actionButton.text + options=actionButton.options + items=actionButton.items + permission=actionButton.permission + isLoading=actionButton.isLoading + renderInPlace=actionButton.renderInPlace + triggerClass=actionButton.triggerClass + disabled=actionButton.disabled + onClick=actionButton.onClick + fn=actionButton.fn + perform=actionButton.perform + onSelect=actionButton.onSelect + onChange=actionButton.onChange + }} + {{/if}} + {{/let}} + {{else}} + `, class extends Component {}), + }); + }); + + test('a cell component from an extension engine renders in the table', async function (assert) { + this.set('columns', [{ id: 'score', label: 'Score', valuePath: 'status', cellComponent: { engine: '@acme/engine', path: 'cell/score' } }]); + + await render(hbs``); + + assert.dom('.acme-cell').hasText('Ada: active'); + }); + + test('a filter component from an extension engine renders in the filters picker', async function (assert) { + this.set('columns', [ + { id: 'score', label: 'Score', valuePath: 'status', filterable: true, filterParam: 'score', filterComponent: { engine: '@acme/engine', path: 'filter/score' } }, + ]); + + await render(hbs``); + await click('.ember-basic-dropdown-trigger'); + + assert.dom('.acme-filter').hasAttribute('placeholder', 'Score'); + }); + + test('a button component from an extension engine renders among the action buttons', async function (assert) { + this.set('buttons', [ + { id: 'sync', text: 'Sync', component: { engine: '@acme/engine', path: 'button/sync' } }, + { id: 'plain', text: 'Plain', component: 'button' }, + ]); + + await render(hbs``); + + assert.dom('.acme-button').hasText('Sync'); + assert.ok( + findAll('button').some((button) => button.textContent.includes('Plain')), + 'a string component still renders directly' + ); + }); + }); + + module('Layout::Resource::ActionButtons', function () { + test('a dropdown item without a handler renders and does nothing', async function (assert) { + this.set('buttons', [{ id: 'more', icon: 'ellipsis-h', items: [{ label: 'Nothing yet' }] }]); + + await render(hbs``); + await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger'); + await click('.next-dd-item'); + + assert.ok(true, 'clicking it does not throw'); + }); + + test('disabled dropdown items are marked disabled', async function (assert) { + this.set('buttons', [{ id: 'more', icon: 'ellipsis-h', items: [{ label: 'Void', disabled: true }] }]); + + await render(hbs``); + await clickFound(assert, buttonWithIcon('ellipsis'), 'the dropdown trigger'); + + assert.dom('.next-dd-item').hasClass('disabled'); + }); + }); +}); diff --git a/tests/unit/utils/resource-view-test.js b/tests/unit/utils/resource-view-test.js new file mode 100644 index 00000000..0cd29fc5 --- /dev/null +++ b/tests/unit/utils/resource-view-test.js @@ -0,0 +1,68 @@ +import { module, test } from 'qunit'; +import lookupResourceView, { mergeHeaderButtons } from '@fleetbase/ember-ui/utils/resource-view'; +import { ResourceViewStub } from 'dummy/tests/helpers/resource-view-stubs'; + +module('Unit | Utility | resource-view', function () { + test('lookupResourceView returns the service, or null when there is none', function (assert) { + const service = {}; + assert.strictEqual(lookupResourceView({ lookup: () => service }), service); + assert.strictEqual(lookupResourceView({ lookup: () => undefined }), null); + assert.strictEqual(lookupResourceView(null), null); + assert.strictEqual( + lookupResourceView({ + lookup() { + throw new Error('module missing'); + }, + }), + null, + 'a lookup that throws is treated as no service' + ); + }); + + test('mergeHeaderButtons leaves the buttons alone without a service or registry', function (assert) { + const buttons = [{ id: 'edit' }]; + assert.strictEqual(mergeHeaderButtons(null, 'ledger:invoice:details', buttons), buttons); + assert.strictEqual(mergeHeaderButtons(new ResourceViewStub(), null, buttons), buttons); + assert.deepEqual(mergeHeaderButtons(null, null, undefined), []); + }); + + test('mergeHeaderButtons merges actions, and menu items only when asked', function (assert) { + const views = new ResourceViewStub(); + views.add('ledger:invoice:details:actions', { id: 'print' }); + views.add('ledger:invoice:details:menu', { id: 'acme' }); + + assert.deepEqual( + mergeHeaderButtons(views, 'ledger:invoice:details', [{ id: 'edit' }]).map((b) => b.id), + ['edit', 'print'], + 'table toolbars take no menu' + ); + + const dropdown = { id: 'more', items: [{ id: 'void' }] }; + const merged = mergeHeaderButtons(views, 'ledger:invoice:details', [{ id: 'edit' }, dropdown], {}, { withMenu: true }); + assert.deepEqual( + merged.map((b) => b.id), + ['edit', 'more', 'print'] + ); + assert.deepEqual( + merged[1].items.map((i) => i.id), + ['void', 'acme'] + ); + assert.deepEqual( + dropdown.items.map((i) => i.id), + ['void'], + 'the given dropdown is not modified' + ); + + const appended = mergeHeaderButtons(views, 'ledger:invoice:details', [], {}, { withMenu: true }); + assert.deepEqual( + appended.map((b) => b.id), + ['print', 'registered-menu'] + ); + assert.deepEqual( + appended[1].items.map((i) => i.id), + ['acme'] + ); + + assert.deepEqual(mergeHeaderButtons(new ResourceViewStub(), 'ledger:invoice:details', [], {}, { withMenu: true }), [], 'no dropdown is added for an empty menu'); + }); +});