Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
22 changes: 10 additions & 12 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
> v0.4.5 ~ "Tables and panels render extension-registered columns, actions and buttons"
> v0.4.6 ~ "Skeleton loading components and a report widget that runs live"

---
## Highlights

- **Resource view registries.** Layout components merge what extensions register when given `@registry`, a surface prefix such as `ledger:table:invoice`. This pairs with fleetbase/ember-core v0.3.25.
- `Layout::Resource::Tabular`: columns, row actions, bulk actions and toolbar buttons.
- `Layout::Resource::TabularActions`: toolbar buttons and bulk actions.
- `Layout::Resource::Panel` and `resourceContextPanel.open({ registry })`: header buttons, plus "…" menu items.
- Table cells, filters and action-button components may be `ExtensionComponent`s, rendered from the extension's own engine.
- With an older ember-core, views render exactly as before.
- **Shared header components.** The three copies of the header-button and bulk-action markup are now `Layout::Resource::ActionButtons` and `Layout::Resource::BulkActions`.
- **Tabular arguments.** Tabular forwards `@searchDisabled` and takes a `<:header>` block for extra header controls.
- **Fix: Tabular ignored column changes after first render.** It copied `@columns` once. It now derives them, and the column picker's choices survive recomputes.
- **Fix: Tabular's bulk actions ignored `permission`.** They now honour it.
- **Fix: a dropdown item without a handler threw from `dropdown-fn`.** It now renders and does nothing.
- **Skeleton loading components.** `Skeleton` draws text, title, circle, avatar, rect, button, badge and input shapes, and `Skeleton::Text`, `Skeleton::Table`, `Skeleton::Card`, `Skeleton::CardsGrid`, `Skeleton::Panel`, `Skeleton::Form`, `Skeleton::StatTiles`, `Skeleton::PageHeader` and `Skeleton::Tabs` build on it. `Skeleton::Page` is the composite for route `loading.hbs` templates: its `@view` is table, cards, form, dashboard or panel, or a block replaces the body. Boxes are `aria-hidden`, the page announces once through `role="status" aria-busy="true"` with `@label` (falls back to `common.loading`), and the shimmer honours `prefers-reduced-motion`. ([#189](https://github.com/fleetbase/ember-ui/pull/189))
- Skeletons follow the page they stand in for. The table card takes `@tabs`, `@toolbar`, `@checkbox`, `@footer` and `@identityColumn`; the header takes `@borderlessHeader`; cards take `@mediaFirst`, `@avatar`, `@subtitle`, `@status`, `@lines`, `@footerActions`, `@footerBadge` and `@footerLines`; the cards grid uses the `Layout::Resource::CardsGrid` breakpoints so it never shows fewer columns than the page. Every argument is optional and the defaults draw what they drew before.
- Loading substates in an engine only render when the console's application route lets the `loading` event bubble. fleetbase/fleetbase v0.7.69 does.
- **Dark resource card tier.** `Layout::Resource::Card` gets hook classes (`resource-card`, `-header`, `-body`, `-footer`) and an opaque surface with hairline inner borders and a raised footer, replacing the half-transparent fill. Buttons and inputs are unchanged from 0.4.5.
- **The report widget runs the report.** The dashboard `Widget::Report` showed the results stored on the report record, so the tile went stale. It now runs the report through `POST reports/{id}/execute` after loading it, shows "Running report…" then "Last run", and has a Refresh button. Results are capped at 100 rows with a note. A failed run shows an inline error and keeps the last good result. Polling is off by default; `options.refreshInterval` (minutes, minimum 5) turns it on and stops when the widget is destroyed. ([#188](https://github.com/fleetbase/ember-ui/pull/188))
- **Fix: notes on contacts save.** The comment thread sends `subject_uuid` for a subject keyed by uuid; the console's comment serializer drops `subject_id`, so those notes were inserted without a subject.
- **Fix: the date picker always shows the value in its date format.** A consumer that stored the picked `Date` saw its full `toString()` in the field.
- **Fix: `CoordinatesInput` treats a null value as empty** instead of throwing while the component is created.
- **Badges and buttons.** A `delivery` badge status uses the violet tone, and the dark-mode success button hover is green-600, so it reads as a hover.

---
## Need help?
Expand Down
17 changes: 16 additions & 1 deletion addon/components/comment-thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { task } from 'ember-concurrency';
import getWithDefault from '@fleetbase/ember-core/utils/get-with-default';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* Component to handle a thread of comments.
*/
Expand Down Expand Up @@ -84,8 +86,18 @@ export default class CommentThreadComponent extends Component {
return this.args.comments ?? this.comments;
}

/**
* The subject's uuid: its `uuid` field, or its `id` when that is a uuid (records whose
* serializer keys them by uuid have no separate `uuid` attribute).
*/
get subjectUuid() {
return this.subject?.uuid;
const subject = this.subject;

if (subject?.uuid) {
return subject.uuid;
}

return typeof subject?.id === 'string' && UUID_PATTERN.test(subject.id) ? subject.id : undefined;
}

get subjectPublicId() {
Expand All @@ -104,9 +116,12 @@ export default class CommentThreadComponent extends Component {
if (typeof this.args.onPublishComment === 'function') {
yield this.args.onPublishComment(this.input, this.subject);
} else {
// The console's comment serializer drops `subject_id`, so the uuid is what reaches
// the API; without it the comment is inserted with no subject and fails.
let comment = this.store.createRecord('comment', {
content: this.input,
subject_id: this.subjectPublicId,
subject_uuid: this.subjectUuid,
subject_type: this.subjectType,
});

Expand Down
3 changes: 2 additions & 1 deletion addon/components/coordinates-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export default class CoordinatesInputComponent extends Component {
* @memberof CoordinatesInputComponent
*/
isPoint(point) {
return typeof point === 'object' && !isBlank(point.type) && point.type === 'Point' && isArray(point.coordinates);
// typeof null is "object", so null has to be ruled out before reading .type
return point !== null && typeof point === 'object' && !isBlank(point.type) && point.type === 'Point' && isArray(point.coordinates);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions addon/components/date-picker.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<div class="date-picker-container">
<Input
@value={{@value}}
<input
type="text"
value={{this.displayValue}}
placeholder={{@placeholder}}
autocomplete="off"
class="fleetbase-date-picker form-input {{@class}}"
Expand Down
18 changes: 15 additions & 3 deletions addon/components/date-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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';
import { parse, format, isValid } from 'date-fns';

export default class DatePickerComponent extends Component {
@tracked nodeRef;
Expand All @@ -20,6 +20,18 @@ export default class DatePickerComponent extends Component {
return this.args.dateFormat || this.defaultOptions.dateFormat;
}

/**
* The field's text: the value's dates in the picker's format, whatever shape `@value` has
* (a Date, a timestamp, a list, an ISO or formatted string). Binding `@value` itself would
* show a Date's full `toString()`, e.g. right after a pick while the field still has focus.
*/
get displayValue() {
const separator = this.airDatePickerRef?.opts.multipleDatesSeparator ?? ', ';
return this.toDates(this.args.value)
.map((date) => format(date, this.dateFormat))
.join(separator);
}

@action setupComponent(node) {
this.nodeRef = node;
this.airDatePickerRef = new AirDatepicker(node, this.getOptions());
Expand Down Expand Up @@ -73,8 +85,8 @@ export default class DatePickerComponent extends Component {
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.
// While the field has focus the text may still be being typed; `handleTypedValue` settles it
// on change.
if (document.activeElement === element) {
return;
}
Expand Down
9 changes: 5 additions & 4 deletions addon/components/layout/resource/card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion addon/components/layout/resource/card/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion addon/components/layout/resource/card/footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion addon/components/layout/resource/card/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions addon/components/skeleton.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span class="ui-skeleton ui-skeleton-{{this.variant}} {{@class}}" aria-hidden="true" {{set-width @width}} {{set-height @height}} ...attributes></span>
19 changes: 19 additions & 0 deletions addon/components/skeleton.js
Original file line number Diff line number Diff line change
@@ -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';
}
}
69 changes: 69 additions & 0 deletions addon/components/skeleton/card.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<div class="ui-skeleton-card {{@class}}" aria-hidden="true" ...attributes>
{{#if this.showMediaFirst}}
<div class="ui-skeleton-card-media" {{set-height this.mediaHeight}}>
<Skeleton @variant="rect" @width="100%" @height="100%" />
</div>
{{/if}}
{{#unless @hideHeader}}
<div class="ui-skeleton-card-header">
{{#if @avatar}}
<span class="ui-skeleton-card-identity">
<Skeleton @variant="avatar" @width="1.75rem" @height="1.75rem" />
<span class="ui-skeleton-card-identity-text">
<Skeleton @variant="title" @width="70%" @height="0.875rem" />
<Skeleton @variant="text" @width="45%" @height="0.625rem" />
</span>
</span>
{{else if @subtitle}}
<span class="ui-skeleton-card-identity-text">
<Skeleton @variant="title" @width="60%" />
<Skeleton @variant="text" @width="40%" @height="0.625rem" />
</span>
{{else}}
<Skeleton @variant="title" @width="55%" />
{{/if}}
{{#if (eq @status "badge")}}
<Skeleton @variant="badge" />
{{else if (not-eq @status false)}}
<Skeleton @variant="circle" @width="0.5rem" @height="0.5rem" />
{{/if}}
</div>
{{/unless}}
{{#if this.showMediaAfterHeader}}
<div class="ui-skeleton-card-media" {{set-height this.mediaHeight}}>
<Skeleton @variant="rect" @width="100%" @height="100%" />
</div>
{{/if}}
{{#if this.lines.length}}
<div class="ui-skeleton-card-body">
{{#each this.lines as |line|}}
<Skeleton @variant="text" @width={{line.width}} data-skeleton-line={{line.index}} />
{{/each}}
</div>
{{/if}}
<div class="ui-skeleton-card-footer {{if this.footerLines.length 'ui-skeleton-card-footer-stacked'}}">
<div class="ui-skeleton-card-footer-row">
{{#if this.footerActions.length}}
{{#if @footerBadge}}
<Skeleton @variant="badge" />
{{else}}
<Skeleton @variant="text" @width="4rem" />
{{/if}}
<span class="ui-skeleton-card-meta">
{{#each this.footerActions as |button|}}
<Skeleton @variant="button" @width="1.75rem" @height="1.625rem" data-skeleton-card-action={{button}} />
{{/each}}
</span>
{{else}}
<Skeleton @variant="badge" />
<span class="ui-skeleton-card-meta">
<Skeleton @variant="text" @width="2.5rem" />
<Skeleton @variant="text" @width="2.5rem" />
</span>
{{/if}}
</div>
{{#each this.footerLines as |line|}}
<Skeleton @variant="text" @width={{line.width}} @height="0.625rem" data-skeleton-footer-line={{line.index}} />
{{/each}}
</div>
</div>
38 changes: 38 additions & 0 deletions addon/components/skeleton/card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import SkeletonBaseComponent from '../../utils/skeleton-base-component';

const LINE_WIDTHS = ['70%', '45%', '60%', '35%', '55%'];

/**
* One resource card while it loads, in the card anatomy: header, media, body lines and a
* footer. It follows the card it stands in for: `@mediaFirst` when the image leads the card,
* `@hideMedia` without one, `@avatar` for a header that is an identity (avatar, name and a
* subtitle), `@subtitle` for a title with a muted line under it, `@hideHeader` when the title
* sits in the body, `@status` as "badge", a dot (the default) or false, `@lines` body lines
* (2 by default), `@footerActions` icon buttons in the footer, led by a badge when
* `@footerBadge` is set, and `@footerLines` meta lines under that row.
*/
export default class SkeletonCardComponent extends SkeletonBaseComponent {
get mediaHeight() {
return this.args.mediaHeight ?? 160;
}

get showMediaFirst() {
return !this.args.hideMedia && Boolean(this.args.mediaFirst);
}

get showMediaAfterHeader() {
return !this.args.hideMedia && !this.args.mediaFirst;
}

get lines() {
return this.range(this.args.lines, 2).map((index) => ({ index, width: LINE_WIDTHS[index % LINE_WIDTHS.length] }));
}

get footerActions() {
return this.range(this.args.footerActions, 0);
}

get footerLines() {
return this.range(this.args.footerLines, 0).map((index) => ({ index, width: LINE_WIDTHS[(index + 2) % LINE_WIDTHS.length] }));
}
}
18 changes: 18 additions & 0 deletions addon/components/skeleton/cards-grid.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="ui-skeleton-cards-grid ui-skeleton-cards-grid-cols-{{this.columns}} {{@class}}" aria-hidden="true" ...attributes>
{{#each this.items as |item|}}
<Skeleton::Card
@mediaHeight={{@mediaHeight}}
@hideMedia={{@hideMedia}}
@mediaFirst={{@mediaFirst}}
@avatar={{@avatar}}
@subtitle={{@subtitle}}
@hideHeader={{@hideHeader}}
@status={{@status}}
@lines={{@lines}}
@footerActions={{@footerActions}}
@footerBadge={{@footerBadge}}
@footerLines={{@footerLines}}
data-skeleton-card={{item}}
/>
{{/each}}
</div>
13 changes: 13 additions & 0 deletions addon/components/skeleton/cards-grid.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions addon/components/skeleton/form.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="ui-skeleton-form ui-skeleton-form-cols-{{this.columns}} {{@class}}" aria-hidden="true" ...attributes>
{{#each this.fields as |field|}}
<div class="ui-skeleton-form-field" data-skeleton-field={{field}}>
<Skeleton @variant="text" @width="7rem" @height="0.625rem" />
<Skeleton @variant="input" />
</div>
{{/each}}
</div>
13 changes: 13 additions & 0 deletions addon/components/skeleton/form.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 21 additions & 0 deletions addon/components/skeleton/page-header.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<div class="ui-skeleton-page-header {{if @borderless 'ui-skeleton-page-header-borderless'}} {{@class}}" aria-hidden="true" ...attributes>
<div class="ui-skeleton-page-header-left">
{{#unless @hideAvatar}}
<Skeleton @variant="avatar" />
{{/unless}}
<div class="ui-skeleton-page-header-text">
<Skeleton @variant="title" @width="11rem" />
{{#unless @hideSubtitle}}
<Skeleton @variant="text" @width="17rem" @height="0.625rem" />
{{/unless}}
</div>
</div>
<div class="ui-skeleton-page-header-actions">
{{#unless @hideSearch}}
<Skeleton @variant="input" @width="16rem" @height="1.875rem" class="ui-skeleton-page-header-search" />
{{/unless}}
{{#each this.actions as |action|}}
<Skeleton @variant="button" data-skeleton-action={{action}} />
{{/each}}
</div>
</div>
Loading
Loading