Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
],
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 12 additions & 34 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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 `<SignaturePad>` 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 `<Checkbox>` 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?
Expand Down
11 changes: 10 additions & 1 deletion addon/components/date-picker.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
<div class="date-picker-container">
<Input @value={{@value}} placeholder={{@placeholder}} autocomplete="off" class="fleetbase-date-picker form-input {{@class}}" ...attributes {{did-insert this.setupComponent}} />
<Input
@value={{@value}}
placeholder={{@placeholder}}
autocomplete="off"
class="fleetbase-date-picker form-input {{@class}}"
...attributes
{{did-insert this.setupComponent}}
{{did-update this.syncValue @value}}
{{on "change" this.handleTypedValue}}
/>
</div>
152 changes: 135 additions & 17 deletions addon/components/date-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

// `<Input>` 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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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);
}
}
3 changes: 2 additions & 1 deletion addon/components/filters-picker.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
{{#unless filter.noFilterLabel}}
<label class="filter-component-label">{{or filter.filterLabel filter.label}}</label>
{{/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)
Expand Down
95 changes: 95 additions & 0 deletions addon/components/layout/resource/action-buttons.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{{#each @buttons as |actionButton|}}
{{#if actionButton.items}}
<DropdownButton
@text={{or actionButton.text actionButton.label}}
@textClass={{actionButton.textClass}}
@type={{actionButton.type}}
@icon={{or actionButton.icon @dropdownIcon}}
@size={{or actionButton.size @dropdownSize}}
@iconPrefix={{actionButton.prefix}}
@buttonClass={{actionButton.buttonClass}}
@buttonWrapperClass={{actionButton.wrapperClass}}
@triggerClass={{actionButton.triggerClass}}
@renderInPlace={{or actionButton.renderInPlace @renderInPlace}}
@isLoading={{actionButton.isLoading}}
@disabled={{actionButton.disabled}}
@helpText={{actionButton.helpText}}
@tooltipPlacement={{actionButton.tooltipPlacement}}
as |dd|
>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
{{#each actionButton.items as |item|}}
{{#if item.separator}}
<div class="next-dd-menu-seperator"></div>
{{else}}
<div class="px-1 {{item.wrapperClass}}">
<a
href="javascript:;"
class="next-dd-item {{item.class}} {{if item.disabled 'disabled'}}"
disabled={{item.disabled}}
{{on "click" (dropdown-fn dd (or item.fn item.onClick (noop)))}}
>
{{#if item.icon}}
<div class="w-7 flex-grow-0 flex-shrink-0">
<FaIcon @icon={{item.icon}} />
</div>
{{/if}}
<span>{{or item.text item.label}}</span>
</a>
</div>
{{/if}}
{{/each}}
</div>
</DropdownButton>
{{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}}
<Button
@type={{actionButton.type}}
@text={{actionButton.text}}
@onClick={{if actionButton.perform (perform actionButton.perform) (or actionButton.onClick actionButton.fn)}}
@size={{actionButton.size}}
@buttonType={{actionButton.buttonType}}
@disabled={{actionButton.disabled}}
@exampleText={{actionButton.exampleText}}
@helpText={{actionButton.helpText}}
@icon={{actionButton.icon}}
@iconClass={{actionButton.iconClass}}
@iconFlip={{actionButton.iconFlip}}
@iconPrefix={{actionButton.iconPrefix}}
@iconRotation={{actionButton.iconRotation}}
@iconSize={{actionButton.iconSize}}
@isLoading={{actionButton.isLoading}}
@outline={{actionButton.outline}}
@permission={{actionButton.permission}}
@wrapperClass={{actionButton.wrapperClass}}
@textClass={{actionButton.textClass}}
@tooltipPlacement={{actionButton.tooltipPlacement}}
@visible={{actionButton.visible}}
class={{actionButton.class}}
/>
{{/if}}
{{/each}}
Loading
Loading