diff --git a/addon/components/activity-log.hbs b/addon/components/activity-log.hbs index c34afafa..c3f5c604 100644 --- a/addon/components/activity-log.hbs +++ b/addon/components/activity-log.hbs @@ -1,137 +1,134 @@ -
-
-
{{t "common.activity"}}
-
-
- {{yield to="filters"}} - +
+ {{#if this.showHeader}} +
+
{{t "common.activity"}}
+ +
+ {{yield to="viewAll"}} + + {{#if this.showControls}} +
+ {{yield to="filters"}} + +
+
-
-
- - {{#each this.groups as |group|}} -
-
-
- {{group.dateLabel}} -
-
-
+ {{/if}} -
    - + {{#if this.hasItems}} +
      + {{#each this.items as |item|}} +
    1. + - {{#each group.items as |item|}} -
    2. - +
      +
      + + + {{item.verb}} -
      -
      - {{#if this.showAvatars}} - {{item.actor.name}} - {{/if}} + {{#if item.hasMultipleChanges}} + {{#if item.targetPhrase}} + + with + {{/if}} + + -
      -
      - - {{item.sentence}} - + +
      +
      +
      Attribute
      + {{#if this.showAttributePreviousValues}} +
      Previous value
      + {{/if}} +
      New value
      +
      - {{#if this.showBadges}} - - {{item.badge.text}} - + {{#each item.changes as |change|}} +
      +
      {{change.label}}
      + {{#if this.showAttributePreviousValues}} +
      {{change.from}}
      + {{/if}} +
      {{change.to}}
      +
      + {{/each}} +
      +
      + + {{else if item.inlineChange}} + + {{item.inlineChange.attribute}} + {{#if item.inlineChange.hasPreviousValue}} + from + {{item.inlineChange.from}} + to + {{else}} + to {{/if}} -
      - - {{#if item.inlineSummary}} -
      - {{item.inlineSummary}} -
      - {{/if}} - -
      - -
      + {{item.inlineChange.to}} + + {{else if item.objectLabel}} + + {{/if}} - {{#if item.advancedChanges.length}} -
      - + {{#if (and item.hasChanges (not item.hasMultipleChanges) item.objectLabel)}} + on + + {{/if}} + - {{#if (set-has-item this.expanded item.key)}} -
      - {{#each item.advancedChanges as |c|}} -
      - - {{c.key}} - - from - {{c.from}} - to - {{c.to}} -
      - {{/each}} -
      - {{/if}} -
      - {{/if}} -
      -
      + {{#if this.showBadges}} + {{item.badge.text}} + {{/if}}
      -
    3. - {{/each}} -
    -
- {{/each}} - {{#if (eq this.groups.length 0)}} -
-
-
- {{#if this.loadActivities.isRunning}} - - {{else}} - -
No activity yet
- {{/if}} + +
+ + {{/each}} + + {{else}} +
+ {{#if this.loadActivities.isRunning}} + + {{else}} +
+ +
No activity yet
-
+ {{/if}}
{{/if}} -
\ No newline at end of file + + {{yield}} +
diff --git a/addon/components/activity-log.js b/addon/components/activity-log.js index 2f752c0b..58eaa753 100644 --- a/addon/components/activity-log.js +++ b/addon/components/activity-log.js @@ -5,7 +5,6 @@ import { action } from '@ember/object'; import { debug } from '@ember/debug'; import { isArray } from '@ember/array'; import { capitalize } from '@ember/string'; -import { htmlSafe } from '@ember/template'; import { task } from 'ember-concurrency'; import { parseISO, isDate, isValid, format, formatDistanceToNow } from 'date-fns'; import smartHumanize from '../utils/smart-humanize'; @@ -13,33 +12,46 @@ import smartHumanize from '../utils/smart-humanize'; export default class ActivityLogComponent extends Component { @service store; @tracked activities = []; - @tracked expanded = new Set(); @tracked dateFilter = null; - @tracked query = null; - // Optional style “knobs” (you can pass in from the parent) get density() { return this.args.density ?? 'compact'; - } // 'cozy'|'compact' + } + get showAvatars() { return this.args.showAvatars ?? true; } + get showBadges() { - return this.args.showBadges ?? true; + return this.args.showBadges ?? false; } - get groups() { - const activities = isArray(this.activities) ? this.activities : []; + get showHeader() { + return this.args.showHeader ?? true; + } - const normalized = activities.map((a, i) => this.#normalizeActivity(a, i)).sort((a, b) => (b.timestamp?.dateMs ?? 0) - (a.timestamp?.dateMs ?? 0)); + get showControls() { + return this.args.showControls ?? true; + } - const byDay = new Map(); - for (const item of normalized) { - const key = item.dayKey ?? 'unknown'; - if (!byDay.has(key)) byDay.set(key, { dateLabel: item.dayLabel ?? key, items: [] }); - byDay.get(key).items.push(item); + get showAttributePreviousValues() { + return this.args.showAttributePreviousValues ?? true; + } + + get items() { + const activities = isArray(this.activities) ? this.activities : []; + const normalized = activities.map((activity, index) => this.#normalizeActivity(activity, index)).sort((a, b) => (b.timestamp?.dateMs ?? 0) - (a.timestamp?.dateMs ?? 0)); + const limit = Number(this.args.maxVisibleActivities); + + if (Number.isFinite(limit) && limit > 0) { + return normalized.slice(0, limit); } - return [...byDay.values()]; + + return normalized; + } + + get hasItems() { + return this.items.length > 0; } constructor() { @@ -51,6 +63,7 @@ export default class ActivityLogComponent extends Component { @task *loadActivities() { try { const params = {}; + if (this.args.companyUuid) params.company_uuid = this.args.companyUuid; if (this.args.subjectId) params.subject_id = this.args.subjectId; if (this.args.causerId) params.causer_id = this.args.causerId; if (this.dateFilter) params.created_at = this.dateFilter; @@ -79,12 +92,6 @@ export default class ActivityLogComponent extends Component { if (typeof this.args.onSubjectClick === 'function') this.args.onSubjectClick(subject); } - @action toggleAdvanced(itemKey) { - const next = new Set(this.expanded); - next.has(itemKey) ? next.delete(itemKey) : next.add(itemKey); - this.expanded = next; - } - // ── Normalize & Phrase ────────────────────────────────────────────────────── #normalizeActivity(activity, idx = 0) { const createdISO = activity?.created_at ?? null; @@ -94,37 +101,23 @@ export default class ActivityLogComponent extends Component { const d = this.#parseDate(tsISO); const dateMs = d ? d.getTime() : 0; const dayKey = d ? format(d, 'yyyy-MM-dd') : 'unknown'; - const dayLabel = d ? format(d, 'EEE, MMM dd, yyyy') : 'Unknown date'; const exactLocal = d ? format(d, 'PP p') : ''; const relative = d ? formatDistanceToNow(d, { addSuffix: true }) : ''; const causer = activity?.causer ?? {}; const subject = activity?.subject ?? {}; const subjectTypeLabel = activity?.humanized_subject_type ?? this.#subjectTypeLabel(activity?.subject_type); - const subjectDisplay = this.#subjectDisplay(subject); const event = String(activity?.event || '').toLowerCase(); + const changes = this.#computeChanges(activity?.properties); + const changeCount = changes.length; + const shouldShowSubjectContext = this.#shouldShowSubjectContext(); + const verb = this.#eventToVerb(event, activity?.description, changeCount, shouldShowSubjectContext); + const hasMultipleChanges = changeCount > 1; + const inlineChange = changeCount === 1 ? this.#inlineChangeSummary(changes[0]) : null; + const objectLabel = this.#objectLabel(activity, subjectTypeLabel); + const targetPhrase = shouldShowSubjectContext ? this.#targetPhrase(activity, subjectTypeLabel, event) : null; + const actorName = causer?.name ?? 'Someone'; - const eventLabel = capitalize(event || 'updated'); - const verb = this.#eventToVerb(event, activity?.description); - - // Diffs → split into simple vs advanced, and also build a human inline sentence - const allChanges = this.#computeChanges(activity?.properties); - const simpleChanges = []; - const advancedChanges = []; - - for (const c of allChanges) { - if (this.#isAdvancedValue(c.fromRaw, c.toRaw) || this.#isLikelyUuidKey(c.key)) { - advancedChanges.push(c); - } else { - simpleChanges.push(c); - } - } - - const inlineSummary = this.#summarizeSimple(simpleChanges); - - const sentence = `${causer?.name ?? 'Someone'} ${verb} ${subjectTypeLabel} (${subjectDisplay})`; - - // Event → badge style (Fleetbase-ish accent mapping) const badge = this.#eventBadge(event); return { @@ -132,18 +125,23 @@ export default class ActivityLogComponent extends Component { actor: { name: causer?.name ?? 'Unknown', avatarUrl: causer?.avatar_url ?? null, + initial: this.#initial(actorName), raw: causer, }, subject, causer, verb, event, - eventLabel, - badge, // {text, class} + eventLabel: capitalize(event || 'updated'), + badge, subjectTypeLabel, - subjectDisplay, - sentence, - inlineSummary, // "set color to red; status to live" + objectLabel, + targetPhrase, + changes, + changeCount, + hasChanges: changeCount > 0, + hasMultipleChanges, + inlineChange, timestamp: { iso: tsISO, exactLocal, @@ -151,48 +149,33 @@ export default class ActivityLogComponent extends Component { dateMs, }, dayKey, - dayLabel, - simpleChanges, - advancedChanges, raw: activity, }; } - #summarizeSimple(simpleChanges) { - if (!simpleChanges?.length) return ''; - - const parts = []; - for (const c of simpleChanges) { - const k = c.key.replace(/_/g, ' '); - - if (c.from !== 'null' && c.from !== undefined && c.from !== '' && c.from !== c.to) { - parts.push( - `changed ${k} from ${this.#code( - c.from - )} to ${this.#code(c.to)}` - ); - } else { - parts.push( - `set ${k} to ${this.#code(c.to)}` - ); - } + #inlineChangeSummary(change) { + if (!change) return null; + if (this.#isAdvancedValue(change.fromRaw, change.toRaw) || this.#isLikelyUuidKey(change.key)) return null; + if (change.from !== 'null' && change.from !== undefined && change.from !== '' && change.from !== change.to) { + return { + attribute: change.label, + from: change.from, + to: change.to, + hasPreviousValue: true, + }; } - // Make the entire thing safe once at the end - return htmlSafe(parts.join(', ')); - } - - #code(v) { - // lightweight backtick wrapper for inline emphasis - return this.args.backtickValues ? `\`${String(v)}\`` : v; + return { + attribute: change.label, + to: change.to, + hasPreviousValue: false, + }; } - #eventToVerb(event, description) { + #eventToVerb(event, description, changeCount = 0, showSubjectContext = false) { if (description && typeof description === 'string') return description; + if (showSubjectContext && event === 'updated') return 'updated'; + if (changeCount > 0 && (!event || event === 'updated')) return 'changed'; switch (event) { case 'created': return 'created'; @@ -234,6 +217,7 @@ export default class ActivityLogComponent extends Component { out.push({ key, + label: this.#attributeLabel(key), from: this.#formatValue(prev), to: this.#formatValue(next), fromRaw: prev, @@ -261,6 +245,10 @@ export default class ActivityLogComponent extends Component { return typeof key === 'string' && (key.endsWith('_uuid') || key === 'uuid' || key.endsWith('Id') || key.endsWith('_id')); } + #attributeLabel(key) { + return smartHumanize(String(key).replace(/_/g, ' ')); + } + #looksLikeUuid(v) { return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(v); } @@ -292,6 +280,55 @@ export default class ActivityLogComponent extends Component { return subject.display_name || subject.name || subject.title || subject.address || subject.tracking || subject.public_id || subject.uuid || 'Unknown'; } + #objectLabel(activity, subjectTypeLabel) { + const subjectDisplay = this.#subjectDisplay(activity?.subject); + return subjectDisplay !== 'Unknown' ? subjectDisplay : subjectTypeLabel; + } + + #shouldShowSubjectContext() { + if (typeof this.args.showSubjectContext === 'boolean') { + return this.args.showSubjectContext; + } + + if (this.args.subjectId) { + return false; + } + + return Boolean(this.args.companyUuid || this.args.causerId); + } + + #targetPhrase(activity, subjectTypeLabel, event) { + const typeLabel = this.#sentenceCaseLabel(subjectTypeLabel); + if (!typeLabel) return null; + + const subjectDisplay = this.#subjectDisplay(activity?.subject); + const hasDisplay = subjectDisplay !== 'Unknown' && subjectDisplay.toLowerCase() !== typeLabel; + const displaySuffix = hasDisplay ? ` (${subjectDisplay})` : ''; + const article = this.#indefiniteArticle(typeLabel); + + if (event === 'created') { + return `${article} new ${typeLabel}${displaySuffix}`; + } + + return `${article} ${typeLabel}${displaySuffix}`; + } + + #sentenceCaseLabel(label) { + if (!label || typeof label !== 'string') return ''; + return label.trim().toLowerCase(); + } + + #indefiniteArticle(label) { + return /^[aeiou]/i.test(label) ? 'an' : 'a'; + } + + #initial(name) { + return String(name || 'S') + .trim() + .charAt(0) + .toUpperCase(); + } + #isPlainObject(v) { return v && typeof v === 'object' && !isArray(v); } diff --git a/addon/components/custom-field/input.hbs b/addon/components/custom-field/input.hbs index bf4530d4..f9b2b1dd 100644 --- a/addon/components/custom-field/input.hbs +++ b/addon/components/custom-field/input.hbs @@ -54,7 +54,7 @@ @radioId={{concat this.customField.name "-radio-option-" index}} @value={{radioOption}} @groupValue={{this.value}} - @name={{radioOption}} + @name={{this.customField.name}} @changed={{this.onChangeHandler}} />
{{radioOption}}
diff --git a/addon/components/date-time-input.js b/addon/components/date-time-input.js index c6a35cb2..3d99f7fd 100644 --- a/addon/components/date-time-input.js +++ b/addon/components/date-time-input.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { parse, format } from 'date-fns'; +import { parse, format, isValid } from 'date-fns'; export default class DateTimeInputComponent extends Component { @tracked timeFormat = 'HH:mm'; @@ -13,8 +13,26 @@ export default class DateTimeInputComponent extends Component { constructor() { super(...arguments); - this.date = this.args.value instanceof Date ? format(this.args.value, this.dateFormat) : null; - this.time = this.args.value instanceof Date ? format(this.args.value, this.timeFormat) : null; + const value = this.parseValue(this.args.value); + + this.date = value ? format(value, this.dateFormat) : null; + this.time = value ? format(value, this.timeFormat) : null; + } + + parseValue(value) { + if (value instanceof Date && isValid(value)) { + return value; + } + + if (typeof value === 'string') { + const parsedValue = parse(value, this.dateTimeFormat, new Date()); + + if (isValid(parsedValue)) { + return parsedValue; + } + } + + return null; } /** diff --git a/addon/components/docs-panel.hbs b/addon/components/docs-panel.hbs index c5e92ada..4f7f2614 100644 --- a/addon/components/docs-panel.hbs +++ b/addon/components/docs-panel.hbs @@ -16,14 +16,21 @@
- + {{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}} + {{#if this.docsPanel.isIframeLoading}} +
+ + Loading documentation... +
+ {{/if}} {{else}} @@ -42,4 +49,4 @@ {{/if}}
-{{/if}} \ No newline at end of file +{{/if}} diff --git a/addon/components/docs-panel.js b/addon/components/docs-panel.js index 0108dae0..1e024cc3 100644 --- a/addon/components/docs-panel.js +++ b/addon/components/docs-panel.js @@ -15,6 +15,11 @@ export default class DocsPanelComponent extends Component { this.docsPanel.markIframeFailed(); } + @action + markIframeLoaded() { + this.docsPanel.markIframeLoaded(); + } + @action openExternal() { this.docsPanel.openExternal(); diff --git a/addon/components/dropdown-button.hbs b/addon/components/dropdown-button.hbs index 79b1f8bb..89af1c92 100644 --- a/addon/components/dropdown-button.hbs +++ b/addon/components/dropdown-button.hbs @@ -4,6 +4,8 @@ class={{@wrapperClass}} @renderInPlace={{@renderInPlace}} @registerAPI={{this.onRegisterAPI}} + @destination={{@destination}} + @destinationElement={{@destinationElement}} @horizontalPosition={{@horizontalPosition}} @verticalPosition={{@verticalPosition}} @calculatePosition={{@calculatePosition}} @@ -98,4 +100,4 @@ {{/if}} -{{/if}} \ No newline at end of file +{{/if}} diff --git a/addon/components/filters-picker.js b/addon/components/filters-picker.js index 1929ff66..cdc4055b 100644 --- a/addon/components/filters-picker.js +++ b/addon/components/filters-picker.js @@ -2,14 +2,21 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; import { isArray } from '@ember/array'; import getUrlParam from '../utils/get-url-param'; export default class FiltersPickerComponent extends Component { @service hostRouter; + @service router; @service events; @tracked filters = []; + get activeRouter() { + /* eslint-disable-next-line ember/no-private-routing-service */ + return this.hostRouter ?? this.router ?? getOwner(this).lookup('router:main'); + } + get activeFilters() { return this.filters.filter((f) => f.isFilterActive); } @@ -25,12 +32,12 @@ export default class FiltersPickerComponent extends Component { // Refresh whenever the route (→ query-params) changes this._routeHandler = () => this.#rebuildFilters(); - this.hostRouter.on('routeDidChange', this._routeHandler); + this.activeRouter?.on?.('routeDidChange', this._routeHandler); } willDestroy() { super.willDestroy(...arguments); - this.hostRouter.off('routeDidChange', this._routeHandler); + this.activeRouter?.off?.('routeDidChange', this._routeHandler); } #readUrlValue(param) { @@ -103,7 +110,8 @@ export default class FiltersPickerComponent extends Component { } // Build a qp bag that explicitly clears the filter params - const qp = { ...this.hostRouter.currentRoute.queryParams }; + const router = this.activeRouter; + const qp = { ...router.currentRoute.queryParams }; (this.args.columns ?? []) .filter((c) => c.filterable) @@ -122,7 +130,7 @@ export default class FiltersPickerComponent extends Component { }); try { - await this.hostRouter.transitionTo(this.hostRouter.currentRouteName, { + await router.transitionTo(router.currentRouteName, { queryParams: qp, }); } catch (error) { diff --git a/addon/components/fleetbase-attribution.hbs b/addon/components/fleetbase-attribution.hbs new file mode 100644 index 00000000..eee5af82 --- /dev/null +++ b/addon/components/fleetbase-attribution.hbs @@ -0,0 +1,11 @@ +{{#unless this.disabled}} +
+ + Powered by Fleetbase + + + +
+{{/unless}} diff --git a/addon/components/fleetbase-attribution.js b/addon/components/fleetbase-attribution.js new file mode 100644 index 00000000..a15f3fc1 --- /dev/null +++ b/addon/components/fleetbase-attribution.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import config from 'ember-get-config'; + +export default class FleetbaseAttributionComponent extends Component { + @service modalsManager; + + licensingUrl = 'https://www.fleetbase.io'; + + get disabled() { + return config.APP?.disableFleetbaseAttribution === true; + } + + @action openLegalNotice() { + this.modalsManager.show('modals/fleetbase-legal-notice', { + title: 'Fleetbase Legal Notices', + acceptButtonText: 'Done', + acceptButtonIcon: 'check', + hideDeclineButton: true, + modalClass: 'modal-md fleetbase-legal-notice-modal', + }); + } +} diff --git a/addon/components/floating.js b/addon/components/floating.js index 99b9dfa2..3f0b684d 100644 --- a/addon/components/floating.js +++ b/addon/components/floating.js @@ -82,9 +82,9 @@ export default class FloatingComponent extends Component { const { defaultOptions } = this; const mware = this.args.middleware; const offsetBy = this.args.offset; - const displayArrow = this.args.offset; + const displayArrow = this.args.arrow; - const middleware = isArray(mware) ? mware : defaultOptions.middleware; + const middleware = isArray(mware) ? [...mware] : [...defaultOptions.middleware]; if (typeof offsetBy === 'number') { middleware.push(offset(offsetBy)); diff --git a/addon/components/layout/resource/tabular.hbs b/addon/components/layout/resource/tabular.hbs index 28b06e0b..8b5b6dca 100644 --- a/addon/components/layout/resource/tabular.hbs +++ b/addon/components/layout/resource/tabular.hbs @@ -165,7 +165,16 @@ @tfootVerticalOffset={{@tfootVerticalOffset}} @tfootVerticalOffsetElements={{@tfootVerticalOffsetElements}} @onSort={{this.handleSort}} + @onRowClick={{@onRowClick}} @checkboxSticky={{this.checkboxSticky}} + @emptyStateComponent={{@emptyStateComponent}} + @emptyStateTitle={{@emptyStateTitle}} + @emptyStateDescription={{@emptyStateDescription}} + @emptyStateIcon={{@emptyStateIcon}} + @emptyStateIconPrefix={{@emptyStateIconPrefix}} + @emptyStateAction={{@emptyStateAction}} + @searchQuery={{@searchQuery}} + @isFiltered={{@isFiltered}} /> {{/if}} - \ No newline at end of file + diff --git a/addon/components/layout/sidebar.hbs b/addon/components/layout/sidebar.hbs index 4178da1c..8f14b034 100644 --- a/addon/components/layout/sidebar.hbs +++ b/addon/components/layout/sidebar.hbs @@ -2,9 +2,22 @@
+ {{#if (has-block "footer")}} + + {{else if this.showDefaultAttribution}} + + {{/if}}
- \ No newline at end of file + diff --git a/addon/components/layout/sidebar.js b/addon/components/layout/sidebar.js index 62b91d54..151c21b8 100644 --- a/addon/components/layout/sidebar.js +++ b/addon/components/layout/sidebar.js @@ -4,6 +4,7 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { cancel, later } from '@ember/runloop'; import { capitalize } from '@ember/string'; +import config from 'ember-get-config'; class SidebarContext { constructor(component) { @@ -25,9 +26,17 @@ export default class LayoutSidebarComponent extends Component { @tracked isResizing = false; @tracked hidden = false; @tracked minimized = false; + @tracked lastVisibleWidth = 0; hideTimer = null; + resizeFrame = null; + pendingResizeWidth = null; + activeResizeContainer = null; context = null; + get showDefaultAttribution() { + return config.APP?.disableFleetbaseAttribution !== true; + } + @action setupNode(property, node) { this[`${property}Node`] = node; @@ -76,24 +85,9 @@ export default class LayoutSidebarComponent extends Component { const dx = event.clientX - this.mouseX; const multiplier = 1; const width = dx * multiplier + this.sidebarWidth; - const minResizeWidth = this.args.minResizeWidth ?? 200; - const maxResizeWidth = this.args.maxResizeWidth ?? 330; - - // Min resize width - if (width <= minResizeWidth) { - sidebarNode.style.width = `${minResizeWidth}px`; - return; - } - - // Max resize width - if (width >= maxResizeWidth) { - sidebarNode.style.width = `${maxResizeWidth}px`; - return; - } + this.pendingResizeWidth = width; + this.scheduleResizeFrame(); - // Style changes - sidebarNode.style.width = `${width}px`; - sidebarNode.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; if (typeof onResize === 'function') { @@ -113,9 +107,12 @@ export default class LayoutSidebarComponent extends Component { // Set the sidebar width this.sidebarWidth = bounds.width; + this.lastVisibleWidth = bounds.width; // Start resizing this.isResizing = true; + sidebarNode.classList.add('sidebar-is-resizing'); + this.setResizeContainerActive(sidebarNode, true); // Get the current mouse position this.mouseX = event.clientX; @@ -134,6 +131,9 @@ export default class LayoutSidebarComponent extends Component { @action stopResize(event) { const { onResizeEnd } = this.args; const { sidebarNode } = this; + const collapseBelowWidth = this.args.collapseBelowWidth ?? 160; + const minResizeWidth = this.args.minResizeWidth ?? 200; + const rawWidth = event.clientX - this.mouseX + this.sidebarWidth; // End resizing this.isResizing = false; @@ -141,7 +141,26 @@ export default class LayoutSidebarComponent extends Component { // Remove style changes document.body.style.removeProperty('cursor'); sidebarNode.style.userSelect = 'auto'; - this.syncTransitionWidth(sidebarNode); + this.pendingResizeWidth = rawWidth; + this.flushResizeFrame(); + sidebarNode.classList.remove('sidebar-is-resizing'); + this.setResizeContainerActive(sidebarNode, false); + const currentWidth = sidebarNode?.getBoundingClientRect?.().width ?? 0; + + if (rawWidth <= collapseBelowWidth) { + this.hide(sidebarNode, false, { restoreWidthAfterHide: true }); + } else { + this.clearResizeCollapseState(sidebarNode); + + const visibleWidth = Math.max(currentWidth, minResizeWidth); + sidebarNode.style.width = `${visibleWidth}px`; + + if (visibleWidth >= minResizeWidth) { + this.lastVisibleWidth = visibleWidth; + } + + this.syncTransitionWidth(sidebarNode); + } // Remove the handlers of `mousemove` and `mouseup` document.removeEventListener('mousemove', this.resize); @@ -152,12 +171,95 @@ export default class LayoutSidebarComponent extends Component { } } + scheduleResizeFrame() { + if (this.resizeFrame) { + return; + } + + this.resizeFrame = requestAnimationFrame(() => { + this.resizeFrame = null; + this.applyResizeWidth(this.pendingResizeWidth); + }); + } + + flushResizeFrame() { + if (this.resizeFrame) { + cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = null; + } + + this.applyResizeWidth(this.pendingResizeWidth); + } + + setResizeContainerActive(sidebarNode = this.sidebarNode, isActive = false) { + const container = sidebarNode?.closest?.('.next-view-container'); + + if (isActive && container) { + this.activeResizeContainer = container; + container.classList.add('sidebar-is-resizing'); + document.body.classList.add('next-sidebar-is-resizing'); + this.resetHorizontalScroll(); + return; + } + + this.activeResizeContainer?.classList.remove('sidebar-is-resizing'); + this.activeResizeContainer = null; + document.body.classList.remove('next-sidebar-is-resizing'); + this.resetHorizontalScroll(); + } + + applyResizeWidth(width) { + const { sidebarNode } = this; + + if (!sidebarNode || !Number.isFinite(width)) { + return; + } + + const minResizeWidth = this.args.minResizeWidth ?? 200; + const maxResizeWidth = this.args.maxResizeWidth ?? 330; + const activeResizeFloor = this.isResizing ? 1 : 0; + const fadeEndWidth = 50; + const isCollapsing = width < minResizeWidth; + const resizeWidth = isCollapsing ? Math.max(width, activeResizeFloor) : Math.max(minResizeWidth, Math.min(width, maxResizeWidth)); + const collapseOffset = isCollapsing ? minResizeWidth - resizeWidth : 0; + const fadeDistance = Math.max(1, minResizeWidth - fadeEndWidth); + const collapseProgress = Math.min(collapseOffset / fadeDistance, 1); + + sidebarNode.style.width = `${resizeWidth}px`; + sidebarNode.style.userSelect = 'none'; + sidebarNode.classList.toggle('sidebar-resizing-to-collapse', collapseOffset > 0); + sidebarNode.style.setProperty('--sidebar-drawer-width', `${minResizeWidth}px`); + sidebarNode.style.setProperty('--sidebar-collapse-offset', `-${collapseOffset}px`); + sidebarNode.style.setProperty('--sidebar-collapse-progress', collapseProgress); + this.resetHorizontalScroll(); + } + + resetHorizontalScroll() { + document.documentElement.scrollLeft = 0; + document.body.scrollLeft = 0; + + if (this.activeResizeContainer) { + this.activeResizeContainer.scrollLeft = 0; + } + } + syncState(state) { this.sidebar.setVisualState(state); this.hidden = state === 'hidden'; this.minimized = state === 'minimized'; } + clearResizeCollapseState(sidebarNode = this.sidebarNode) { + if (!sidebarNode) return; + + sidebarNode.classList.remove('sidebar-resizing-to-collapse'); + sidebarNode.style.removeProperty('--sidebar-drawer-width'); + sidebarNode.style.removeProperty('--sidebar-collapse-offset'); + sidebarNode.style.removeProperty('--sidebar-collapse-progress'); + this.pendingResizeWidth = null; + this.setResizeContainerActive(sidebarNode, false); + } + cancelHideTimer() { if (this.hideTimer) { cancel(this.hideTimer); @@ -174,13 +276,41 @@ export default class LayoutSidebarComponent extends Component { sidebarNode.style.setProperty('--sidebar-transition-width', `${width}px`); } + restoreVisibleWidth(sidebarNode = this.sidebarNode) { + if (!sidebarNode) return; + + const restoreWidth = this.args.restoreWidth ?? 220; + const width = this.lastVisibleWidth || restoreWidth; + + sidebarNode.style.width = `${width}px`; + this.syncTransitionWidth(sidebarNode); + } + + restoreVisibleWidthWithoutTransition(sidebarNode = this.sidebarNode) { + if (!sidebarNode) return; + + const transition = sidebarNode.style.transition; + + sidebarNode.style.transition = 'none'; + this.restoreVisibleWidth(sidebarNode); + sidebarNode.getBoundingClientRect(); + + if (transition) { + sidebarNode.style.transition = transition; + } else { + sidebarNode.style.removeProperty('transition'); + } + } + @action hideNow(sidebarNode) { sidebarNode = sidebarNode ?? this.sidebarNode; return this.hide(sidebarNode, true); } - @action hide(sidebarNode, now = false) { + @action hide(sidebarNode, now = false, options = {}) { sidebarNode = sidebarNode ?? this.sidebarNode; + const restoreWidthAfterHide = options.restoreWidthAfterHide === true; + this.cancelHideTimer(); this.syncTransitionWidth(sidebarNode); this.syncState('hidden'); @@ -188,6 +318,7 @@ export default class LayoutSidebarComponent extends Component { if (now === true) { sidebarNode.classList.add('sidebar-hidden'); sidebarNode.classList.remove('sidebar-hide', 'sidebar-minimized'); + this.clearResizeCollapseState(sidebarNode); return; } @@ -200,6 +331,12 @@ export default class LayoutSidebarComponent extends Component { () => { sidebarNode.classList.add('sidebar-hidden'); sidebarNode.classList.remove('sidebar-hide'); + + if (restoreWidthAfterHide) { + this.restoreVisibleWidthWithoutTransition(sidebarNode); + } + + this.clearResizeCollapseState(sidebarNode); this.hideTimer = null; }, 500 @@ -209,7 +346,9 @@ export default class LayoutSidebarComponent extends Component { @action show(sidebarNode) { sidebarNode = sidebarNode ?? this.sidebarNode; this.cancelHideTimer(); + this.restoreVisibleWidth(sidebarNode); this.syncTransitionWidth(sidebarNode); + this.clearResizeCollapseState(sidebarNode); sidebarNode.classList.remove('sidebar-hidden', 'sidebar-hide', 'sidebar-minimized'); this.syncState('visible'); } @@ -225,6 +364,11 @@ export default class LayoutSidebarComponent extends Component { @action teardown() { this.cancelHideTimer(); + if (this.resizeFrame) { + cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = null; + } + this.setResizeContainerActive(this.sidebarNode, false); document.removeEventListener('mousemove', this.resize); document.removeEventListener('mouseup', this.stopResize); this.sidebar.clearContext(this.context); diff --git a/addon/components/layout/sidebar/navigator.hbs b/addon/components/layout/sidebar/navigator.hbs new file mode 100644 index 00000000..ded96ad4 --- /dev/null +++ b/addon/components/layout/sidebar/navigator.hbs @@ -0,0 +1,211 @@ +
+ {{#if @primaryAction}} + + {{/if}} + +
+ + + {{#if (and this.hasSearchPopover this.popoverTarget)}} + {{#in-element this.popoverTarget insertBefore=null}} + + + + {{/in-element}} + {{/if}} +
+ +
+ {{#if this.outgoingView as |outgoing|}} +
+ {{#if outgoing.parent}} +
+ + + + {{or outgoing.parent.label outgoing.parent.title}} +
+ {{/if}} + + {{#each outgoing.items as |item|}} + + {{/each}} +
+ {{/if}} + +
+ {{#if this.isNested}} + + {{/if}} + + {{#each this.currentItems as |item|}} + + {{/each}} +
+
+
diff --git a/addon/components/layout/sidebar/navigator.js b/addon/components/layout/sidebar/navigator.js new file mode 100644 index 00000000..c9270ca0 --- /dev/null +++ b/addon/components/layout/sidebar/navigator.js @@ -0,0 +1,554 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; + +export default class LayoutSidebarNavigatorComponent extends Component { + @service('sidebar-navigator') sidebarNavigator; + @tracked query = ''; + @tracked viewStack = []; + @tracked outgoingView = null; + @tracked transitionDirection = 'forward'; + @tracked isSearchOpen = false; + @tracked searchState = 'idle'; + @tracked isSearching = false; + @tracked providerResults = []; + @tracked popoverStyle = ''; + @tracked popoverTarget = null; + @tracked activeSearchIndex = 0; + + searchInputNode; + portalSearchInputNode; + searchWrapNode; + searchResultsNode; + viewportNode; + transitionTimer; + closeSearchTimer; + openSearchTimer; + openSearchFrame; + searchToken = 0; + + constructor() { + super(...arguments); + this.syncViewStackToRoute(); + this.router?.on?.('routeDidChange', this.syncViewStackToRoute); + + if (typeof document !== 'undefined' && this.searchShortcutEnabled) { + document.addEventListener('keydown', this.handleDocumentKeydown); + } + + if (typeof window !== 'undefined') { + window.addEventListener('resize', this.updatePopoverPosition); + } + } + + willDestroy() { + super.willDestroy(...arguments); + this.router?.off?.('routeDidChange', this.syncViewStackToRoute); + if (typeof document !== 'undefined') { + document.removeEventListener('keydown', this.handleDocumentKeydown); + } + + if (typeof window !== 'undefined') { + window.clearTimeout(this.transitionTimer); + window.clearTimeout(this.closeSearchTimer); + window.clearTimeout(this.openSearchTimer); + window.cancelAnimationFrame(this.openSearchFrame); + window.removeEventListener('resize', this.updatePopoverPosition); + } + + this.destroyPopoverTarget(); + } + + get router() { + return this.lookupService('router') ?? this.lookupService('host-router'); + } + + lookupService(name) { + try { + return getOwner(this).lookup(`service:${name}`); + } catch (_) { + return null; + } + } + + get items() { + return this.sidebarNavigator.normalizeItems(this.args.items ?? []); + } + + get currentItems() { + return this.currentParent?.children ?? this.items; + } + + get currentParent() { + return this.currentStack[this.currentStack.length - 1]; + } + + get currentStack() { + let items = this.items; + const stack = []; + + for (const stackedItem of this.viewStack) { + const item = this.findMatchingItem(items, stackedItem); + + if (!item) { + break; + } + + stack.push(item); + items = item.children ?? []; + } + + return stack; + } + + get title() { + return this.currentParent?.label ?? this.currentParent?.title; + } + + get isNested() { + return this.currentStack.length > 0; + } + + get hasQuery() { + return this.query.trim().length > 0; + } + + get searchShortcutEnabled() { + return this.args.enableSearchShortcut !== false; + } + + get shortcutLabel() { + return typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('mac') ? 'Cmd K' : 'Ctrl K'; + } + + get hasSearchPopover() { + return this.isSearchOpen; + } + + get searchResults() { + return [...this.staticSearchResults, ...this.providerResults]; + } + + get limitedSearchResults() { + return this.searchResults.slice(0, this.maxSearchResults); + } + + get hasSearchResults() { + return this.limitedSearchResults.length > 0; + } + + get maxSearchResults() { + return Number(this.args.maxSearchResults) || 12; + } + + get searchVisualHidden() { + return this.hasSearchPopover || this.searchState === 'closing'; + } + + get searchPopoverClass() { + return `next-sidebar-navigator-search-popover is-${this.searchState}`; + } + + get searchOverlayClass() { + return `next-sidebar-navigator-search-overlay is-${this.searchState}`; + } + + get staticSearchResults() { + return this.sidebarNavigator.searchItems(this.items, this.query).map((result) => { + return { + ...result.item, + item: result.item, + path: result.path, + label: result.item.label ?? result.item.title, + description: result.item.description, + breadcrumb: this.breadcrumb(result.path), + type: 'Navigation', + }; + }); + } + + get emptySearch() { + return this.hasQuery && !this.isSearching && this.searchResults.length === 0; + } + + @action syncViewStackToRoute() { + const activePath = this.sidebarNavigator.activePath(this.items); + + if (activePath.length > 1) { + this.viewStack = activePath.slice(0, -1); + } + } + + @action registerSearchInput(inputNode) { + this.searchInputNode = inputNode; + } + + @action registerPortalSearchInput(inputNode) { + this.portalSearchInputNode = inputNode; + inputNode?.focus(); + } + + @action registerSearchWrap(searchWrapNode) { + this.searchWrapNode = searchWrapNode; + this.updatePopoverPosition(); + } + + @action registerViewport(viewportNode) { + this.viewportNode = viewportNode; + } + + @action registerSearchResults(searchResultsNode) { + this.searchResultsNode = searchResultsNode; + } + + @action updateQuery(event) { + this.query = event.target.value; + this.openSearch(); + this.activeSearchIndex = 0; + this.searchProvider(); + } + + @action clearQuery() { + this.query = ''; + this.providerResults = []; + this.isSearching = false; + this.activeSearchIndex = 0; + this.searchToken++; + this.portalSearchInputNode?.focus(); + } + + @action openSearch() { + if (this.hasSearchPopover && this.searchState !== 'closing') { + this.updatePopoverPosition(); + return; + } + + window.clearTimeout(this.closeSearchTimer); + window.clearTimeout(this.openSearchTimer); + window.cancelAnimationFrame(this.openSearchFrame); + this.ensurePopoverTarget(); + this.searchState = this.reducedMotion ? 'open' : 'primed'; + this.isSearchOpen = true; + this.updatePopoverPosition(); + this.activeSearchIndex = 0; + + this.openSearchFrame = window.requestAnimationFrame(() => { + if (this.searchState === 'primed') { + this.searchState = 'opening'; + } + + this.portalSearchInputNode?.focus(); + }); + + if (!this.reducedMotion) { + this.openSearchTimer = window.setTimeout(() => { + if (this.searchState === 'opening') { + this.searchState = 'open'; + } + }, 180); + } + } + + @action closeSearch() { + if (!this.hasSearchPopover) { + return; + } + + window.clearTimeout(this.closeSearchTimer); + window.clearTimeout(this.openSearchTimer); + window.cancelAnimationFrame(this.openSearchFrame); + this.updatePopoverPosition(); + this.searchState = this.reducedMotion ? 'idle' : 'closing'; + this.portalSearchInputNode?.blur(); + + const close = () => { + this.isSearchOpen = false; + this.searchState = 'idle'; + this.destroyPopoverTarget(); + }; + + if (this.reducedMotion) { + close(); + return; + } + + this.closeSearchTimer = window.setTimeout(close, 160); + } + + @action openItem(item) { + if (item.children?.length) { + this.query = ''; + this.transitionToStack([...this.currentStack, item], 'forward'); + return; + } + + this.transitionItem(item); + } + + @action openSearchResult(result) { + const item = result.item ?? result; + + this.query = ''; + this.providerResults = []; + this.activeSearchIndex = 0; + this.closeSearch(); + + if (item.children?.length) { + this.transitionToStack(result.path ?? [...this.currentStack, item], 'forward'); + return; + } + + if (result.path) { + this.transitionToStack(result.path.slice(0, -1), 'forward'); + } + + this.transitionItem(item); + } + + @action back() { + this.query = ''; + this.transitionToStack(this.currentStack.slice(0, -1), 'back'); + } + + @action transitionItem(item) { + if (typeof item.onClick === 'function') { + item.onClick(item); + return; + } + + if (item.url) { + if (item.target) { + window.open(item.url, item.target); + return; + } + + window.location.href = item.url; + return; + } + + if (item.route && this.router) { + if (item.queryParams) { + this.router.transitionTo(item.route, ...(item.models ?? []), { queryParams: item.queryParams }); + return; + } + + this.router.transitionTo(item.route, ...(item.models ?? [])); + } + } + + @action handleKeydown(event) { + if (event.key !== 'Escape') { + return; + } + + if (this.hasSearchPopover) { + this.closeSearch(); + return; + } + + if (this.isNested) { + this.back(); + } + } + + @action handleDocumentKeydown(event) { + if (!(event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) { + return; + } + + event.preventDefault(); + this.openSearch(); + this.searchInputNode?.focus(); + } + + @action handleSearchPanelKeydown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + this.closeSearch(); + return; + } + + if (!this.hasSearchResults) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.setActiveSearchIndex(Math.min(this.activeSearchIndex + 1, this.limitedSearchResults.length - 1)); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.setActiveSearchIndex(Math.max(this.activeSearchIndex - 1, 0)); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + this.openActiveSearchResult(); + } + } + + @action setActiveSearchIndex(index) { + this.activeSearchIndex = index; + this.scrollActiveResultIntoView(); + } + + @action openActiveSearchResult() { + const result = this.limitedSearchResults[this.activeSearchIndex]; + + if (result) { + this.openSearchResult(result); + } + } + + @action updatePopoverPosition() { + if (!this.isSearchOpen || !this.searchWrapNode || typeof window === 'undefined') { + return; + } + + const rect = this.searchWrapNode.getBoundingClientRect(); + const viewportPadding = 12; + const targetWidth = 440; + const maxWidth = Math.max(260, window.innerWidth - viewportPadding * 2); + const width = Math.min(targetWidth, maxWidth); + const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding); + const top = rect.top; + const sourceScale = rect.width / width; + const sourceTranslateX = rect.left - left; + + this.popoverStyle = `position: fixed; top: ${top}px; left: ${left}px; width: ${width}px; --search-source-x: ${sourceTranslateX}px; --search-source-scale: ${sourceScale};`; + } + + ensurePopoverTarget() { + if (this.popoverTarget || typeof document === 'undefined') { + return; + } + + const root = document.getElementById('application-root-wormhole') ?? document.body; + const target = document.createElement('div'); + + target.className = 'next-sidebar-navigator-search-portal'; + root.appendChild(target); + this.popoverTarget = target; + } + + destroyPopoverTarget() { + this.popoverTarget?.remove(); + this.popoverTarget = null; + this.portalSearchInputNode = null; + this.searchResultsNode = null; + } + + transitionToStack(nextStack, direction) { + window.clearTimeout(this.transitionTimer); + + if (this.viewportNode) { + this.viewportNode.style.setProperty('--next-sidebar-navigator-transition-height', `${this.viewportNode.scrollHeight}px`); + } + + this.outgoingView = { + items: this.currentItems, + parent: this.currentParent, + }; + this.transitionDirection = direction; + this.viewStack = nextStack; + this.transitionTimer = window.setTimeout(() => { + this.outgoingView = null; + this.viewportNode?.style.removeProperty('--next-sidebar-navigator-transition-height'); + }, 220); + } + + findMatchingItem(items = [], item = {}) { + const key = this.itemKey(item); + return items.find((candidate) => this.itemKey(candidate) === key); + } + + itemKey(item = {}) { + return item.id ?? item.route ?? item.url ?? item.label ?? item.title; + } + + searchProvider() { + const provider = this.args.searchProvider; + const query = this.query.trim(); + const token = ++this.searchToken; + + if (typeof provider !== 'function' || !query) { + this.providerResults = []; + this.isSearching = false; + return; + } + + this.isSearching = true; + + let providerResults; + + try { + providerResults = provider({ query, items: this.items, limit: this.maxSearchResults }); + } catch (_) { + this.providerResults = []; + this.isSearching = false; + return; + } + + Promise.resolve(providerResults) + .then((results = []) => { + if (token !== this.searchToken) { + return; + } + + this.providerResults = this.sidebarNavigator.normalizeSearchResults(results); + this.activeSearchIndex = Math.min(this.activeSearchIndex, Math.max(this.limitedSearchResults.length - 1, 0)); + }) + .catch(() => { + if (token === this.searchToken) { + this.providerResults = []; + } + }) + .finally(() => { + if (token === this.searchToken) { + this.isSearching = false; + } + }); + } + + scrollActiveResultIntoView() { + window.requestAnimationFrame(() => { + const activeResult = this.searchResultsNode?.querySelector(`[data-search-result-index="${this.activeSearchIndex}"]`); + activeResult?.scrollIntoView({ block: 'nearest' }); + }); + } + + itemDescriptionVisible = (item) => { + return Boolean(item?.showDescription && item?.description); + }; + + tooltipFor = (item) => { + if (typeof item?.tooltip === 'string') { + return item.tooltip; + } + + if (item?.tooltip === true) { + return item.description; + } + + return null; + }; + + isActive = (item) => { + return this.sidebarNavigator.isActive(item); + }; + + isParentActive = (item) => { + return this.sidebarNavigator.activePath([item]).length > 0; + }; + + breadcrumb = (path) => { + return this.sidebarNavigator.breadcrumb(path); + }; + + reducedMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} diff --git a/addon/components/modals/fleetbase-legal-notice.hbs b/addon/components/modals/fleetbase-legal-notice.hbs new file mode 100644 index 00000000..62404812 --- /dev/null +++ b/addon/components/modals/fleetbase-legal-notice.hbs @@ -0,0 +1,61 @@ + + + diff --git a/addon/components/table.hbs b/addon/components/table.hbs index dde93799..3dcbfdf5 100644 --- a/addon/components/table.hbs +++ b/addon/components/table.hbs @@ -4,14 +4,15 @@ {{yield (hash columns=this.visibleColumns - rows=@rows + rows=this.rows pagination=@pagination - body=(component "table/body" selectable=@selectable rows=@rows columns=this.visibleColumns canExpand=@canExpand onRowClick=@onRowClick) + emptyState=this.emptyStateContext + body=(component "table/body" selectable=@selectable rows=this.rows columns=this.visibleColumns canExpand=@canExpand onRowClick=@onRowClick) head=(component "table/head" canSelectAll=@canSelectAll canExpand=@canExpand - rows=@rows + rows=this.rows columns=this.visibleColumns allRowsToggled=this.allRowsToggled selectAllRows=this.selectAllRows @@ -47,20 +48,51 @@ {{/each}} - - {{#each @rows as |row|}} - - {{#if @selectable}} - - - - {{/if}} - {{#each this.visibleColumns as |column|}} - - {{/each}} - - {{/each}} - + {{#if this.hasRows}} + + {{#each this.rows as |row|}} + + {{#if @selectable}} + + + + {{/if}} + {{#each this.visibleColumns as |column|}} + + {{/each}} + + {{/each}} + + {{else}} + + + + {{#if (has-block "emptyState")}} + {{yield this.emptyStateContext to="emptyState"}} + {{else if @emptyStateComponent}} + {{component @emptyStateComponent context=this.emptyStateContext}} + {{else}} +
+ {{#if @emptyStateIcon}} +
+ +
+ {{/if}} +
{{or @emptyStateTitle "No records found"}}
+ {{#if @emptyStateDescription}} +
{{@emptyStateDescription}}
+ {{/if}} + {{#if @emptyStateAction}} + + {{/if}} +
+ {{/if}} + + + + {{/if}} {{#if (or @tfoot (and @pagination @useTfootPagination))}} @@ -81,4 +113,4 @@
{{/if}} -
\ No newline at end of file + diff --git a/addon/components/table.js b/addon/components/table.js index e3e57359..9deab0b4 100644 --- a/addon/components/table.js +++ b/addon/components/table.js @@ -5,18 +5,15 @@ import { inject as service } from '@ember/service'; import { isArray } from '@ember/array'; import { later } from '@ember/runloop'; import { filter, alias } from '@ember/object/computed'; -import { isEqual } from '@fleetbase/ember-core/decorators/is-equal'; export default class TableComponent extends Component { @service tableContext; @tracked tableNode; @tracked allRowsToggled = false; @tracked sortColumns = []; - @alias('args.rows') rows; @alias('args.columns') columns; @filter('args.columns.@each.hidden', (column) => !column.hidden) visibleColumns; @filter('args.rows.@each.checked', (row) => row.checked) selectedRows; - @isEqual('selectedRows.length', 'rows.length') allRowsSelected; constructor() { super(...arguments); @@ -24,6 +21,39 @@ export default class TableComponent extends Component { this.initializeSortColumns(); } + get rows() { + const rows = this.args.rows ?? []; + if (isArray(rows)) return rows; + if (typeof rows?.toArray === 'function') return rows.toArray(); + return Array.from(rows); + } + + get hasRows() { + return isArray(this.rows) && this.rows.length > 0; + } + + get allRowsSelected() { + return this.selectedRows.length === (this.rows?.length ?? 0); + } + + get emptyStateColspan() { + const selectableColumnCount = this.args.selectable ? 1 : 0; + const expandColumnCount = this.args.canExpand ? 1 : 0; + + return (this.visibleColumns?.length ?? 0) + selectableColumnCount + expandColumnCount; + } + + get emptyStateContext() { + return { + columns: this.visibleColumns, + rows: this.rows ?? [], + pagination: this.args.pagination, + paginationMeta: this.args.paginationMeta, + searchQuery: this.args.searchQuery, + isFiltered: this.args.isFiltered, + }; + } + initializeSortColumns() { const { sortBy, sortOrder } = this.args; diff --git a/addon/components/table/empty-state.hbs b/addon/components/table/empty-state.hbs new file mode 100644 index 00000000..4b4e37e9 --- /dev/null +++ b/addon/components/table/empty-state.hbs @@ -0,0 +1,49 @@ +
+
+ +
+ +
+

{{this.title}}

+ {{#if this.description}} +

{{this.description}}

+ {{/if}} + {{#if this.note}} +

{{this.note}}

+ {{/if}} +
+ + {{#if (or @primaryAction @secondaryAction)}} +
+ {{#if @secondaryAction}} +
+ {{/if}} + + {{#if this.docsTarget}} +
+ +
+ {{/if}} +
diff --git a/addon/components/table/empty-state.js b/addon/components/table/empty-state.js new file mode 100644 index 00000000..e487870a --- /dev/null +++ b/addon/components/table/empty-state.js @@ -0,0 +1,96 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class TableEmptyStateComponent extends Component { + @service docsPanel; + + get isCompact() { + return this.args.variant === 'compact'; + } + + get hasSearchOrFilter() { + const searchQuery = this.args.context?.searchQuery; + + return Boolean(this.args.context?.isFiltered || (typeof searchQuery === 'string' && searchQuery.trim().length > 0)); + } + + get title() { + if (this.hasSearchOrFilter && this.args.filteredTitle) { + return this.args.filteredTitle; + } + + return this.args.title ?? 'No records yet'; + } + + get description() { + if (this.hasSearchOrFilter && this.args.filteredDescription) { + return this.args.filteredDescription; + } + + return this.args.description; + } + + get note() { + if (this.hasSearchOrFilter && this.args.filteredNote) { + return this.args.filteredNote; + } + + return this.args.note; + } + + get icon() { + if (this.hasSearchOrFilter && this.args.filteredIcon) { + return this.args.filteredIcon; + } + + return this.args.icon ?? 'inbox'; + } + + get wrapperClass() { + if (this.isCompact) { + return 'next-table-empty-state next-table-empty-state-compact'; + } + + return 'next-table-empty-state'; + } + + get actionButtonSize() { + return this.isCompact ? 'sm' : 'md'; + } + + get docsText() { + return this.args.docsText ?? 'Read guide'; + } + + get docsTitle() { + return this.args.docsTitle ?? this.title ?? 'Documentation'; + } + + get docsSource() { + return this.args.docsSource ?? 'table-empty-state'; + } + + get docsTarget() { + return this.args.docsSlug ?? this.args.docsUrl; + } + + @action openDocs() { + const docsTarget = this.docsTarget; + + if (!docsTarget) { + return; + } + + if (this.docsPanel?.open) { + return this.docsPanel.open(docsTarget, { + title: this.docsTitle, + source: this.docsSource, + }); + } + + if (typeof window !== 'undefined') { + return window.open(docsTarget, '_docs'); + } + } +} diff --git a/addon/services/docs-panel.js b/addon/services/docs-panel.js index de322ffe..6f82975e 100644 --- a/addon/services/docs-panel.js +++ b/addon/services/docs-panel.js @@ -1,8 +1,12 @@ import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +export const DOCS_BASE_URL = 'https://www.fleetbase.io/docs/'; const OFFICIAL_DOC_HOSTS = ['www.fleetbase.io', 'fleetbase.io', 'docs.fleetbase.io']; +const DOCS_EMBED_SOURCE = 'console'; +const SUPPORTED_THEMES = ['light', 'dark']; export default class DocsPanelService extends Service { @tracked isOpen = false; @@ -10,6 +14,8 @@ export default class DocsPanelService extends Service { @tracked title = 'Documentation'; @tracked source = null; @tracked iframeFailed = false; + @tracked isIframeLoading = false; + @tracked iframeTheme = 'light'; get canEmbed() { if (!this.url) { @@ -24,23 +30,44 @@ export default class DocsPanelService extends Service { } } + get isIframeThemeDark() { + return this.iframeTheme === 'dark'; + } + + get bodyWrapperClass() { + if (this.isIframeThemeDark) { + return 'fleetbase-docs-panel-body fleetbase-docs-panel-body-dark'; + } + + return 'fleetbase-docs-panel-body fleetbase-docs-panel-body-light'; + } + @action open(url, options = {}) { - this.url = this.normalizeUrl(url); + this.iframeTheme = this.resolveTheme(options.theme); + this.url = this.normalizeUrl(url, { theme: this.iframeTheme }); this.title = options.title ?? 'Documentation'; this.source = options.source ?? null; this.iframeFailed = false; + this.isIframeLoading = this.canEmbed; this.isOpen = true; } @action close() { this.isOpen = false; + this.isIframeLoading = false; } @action markIframeFailed() { this.iframeFailed = true; + this.isIframeLoading = false; + } + + @action + markIframeLoaded() { + this.isIframeLoading = false; } @action @@ -50,19 +77,88 @@ export default class DocsPanelService extends Service { } } - normalizeUrl(url) { + normalizeUrl(url, options = {}) { + const theme = this.sanitizeTheme(options.theme); + if (!url) { - return 'https://www.fleetbase.io/docs'; + return this.withDocsEmbedParams(DOCS_BASE_URL, theme); } if (url.startsWith('/docs')) { - return `https://www.fleetbase.io${url}`; + return this.withDocsEmbedParams(`https://www.fleetbase.io${url}`, theme); } if (url.startsWith('docs/')) { - return `https://www.fleetbase.io/${url}`; + return this.withDocsEmbedParams(`https://www.fleetbase.io/${url}`, theme); + } + + if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//')) { + return this.withDocsEmbedParams(`${DOCS_BASE_URL}${url.replace(/^\/+/, '')}`, theme); + } + + if (this.isOfficialDocsUrl(url)) { + return this.withDocsEmbedParams(url, theme); } return url; } + + resolveTheme(theme) { + const explicitTheme = this.sanitizeTheme(theme); + + if (explicitTheme) { + return explicitTheme; + } + + let themeService; + + try { + const owner = getOwner(this); + + if (owner?.hasRegistration?.('service:theme') === false) { + return 'light'; + } + + themeService = owner?.lookup?.('service:theme'); + } catch { + themeService = null; + } + + return this.sanitizeTheme(themeService?.currentTheme) ?? 'light'; + } + + sanitizeTheme(theme) { + if (SUPPORTED_THEMES.includes(theme)) { + return theme; + } + } + + isOfficialDocsUrl(url) { + try { + const parsed = new URL(url, window.location.origin); + return OFFICIAL_DOC_HOSTS.includes(parsed.hostname) && (parsed.hostname === 'docs.fleetbase.io' || parsed.pathname.startsWith('/docs')); + } catch { + return false; + } + } + + withDocsEmbedParams(url, theme) { + try { + const parsed = new URL(url, window.location.origin); + + if (!this.isOfficialDocsUrl(parsed.href)) { + return url; + } + + parsed.searchParams.set('embed', DOCS_EMBED_SOURCE); + + if (theme) { + parsed.searchParams.set('theme', theme); + } + + return parsed.href; + } catch { + return url; + } + } } diff --git a/addon/services/sidebar-navigator.js b/addon/services/sidebar-navigator.js new file mode 100644 index 00000000..07393f3a --- /dev/null +++ b/addon/services/sidebar-navigator.js @@ -0,0 +1,126 @@ +import Service from '@ember/service'; +import { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application'; + +export default class SidebarNavigatorService extends Service { + @service abilities; + + get router() { + return this.lookupService('router') ?? this.lookupService('host-router'); + } + + lookupService(name) { + try { + return getOwner(this).lookup(`service:${name}`); + } catch (_) { + return null; + } + } + + normalizeItems(items = []) { + if (!Array.isArray(items)) { + return []; + } + + return items + .filter((item) => this.isVisible(item)) + .map((item) => { + return { + ...item, + children: this.normalizeItems(item.children ?? []), + }; + }); + } + + isVisible(item = {}) { + if (!item || item.visible === false) { + return false; + } + + if (item.permission) { + try { + return this.abilities.can(item.permission); + } catch (_) { + return true; + } + } + + return true; + } + + flattenItems(items = [], trail = []) { + return items.flatMap((item) => { + const path = [...trail, item]; + + return [{ item, path }, ...this.flattenItems(item.children ?? [], path)]; + }); + } + + searchItems(items = [], query = '') { + const needle = query.trim().toLowerCase(); + + if (!needle) { + return []; + } + + return this.flattenItems(items).filter(({ item, path }) => { + const haystack = [item.label, item.title, item.description, item.route, item.url, ...(item.keywords ?? []), ...path.map((pathItem) => pathItem.label ?? pathItem.title)] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(needle); + }); + } + + normalizeSearchResults(results = []) { + if (!Array.isArray(results)) { + return []; + } + + return results.filter(Boolean).map((result) => { + return { + type: 'Result', + ...result, + item: result.item ?? result, + label: result.label ?? result.title, + breadcrumb: result.breadcrumb, + }; + }); + } + + activePath(items = [], routeName = this.router?.currentRouteName, currentURL = this.router?.currentURL) { + for (const item of items) { + const childPath = this.activePath(item.children ?? [], routeName, currentURL); + + if (childPath.length) { + return [item, ...childPath]; + } + + if (this.isActive(item, routeName, currentURL)) { + return [item]; + } + } + + return []; + } + + isActive(item = {}, routeName = this.router?.currentRouteName, currentURL = this.router?.currentURL) { + if (item.route && routeName?.startsWith(item.route)) { + return true; + } + + if (item.url && currentURL === item.url) { + return true; + } + + return false; + } + + breadcrumb(path = []) { + return path + .map((item) => item.label ?? item.title) + .filter(Boolean) + .join(' > '); + } +} diff --git a/addon/services/table-context.js b/addon/services/table-context.js new file mode 100644 index 00000000..e82d280a --- /dev/null +++ b/addon/services/table-context.js @@ -0,0 +1,20 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class TableContextService extends Service { + @tracked node; + @tracked table; + + @action getSelectedIds() { + return this.table?.selectedRows?.map((row) => row.id) ?? []; + } + + @action getSelectedRows() { + return this.table?.selectedRows ?? []; + } + + @action untoggleSelectAll() { + return this.table?.untoggleSelectAll?.(); + } +} diff --git a/addon/styles/addon.css b/addon/styles/addon.css index 3d823005..5c975829 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -12,6 +12,7 @@ @import 'components/button.css'; @import 'components/input.css'; @import 'components/sidebar-panels.css'; +@import 'components/sidebar-navigator.css'; @import 'components/table.css'; @import 'components/panes.css'; @import 'components/navbar.css'; @@ -54,17 +55,50 @@ @import 'components/activity-log.css'; @import 'components/smart-nav-menu.css'; @import 'components/template-builder.css'; +@import 'components/fleetbase-attribution.css'; /** Third party */ @import "ember-basic-dropdown/vendor/ember-basic-dropdown.css"; @import 'ember-power-select/vendor/ember-power-select.css'; @import 'air-datepicker/air-datepicker.css'; .fleetbase-docs-panel-body { + position: relative; height: 100%; min-height: 0; padding: 0; } +.fleetbase-docs-panel-body-light { + background: #fff; +} + +.fleetbase-docs-panel-body-dark { + background: #111827; +} + .fleetbase-docs-panel-body > iframe { display: block; } + +.fleetbase-docs-panel-loading { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: #4b5563; + font-size: 0.875rem; + font-weight: 600; +} + +.fleetbase-docs-panel-body-light .fleetbase-docs-panel-loading { + background: #fff; +} + +.fleetbase-docs-panel-body-dark .fleetbase-docs-panel-loading { + color: #d1d5db; + background: #111827; +} diff --git a/addon/styles/components/activity-log.css b/addon/styles/components/activity-log.css index 36bb4b65..902a846e 100644 --- a/addon/styles/components/activity-log.css +++ b/addon/styles/components/activity-log.css @@ -1,31 +1,189 @@ -.activity-log .activity-change-prop { - @apply px-1 py-0.5 text-xs border rounded-md border-gray-200 dark:border-gray-800; +.activity-log { + @apply text-gray-900 dark:text-gray-100; } -.activity-log .activity-change-prop.highlight-yellow { - @apply bg-yellow-100 border-yellow-200 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-100; +.activity-log-header { + @apply mb-3 flex items-center justify-between gap-3; } -.activity-log .activity-change-prop.highlight-green { - @apply bg-green-100 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-100; +.activity-log-title { + @apply text-sm font-semibold tracking-wide text-gray-500 dark:text-gray-400; } -.activity-log .activity-change-prop.highlight-gray { - @apply bg-gray-100 border-gray-200 text-gray-800 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 +.activity-log-header-actions, +.activity-log-filters { + @apply flex flex-row items-center gap-2; } -.activity-log .activity-change-prop.highlight-blue { - @apply bg-blue-100 border-blue-200 text-blue-800 dark:bg-blue-900 dark:border-blue-700 dark:text-blue-100 +.activity-log-list { + @apply overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900; } -.activity-log .activity-change-prop.old-value { - @apply border-yellow-200 dark:border-yellow-900; +.activity-log-item { + @apply relative flex min-h-[56px] items-center gap-2 px-4 py-3; } -.activity-log .activity-change-prop.new-value { - @apply border-blue-200 dark:border-blue-900; +.activity-log-item:not(:last-child)::before { + @apply absolute bottom-0 left-[26px] top-[36px] w-px bg-gray-200 dark:bg-gray-700; + content: ''; } -.activity-log .activity-log-knob { - @apply absolute -left-[32px] top-2 h-4 w-4 rounded-full bg-gray-100 ring-2 ring-white shadow dark:bg-gray-800 dark:ring-gray-900; -} \ No newline at end of file +.activity-log-marker { + @apply relative z-10 mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-yellow-500 text-[11px] font-semibold leading-none text-white shadow-sm ring-2 ring-white dark:ring-gray-900; +} + +.activity-log-marker img { + @apply h-full w-full object-cover; +} + +.activity-log-marker span { + @apply block leading-none; +} + +.activity-log-row { + @apply flex min-w-0 flex-1 items-start justify-between gap-4; +} + +.activity-log-main { + @apply flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1; +} + +.activity-log-sentence { + @apply min-w-0 text-sm font-medium leading-6 text-gray-700 dark:text-gray-300; +} + +.activity-log-actor, +.activity-log-object, +.activity-log-change-count { + @apply inline border-0 bg-transparent p-0 text-left font-semibold text-gray-900 underline-offset-4 transition hover:underline focus:outline-none focus:ring-0 dark:text-gray-100; +} + +.activity-log-verb, +.activity-log-muted { + @apply text-gray-500 dark:text-gray-400; +} + +.activity-log-inline-change { + @apply inline; +} + +.activity-log-inline-attribute { + @apply font-semibold text-gray-700 dark:text-gray-200; +} + +.activity-log-inline-value { + @apply max-w-[16rem] truncate align-bottom font-semibold text-gray-900 dark:text-gray-100; +} + +.activity-log-change-trigger { + @apply relative inline-flex; +} + +.activity-log-change-count { + @apply rounded-md bg-gray-100 px-2 py-0.5 no-underline hover:bg-gray-200 hover:no-underline dark:bg-gray-800 dark:hover:bg-gray-700; +} + +.activity-log-time { + @apply mt-0.5 shrink-0 whitespace-nowrap text-sm font-medium text-gray-400 dark:text-gray-500; +} + +.activity-log-badge { + @apply inline-flex items-center rounded-md px-2 py-0.5 text-xs ring-1; +} + +.activity-log-empty { + @apply flex min-h-24 items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center dark:border-gray-700 dark:bg-gray-900; +} + +.activity-log-empty-icon { + @apply mb-2 text-gray-400; +} + +.activity-log-empty-text { + @apply text-sm font-medium text-gray-600 dark:text-gray-400; +} + +.activity-log-changes-popover { + @apply z-50 m-0 overflow-hidden rounded-lg border border-gray-200 bg-white p-0 shadow-xl ring-1 ring-black/5 dark:border-gray-700 dark:bg-gray-900 dark:ring-white/10; + min-width: 26rem; + max-width: min(36rem, calc(100vw - 2rem)); +} + +.activity-log-changes-table { + @apply grid text-xs; +} + +.activity-log-changes-row { + @apply grid items-center border-t border-gray-100 dark:border-gray-800; + grid-template-columns: minmax(8rem, 0.9fr) minmax(8rem, 1fr) minmax(8rem, 1fr); +} + +.activity-log-changes-table.without-previous-values .activity-log-changes-row { + grid-template-columns: minmax(8rem, 0.9fr) minmax(8rem, 1fr); +} + +.activity-log-changes-row:first-child { + @apply border-t-0; +} + +.activity-log-changes-row > div { + @apply min-w-0 truncate px-3 py-2; +} + +.activity-log-changes-head { + @apply bg-gray-50 text-[11px] font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-400; +} + +.activity-log-change-attribute { + @apply font-medium text-gray-600 dark:text-gray-300; +} + +.activity-log-change-value { + @apply font-medium text-gray-900 dark:text-gray-100; +} + +.activity-log-change-value-old { + @apply text-gray-500 dark:text-gray-400; +} + +.activity-log-change-value-new { + @apply text-gray-900 dark:text-gray-100; +} + +.activity-log-density-cozy .activity-log-item { + @apply min-h-[64px] px-5 py-4; +} + +.activity-log-density-cozy .activity-log-item:not(:last-child)::before { + @apply left-[30px] top-[40px]; +} + +@media (max-width: 640px) { + .activity-log-header { + @apply items-start; + } + + .activity-log-header-actions { + @apply flex-wrap justify-end; + } + + .activity-log-row { + @apply flex-col gap-1; + } + + .activity-log-time { + @apply mt-0 text-xs; + } + + .activity-log-changes-popover { + min-width: min(24rem, calc(100vw - 2rem)); + } + + .activity-log-changes-row { + grid-template-columns: minmax(7rem, 0.8fr) minmax(7rem, 1fr) minmax(7rem, 1fr); + } + + .activity-log-changes-table.without-previous-values .activity-log-changes-row { + grid-template-columns: minmax(7rem, 0.8fr) minmax(7rem, 1fr); + } +} diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css index 7761d923..24f86807 100644 --- a/addon/styles/components/badge.css +++ b/addon/styles/components/badge.css @@ -37,6 +37,7 @@ .status-badge.published-status-badge > span, .status-badge.live-status-badge > span, .status-badge.online-status-badge > span, +.status-badge.connected-status-badge > span, .status-badge.created-status-badge > span, .status-badge.open-status-badge > span, .status-badge.enabled-status-badge > span, @@ -65,6 +66,7 @@ .status-badge.published-status-badge > span svg, .status-badge.live-status-badge > span svg, .status-badge.online-status-badge > span svg, +.status-badge.connected-status-badge > span svg, .status-badge.created-status-badge > span svg, .status-badge.open-status-badge > span svg, .status-badge.enabled-status-badge > span svg, @@ -137,11 +139,17 @@ } .status-badge.closed-status-badge > span, +.status-badge.not-configured-status-badge > span, +.status-badge.not-verified-status-badge > span, +.status-badge.not-tested-status-badge > span, .status-badge.slate-status-badge > span { @apply bg-slate-800 border-slate-700 text-slate-100; } .status-badge.closed-status-badge > span svg, +.status-badge.not-configured-status-badge > span svg, +.status-badge.not-verified-status-badge > span svg, +.status-badge.not-tested-status-badge > span svg, .status-badge.slate-status-badge > span svg { @apply text-slate-300; } @@ -161,6 +169,8 @@ .status-badge.faulty-status-badge > span, .status-badge.degraded-status-badge > span, .status-badge.error-status-badge > span, +.status-badge.connection-failed-status-badge > span, +.status-badge.sync-failed-status-badge > span, .status-badge.escalated-status-badge > span, .status-badge.urgent-status-badge > span, .status-badge.high-status-badge > span, @@ -186,6 +196,8 @@ .status-badge.faulty-status-badge > span svg, .status-badge.degraded-status-badge > span svg, .status-badge.error-status-badge > span svg, +.status-badge.connection-failed-status-badge > span svg, +.status-badge.sync-failed-status-badge > span svg, .status-badge.escalated-status-badge > span svg, .status-badge.high-status-badge > span svg, .status-badge.rejected-status-badge > span svg, @@ -221,6 +233,9 @@ .status-badge.maintenance-status-badge > span, .status-badge.test-status-badge > span, .status-badge.warning-status-badge > span, +.status-badge.synchronizing-status-badge > span, +.status-badge.syncing-status-badge > span, +.status-badge.sync-queued-status-badge > span, .status-badge.medium-status-badge > span, .status-badge.preparing-status-badge > span, .status-badge.trial-status-badge > span, @@ -252,6 +267,9 @@ .status-badge.maintenance-status-badge > span svg, .status-badge.test-status-badge > span svg, .status-badge.warning-status-badge > span svg, +.status-badge.synchronizing-status-badge > span svg, +.status-badge.syncing-status-badge > span svg, +.status-badge.sync-queued-status-badge > span svg, .status-badge.medium-status-badge > span svg, .status-badge.preparing-status-badge > span svg, .status-badge.trial-status-badge > span svg, diff --git a/addon/styles/components/fleetbase-attribution.css b/addon/styles/components/fleetbase-attribution.css new file mode 100644 index 00000000..1451aa79 --- /dev/null +++ b/addon/styles/components/fleetbase-attribution.css @@ -0,0 +1,212 @@ +.fleetbase-attribution-notice { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.1875rem; + min-width: 0; + color: rgb(107 114 128); + font-size: 0.6875rem; + line-height: 0.875rem; + white-space: normal; +} + +.fleetbase-attribution-notice--standalone { + margin-top: 1.25rem; +} + +.fleetbase-attribution-notice--sidebar { + justify-content: flex-start; +} + +.fleetbase-attribution-link, +.fleetbase-attribution-legal-link { + color: inherit; + font: inherit; + text-align: left; + text-decoration: none; +} + +.fleetbase-attribution-link { + font-weight: 600; +} + +.fleetbase-attribution-separator { + color: rgb(156 163 175); + flex: 0 0 auto; +} + +.fleetbase-attribution-link:hover, +.fleetbase-attribution-legal-link:hover { + color: rgb(14 165 233); + text-decoration: underline; +} + +.fleetbase-attribution-legal-link { + appearance: none; + background: transparent; + border: 0; + cursor: pointer; + padding: 0; +} + +.fleetbase-legal-notice-content { + color: rgb(31 41 55); + display: flex; + flex-direction: column; + gap: 0.875rem; + padding-top: 0 !important; +} + +.fleetbase-legal-notice-hero { + align-items: flex-start; + display: flex; + gap: 0.75rem; + justify-content: space-between; + padding-top: 1rem; +} + +.fleetbase-legal-notice-hero-main { + align-items: center; + display: flex; + gap: 0.75rem; + min-width: 0; +} + +.fleetbase-legal-notice-logo { + flex: 0 0 auto; + height: 2.5rem; + object-fit: cover; + width: 2.5rem; +} + +.fleetbase-legal-notice-hero h3 { + color: rgb(17 24 39); + font-size: 1rem; + font-weight: 700; + line-height: 1.5rem; + margin: 0; +} + +.fleetbase-legal-notice-hero p, +.fleetbase-legal-notice-hero span { + color: rgb(107 114 128); + font-size: 0.75rem; + line-height: 1.125rem; + margin: 0; +} + +.fleetbase-legal-notice-hero span { + flex: 0 0 auto; + text-align: right; +} + +.fleetbase-legal-notice-section { + display: block; +} + +.fleetbase-legal-notice-section h4 { + color: rgb(17 24 39); + font-size: 0.8125rem; + font-weight: 700; + line-height: 1.125rem; + margin: 0 0 0.375rem; +} + +.fleetbase-legal-notice-section p { + color: rgb(75 85 99); + font-size: 0.875rem; + line-height: 1.5rem; + margin: 0; +} + +.fleetbase-legal-notice-links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.fleetbase-legal-notice-links a { + align-items: center; + background: rgb(255 255 255); + border: 1px solid rgb(229 231 235); + border-radius: 0.5rem; + color: rgb(2 132 199); + display: flex; + font-size: 0.875rem; + font-weight: 600; + gap: 0.5rem; + line-height: 1.25rem; + padding: 0.5rem 0.75rem; + text-decoration: none; +} + +.fleetbase-legal-notice-link-icon { + flex: 0 0 auto; + font-size: 0.75rem; +} + +.fleetbase-legal-notice-links a:hover { + background: rgb(240 249 255); + border-color: rgb(186 230 253); + color: rgb(3 105 161); + text-decoration: none; +} + +body[data-theme='dark'] .fleetbase-attribution-notice { + color: rgb(156 163 175); +} + +body[data-theme='dark'] .fleetbase-attribution-separator { + color: rgb(107 114 128); +} + +body[data-theme='dark'] .fleetbase-attribution-link:hover, +body[data-theme='dark'] .fleetbase-attribution-legal-link:hover { + color: rgb(56 189 248); +} + +body[data-theme='dark'] .fleetbase-legal-notice-content { + color: rgb(229 231 235); +} + +body[data-theme='dark'] .fleetbase-legal-notice-links a { + background: rgb(17 24 39); + border-color: rgb(55 65 81); +} + +body[data-theme='dark'] .fleetbase-legal-notice-hero h3, +body[data-theme='dark'] .fleetbase-legal-notice-section h4 { + color: rgb(249 250 251); +} + +body[data-theme='dark'] .fleetbase-legal-notice-hero p, +body[data-theme='dark'] .fleetbase-legal-notice-hero span, +body[data-theme='dark'] .fleetbase-legal-notice-section p { + color: rgb(209 213 219); +} + +body[data-theme='dark'] .fleetbase-legal-notice-links a { + color: rgb(56 189 248); +} + +body[data-theme='dark'] .fleetbase-legal-notice-links a:hover { + background: rgb(12 74 110 / 0.3); + border-color: rgb(14 116 144); + color: rgb(125 211 252); +} + +@media (max-width: 640px) { + .fleetbase-legal-notice-hero { + flex-direction: column; + gap: 0.5rem; + } + + .fleetbase-legal-notice-hero span { + text-align: left; + } + + .fleetbase-legal-notice-links { + grid-template-columns: 1fr; + } +} diff --git a/addon/styles/components/sidebar-navigator.css b/addon/styles/components/sidebar-navigator.css new file mode 100644 index 00000000..5139c366 --- /dev/null +++ b/addon/styles/components/sidebar-navigator.css @@ -0,0 +1,531 @@ +.next-sidebar-navigator { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + position: relative; +} + +.next-sidebar-navigator-primary-action, +.next-sidebar-navigator-search, +.next-sidebar-navigator-item, +.next-sidebar-navigator-back { + width: 100%; + border-radius: 0.375rem; +} + +.next-sidebar-navigator-primary-action { + min-height: 2rem; + @apply flex items-center justify-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600; +} + +.next-sidebar-navigator-primary-action:hover { + @apply bg-blue-700; +} + +.next-sidebar-navigator-search-wrap { + position: relative; + z-index: 20; +} + +.next-sidebar-navigator-search { + min-height: 1.625rem; + padding: 0.5rem; + @apply flex items-center gap-1.5 text-gray-500 bg-gray-50 border border-gray-200 shadow-sm; +} + +.next-sidebar-navigator-search.is-morphing { + opacity: 0; + transform: scale(0.98); +} + +.next-sidebar-navigator-search input { + padding-top: 0; + padding-bottom: 0; + padding-left: 0; + line-height: 1rem; + font-size: 0.75rem; + appearance: none; + @apply flex-1 min-w-0 bg-transparent border-0 outline-none shadow-none; +} + +.next-sidebar-navigator-search input::-webkit-search-cancel-button, +.next-sidebar-navigator-search input::-webkit-search-decoration, +.next-sidebar-navigator-search input::-webkit-search-results-button, +.next-sidebar-navigator-search input::-webkit-search-results-decoration, +.next-sidebar-navigator-search-popover-input input::-webkit-search-cancel-button, +.next-sidebar-navigator-search-popover-input input::-webkit-search-decoration, +.next-sidebar-navigator-search-popover-input input::-webkit-search-results-button, +.next-sidebar-navigator-search-popover-input input::-webkit-search-results-decoration { + display: none; + -webkit-appearance: none; + appearance: none; +} + +.next-sidebar-navigator-search input:focus { + @apply outline-none shadow-none ring-0; +} + +.next-sidebar-navigator-search button, +.next-sidebar-navigator-search-clear { + @apply flex items-center justify-center w-5 h-5 text-gray-400 rounded; +} + +.next-sidebar-navigator-search button:hover, +.next-sidebar-navigator-search-clear:hover { + @apply text-gray-700 bg-gray-200; +} + +.next-sidebar-navigator-kbd { + font-size: 0.625rem; + line-height: 1; + @apply px-1.5 py-0.5 text-gray-400 border border-gray-300 rounded; +} + +.next-sidebar-navigator-search-overlay { + position: fixed; + inset: 0; + display: block; + z-index: 890; + padding: 0; + cursor: default; + background-color: rgb(15 23 42 / 8%); + border: 0; + appearance: none; + opacity: 1; +} + +.next-sidebar-navigator-search-overlay.is-primed { + opacity: 0; +} + +.next-sidebar-navigator-search-overlay.is-opening { + animation: next-sidebar-search-overlay-in 160ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-search-overlay.is-closing { + animation: next-sidebar-search-overlay-out 140ms cubic-bezier(0.4, 0, 1, 1) both; +} + +.next-sidebar-navigator-search-popover { + transform-origin: top left; + max-height: min(31rem, calc(100vh - 2rem)); + overflow: hidden; + z-index: 900; + box-shadow: + 0 10px 22px rgb(15 23 42 / 12%), + 0 2px 5px rgb(15 23 42 / 8%); + @apply bg-white border border-gray-300 rounded-lg; +} + +.next-sidebar-navigator-search-popover.is-primed { + opacity: 0.64; + transform: translateX(var(--search-source-x, 0)) scaleX(var(--search-source-scale, 1)) scaleY(0.72); +} + +.next-sidebar-navigator-search-popover.is-opening { + animation: next-sidebar-search-popover-open 160ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-search-popover.is-open { + opacity: 1; + transform: translateX(0) scale(1); +} + +.next-sidebar-navigator-search-popover.is-closing { + animation: next-sidebar-search-popover-close 140ms cubic-bezier(0.4, 0, 1, 1) both; +} + +.next-sidebar-navigator-search-popover-input { + min-height: 3rem; + @apply flex items-center gap-2 px-3 text-gray-500 border-b border-gray-200 bg-white; +} + +.next-sidebar-navigator-search-popover-input input { + line-height: 1.25rem; + appearance: none; + @apply flex-1 min-w-0 text-sm bg-transparent border-0 outline-none shadow-none; +} + +.next-sidebar-navigator-search-popover-input input:focus { + @apply outline-none shadow-none ring-0; +} + +.next-sidebar-navigator-search-popover-input button { + @apply flex items-center justify-center w-5 h-5 rounded text-gray-400; +} + +.next-sidebar-navigator-search-popover-input button:hover { + @apply text-gray-700 bg-gray-200; +} + +.next-sidebar-navigator-search-close { + @apply ml-1; +} + +.next-sidebar-navigator-search-results { + max-height: min(26rem, calc(100vh - 6rem)); + overflow-y: auto; + @apply p-1.5 bg-white; +} + +.next-sidebar-navigator-search-result { + min-height: 3.5rem; + @apply relative flex w-full items-start gap-3 rounded-md px-2.5 py-2 text-left text-gray-700 border border-transparent transition-colors duration-150; +} + +.next-sidebar-navigator-search-result.is-active, +.next-sidebar-navigator-search-result:hover { + @apply bg-gray-100 text-gray-900; +} + +.next-sidebar-navigator-search-result-icon { + @apply flex items-center justify-center w-9 h-9 mt-0.5 text-base text-gray-500 bg-gray-100 border border-gray-200 rounded-md shrink-0 transition-colors duration-150; +} + +.next-sidebar-navigator-search-result.is-active .next-sidebar-navigator-search-result-icon, +.next-sidebar-navigator-search-result:hover .next-sidebar-navigator-search-result-icon { + @apply text-gray-900 bg-white border-gray-300; +} + +.next-sidebar-navigator-search-result-content { + @apply flex flex-col min-w-0 flex-1 gap-0.5; +} + +.next-sidebar-navigator-search-result-label { + @apply text-sm font-semibold leading-5 truncate; +} + +.next-sidebar-navigator-search-result-description { + @apply text-xs leading-4 text-gray-500 truncate; +} + +.next-sidebar-navigator-search-result-meta { + font-size: 0.625rem; + line-height: 1; + @apply flex items-center gap-1 text-gray-400 truncate; +} + +.next-sidebar-navigator-search-status { + min-height: 7rem; + @apply flex flex-col items-center justify-center gap-2 px-3 py-6 text-sm text-center text-gray-400 bg-white; +} + +.next-sidebar-navigator-viewport { + position: relative; + min-height: 0; + overflow: hidden; +} + +.next-sidebar-navigator-view { + display: flex; + flex-direction: column; + gap: 0.1875rem; + min-width: 100%; +} + +.next-sidebar-navigator-view-out { + position: absolute; + inset: 0 auto auto 0; + width: 100%; + pointer-events: none; +} + +.next-sidebar-navigator-viewport.is-transitioning { + min-height: var(--next-sidebar-navigator-transition-height, 12rem); +} + +.next-sidebar-navigator-viewport.is-transitioning.is-forward .next-sidebar-navigator-view-out { + animation: next-sidebar-navigator-out-left 180ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-viewport.is-transitioning.is-forward .next-sidebar-navigator-view-in { + animation: next-sidebar-navigator-in-right 180ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-viewport.is-transitioning.is-back .next-sidebar-navigator-view-out { + animation: next-sidebar-navigator-out-right 180ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-viewport.is-transitioning.is-back .next-sidebar-navigator-view-in { + animation: next-sidebar-navigator-in-left 180ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.next-sidebar-navigator-back, +.next-sidebar-navigator-nested-header { + min-height: 2rem; + @apply relative flex items-center justify-center px-2 py-1.5 mb-1 text-sm font-medium text-gray-700 border border-transparent bg-transparent; +} + +.next-sidebar-navigator-back:hover { + background-color: rgb(229 231 235 / 65%); +} + +.next-sidebar-navigator-back-icon { + position: absolute; + left: 0.5rem; + @apply flex items-center justify-center w-5 h-5 text-gray-500; +} + +.next-sidebar-navigator-item { + min-height: 1.75rem; + @apply flex items-center gap-2 px-2 py-0.5 text-left text-gray-700 border border-transparent; +} + +.next-sidebar-navigator-item.has-description { + min-height: 2.5rem; + @apply items-start py-1; +} + +.next-sidebar-navigator-item:hover { + @apply bg-gray-100; +} + +.next-sidebar-navigator-item.is-parent-active { + @apply bg-gray-100; +} + +.next-sidebar-navigator-item.is-active { + @apply text-gray-900 bg-gray-200; +} + +.next-sidebar-navigator-item-icon { + @apply flex items-center justify-center w-5 h-5 shrink-0 text-gray-400; +} + +.next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-icon { + @apply text-gray-800; +} + +.next-sidebar-navigator-item-content { + @apply flex flex-col min-w-0 flex-1; +} + +.next-sidebar-navigator-item-label { + @apply text-sm leading-5 truncate; +} + +.next-sidebar-navigator-item-description { + @apply text-xs leading-4 text-gray-400 truncate; +} + +.next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-description { + @apply text-gray-600; +} + +.next-sidebar-navigator-item-badge { + font-size: 0.625rem; + line-height: 1; + @apply px-1.5 py-0.5 text-gray-500 bg-gray-200 rounded; +} + +.next-sidebar-navigator-item-caret { + @apply ml-auto flex items-center justify-center w-6 h-6 rounded text-gray-400 transition-colors; +} + +.next-sidebar-navigator-item:hover .next-sidebar-navigator-item-caret, +.next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-caret, +.next-sidebar-navigator-item.is-parent-active .next-sidebar-navigator-item-caret { + @apply text-gray-700 bg-gray-200; +} + +.next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-caret { + @apply text-gray-800 bg-gray-300; +} + +body[data-theme='dark'] .next-sidebar-navigator-search { + @apply text-gray-400 bg-gray-800 border-gray-700 shadow-none; +} + +body[data-theme='dark'] .next-sidebar-navigator-search input { + @apply text-gray-100; +} + +body[data-theme='dark'] .next-sidebar-navigator-search button:hover { + @apply text-gray-100 bg-gray-700; +} + +body[data-theme='dark'] .next-sidebar-navigator-kbd { + @apply text-gray-400 border-gray-700 bg-gray-900; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-popover { + @apply bg-gray-900 border-gray-700; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-overlay { + background-color: rgb(2 6 23 / 18%); +} + +body[data-theme='dark'] .next-sidebar-navigator-search-popover-input { + @apply text-gray-400 bg-gray-900 border-gray-700; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-popover-input input { + @apply text-gray-100; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-popover-input button:hover, +body[data-theme='dark'] .next-sidebar-navigator-search-result.is-active, +body[data-theme='dark'] .next-sidebar-navigator-search-result:hover { + @apply text-gray-100 bg-gray-800; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-results, +body[data-theme='dark'] .next-sidebar-navigator-search-status { + @apply bg-gray-900; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-result, +body[data-theme='dark'] .next-sidebar-navigator-back, +body[data-theme='dark'] .next-sidebar-navigator-nested-header, +body[data-theme='dark'] .next-sidebar-navigator-item { + @apply text-gray-200; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-result-icon { + @apply text-gray-400 bg-gray-800 border-gray-700; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-result.is-active .next-sidebar-navigator-search-result-icon, +body[data-theme='dark'] .next-sidebar-navigator-search-result:hover .next-sidebar-navigator-search-result-icon { + @apply text-gray-100 bg-gray-700 border-gray-600; +} + +body[data-theme='dark'] .next-sidebar-navigator-search-result-description { + @apply text-gray-400; +} + +body[data-theme='dark'] .next-sidebar-navigator-back, +body[data-theme='dark'] .next-sidebar-navigator-nested-header { + @apply bg-transparent border-transparent; +} + +body[data-theme='dark'] .next-sidebar-navigator-back:hover, +body[data-theme='dark'] .next-sidebar-navigator-item:hover, +body[data-theme='dark'] .next-sidebar-navigator-item.is-parent-active { + @apply bg-gray-700 bg-opacity-75; +} + +body[data-theme='dark'] .next-sidebar-navigator-item.is-active { + @apply bg-gray-700 bg-opacity-75 text-white; +} + +body[data-theme='dark'] .next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-icon { + @apply text-gray-100; +} + +body[data-theme='dark'] .next-sidebar-navigator-item:hover .next-sidebar-navigator-item-caret, +body[data-theme='dark'] .next-sidebar-navigator-item.is-active .next-sidebar-navigator-item-caret, +body[data-theme='dark'] .next-sidebar-navigator-item.is-parent-active .next-sidebar-navigator-item-caret { + @apply text-gray-100 bg-gray-800; +} + +body[data-theme='dark'] .next-sidebar-navigator-item-badge { + @apply text-gray-300 bg-gray-700; +} + +@keyframes next-sidebar-navigator-out-left { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(-1rem); + } +} + +@keyframes next-sidebar-navigator-in-right { + from { + opacity: 0; + transform: translateX(1rem); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes next-sidebar-navigator-out-right { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(1rem); + } +} + +@keyframes next-sidebar-navigator-in-left { + from { + opacity: 0; + transform: translateX(-1rem); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes next-sidebar-search-popover-open { + from { + opacity: 0.6; + transform: translateX(var(--search-source-x, 0)) scaleX(var(--search-source-scale, 1)) scaleY(0.72); + } + + to { + opacity: 1; + transform: translateX(0) scaleX(1) scaleY(1); + } +} + +@keyframes next-sidebar-search-popover-close { + from { + opacity: 1; + transform: translateX(0) scaleX(1) scaleY(1); + } + + to { + opacity: 0; + transform: translateX(var(--search-source-x, 0)) scaleX(var(--search-source-scale, 1)) scaleY(0.72); + } +} + +@keyframes next-sidebar-search-overlay-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes next-sidebar-search-overlay-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .next-sidebar-navigator-viewport.is-transitioning.is-forward .next-sidebar-navigator-view-out, + .next-sidebar-navigator-viewport.is-transitioning.is-forward .next-sidebar-navigator-view-in, + .next-sidebar-navigator-viewport.is-transitioning.is-back .next-sidebar-navigator-view-out, + .next-sidebar-navigator-viewport.is-transitioning.is-back .next-sidebar-navigator-view-in, + .next-sidebar-navigator-search-overlay.is-opening, + .next-sidebar-navigator-search-overlay.is-closing, + .next-sidebar-navigator-search-popover.is-opening, + .next-sidebar-navigator-search-popover.is-closing { + animation: none; + } +} diff --git a/addon/styles/components/table.css b/addon/styles/components/table.css index 5d8e4577..df7dc300 100644 --- a/addon/styles/components/table.css +++ b/addon/styles/components/table.css @@ -68,6 +68,139 @@ @apply border-r-0; } +.next-table-empty-state-cell { + padding: 0 !important; +} + +.next-table-empty-state { + @apply flex flex-col items-center justify-center px-4 py-8 mx-auto text-center; + max-width: 34rem; + min-height: 16rem; +} + +.next-table-empty-state-compact { + max-width: 22rem; + min-height: 9rem; + padding: 1.25rem 0.75rem; +} + +.next-table-empty-state-icon { + @apply flex items-center justify-center text-blue-600 bg-blue-50 border border-blue-200 rounded-md; + width: 2.75rem; + height: 2.75rem; + margin-bottom: 1rem; + font-size: 1rem; +} + +.next-table-empty-state-compact .next-table-empty-state-icon { + width: 2.25rem; + height: 2.25rem; + font-size: 0.875rem; +} + +.next-table-empty-state-content { + width: 100%; + max-width: 30rem; +} + +.next-table-empty-state-title { + @apply m-0 text-gray-900; + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.375rem; +} + +.next-table-empty-state-compact .next-table-empty-state-title { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.next-table-empty-state-description { + @apply mx-auto mt-2 text-sm text-gray-500 whitespace-normal; + max-width: 28rem; + line-height: 1.375rem; + text-align: center; + overflow-wrap: anywhere; +} + +.next-table-empty-state-compact .next-table-empty-state-description { + font-size: 0.75rem; + line-height: 1.125rem; +} + +.next-table-empty-state-note { + @apply mx-auto mt-2 text-xs text-gray-400 whitespace-normal; + max-width: 28rem; + text-align: center; + overflow-wrap: anywhere; +} + +.next-table-empty-state-actions { + @apply flex flex-wrap items-center justify-center gap-2; + margin-top: 1.25rem; +} + +.next-table-empty-state-compact .next-table-empty-state-actions { + gap: 0.5rem; + margin-top: 1rem; +} + +.next-table-empty-state-action { + @apply px-3 py-1.5 mt-4 text-sm font-medium text-white bg-blue-600 rounded-md; +} + +.next-table-empty-state-action:hover { + @apply bg-blue-700; +} + +.next-table-wrapper table tbody tr td .next-table-empty-state span.btn-wrapper > button.btn { + height: auto; + min-height: unset; + cursor: pointer; +} + +.next-table-empty-state-docs { + margin-top: 0.875rem; +} + +.next-table-empty-state-docs-action { + @apply inline-flex items-center justify-center gap-2 px-2 py-1 text-sm font-medium text-blue-600 bg-transparent border border-transparent rounded-md; + line-height: 1.25rem; + cursor: pointer; +} + +.next-table-empty-state-docs-action:hover { + @apply text-blue-700 bg-blue-50 border-blue-200; +} + +[data-theme='dark'] .next-table-empty-state-icon { + color: #93c5fd; + background: rgb(30 58 138 / 30%); + border-color: #1e40af; +} + +[data-theme='dark'] .next-table-empty-state-title { + color: #f9fafb; +} + +[data-theme='dark'] .next-table-empty-state-description { + color: #9ca3af; +} + +[data-theme='dark'] .next-table-empty-state-note { + color: #6b7280; +} + +[data-theme='dark'] .next-table-empty-state-docs-action { + color: #bfdbfe; +} + +[data-theme='dark'] .next-table-empty-state-docs-action:hover { + color: #dbeafe; + background: rgb(30 58 138 / 32%); + border-color: #1e40af; +} + [data-theme='light'] .table-wrapper { @apply shadow-pop-least; } diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index a20e76f7..bbb9b482 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -25,6 +25,10 @@ body.virtual { overflow-y: scroll; } +body.next-sidebar-is-resizing { + overflow-x: hidden; +} + .next-content-overlay .new-order-overlay-body { @apply px-4 pt-4 space-y-4; } @@ -997,6 +1001,10 @@ body[data-theme='dark'] .next-map-container-view-switch > button:active { overflow: hidden; } +.next-view-container.sidebar-is-resizing { + overflow-x: hidden; +} + .next-view-section-container { display: flex; flex: 1 1 auto; @@ -1369,12 +1377,15 @@ body[data-theme='dark'] .next-view-header .next-view-header-right .next-user-but } .next-sidebar { - height: 100vh; + height: 100%; pointer-events: auto; display: flex; flex-direction: row; --sidebar-transition-width: 220px; - min-width: 220px; + --sidebar-drawer-width: 220px; + --sidebar-collapse-offset: 0px; + --sidebar-collapse-progress: 0; + min-width: 0; max-width: 330px; width: 220px; flex-shrink: 0; @@ -1390,6 +1401,26 @@ body[data-theme='dark'] .next-view-header .next-view-header-right .next-user-but transform 500ms; } +nav.next-sidebar { + overflow: hidden; +} + +.next-sidebar.sidebar-is-resizing { + transition: none; +} + +.next-sidebar::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background-color: rgb(229 231 235); + pointer-events: none; + z-index: 2; +} + .next-sidebar.sidebar-hide { margin-right: calc(-1 * var(--sidebar-transition-width)); transform: translateX(-100%); @@ -1410,13 +1441,27 @@ body[data-theme='dark'] .next-view-header .next-view-header-right .next-user-but .next-sidebar > .next-sidebar-content { display: flex; flex-direction: column; - flex: 1; + flex: 1 1 auto; height: 100%; width: 100%; + min-width: 0; + overflow: hidden; } .next-sidebar > .next-sidebar-content > .next-sidebar-content-inner { @apply px-2 pt-3; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + opacity: calc(1 - var(--sidebar-collapse-progress)); +} + +.next-sidebar > .next-sidebar-content > .next-sidebar-footer { + @apply px-2 py-3 border-t border-gray-200; + flex: 0 0 auto; + min-width: 0; + opacity: calc(1 - var(--sidebar-collapse-progress)); } .next-sidebar.sidebar-hide > .next-sidebar-content { @@ -1430,17 +1475,56 @@ body[data-theme='dark'] .next-view-header .next-view-header-right .next-user-but } .next-content-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel + .gutter, -.next-sidebar > .next-sidebar-content + .gutter, .next-sidebar > .next-sidebar-content + .gutter { width: 10px; margin-left: -5px; background-image: none; - background-color: inherit; + background-color: transparent; cursor: col-resize; - border-right: 2px transparent solid; + border: 0; + box-shadow: none; transition: opacity 500ms; } +.next-sidebar > .next-sidebar-content + .gutter { + position: absolute; + top: 0; + right: 0; + bottom: 0; + margin-left: 0; + padding: 0; + background: transparent; + background-image: none; + border: 0; + box-shadow: none; + overflow: visible; + z-index: 3; +} + +.next-sidebar > .next-sidebar-content + .gutter::before { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2px; + background-color: transparent; + pointer-events: none; +} + +.next-sidebar.sidebar-resizing-to-collapse > .next-sidebar-content { + flex: 0 0 var(--sidebar-drawer-width); + width: var(--sidebar-drawer-width); + min-width: var(--sidebar-drawer-width); + max-width: var(--sidebar-drawer-width); + transform: translateX(var(--sidebar-collapse-offset)); + pointer-events: none; +} + +.next-sidebar.sidebar-is-resizing > .next-sidebar-content { + transition: none; +} + .next-sidebar.sidebar-hide > .gutter { opacity: 0; pointer-events: none; @@ -1455,11 +1539,15 @@ body[data-theme='dark'] .next-view-header .next-view-header-right .next-user-but margin-left: -10px; } -.next-content-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel + .gutter:hover, -.next-sidebar > .next-sidebar-content + .gutter:hover { +.next-content-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel + .gutter:hover { border-right: 2px #76a9fa solid; } +.next-sidebar > .next-sidebar-content + .gutter:hover::before, +.next-sidebar.sidebar-is-resizing > .next-sidebar-content + .gutter::before { + background-color: #76a9fa; +} + .next-sidebar .next-nav-item { @apply text-sm flex flex-row items-center px-3 py-0.5 rounded-md mb-1; } @@ -2332,7 +2420,19 @@ body[data-theme='dark'] .next-table-wrapper table tbody tr:hover td a { } body[data-theme='dark'] .next-sidebar { - @apply bg-gray-900 border-r border-gray-700; + @apply bg-gray-900; +} + +body[data-theme='dark'] .next-sidebar > .next-sidebar-content > .next-sidebar-footer { + @apply border-gray-700; +} + +body[data-theme='dark'] .next-sidebar::after { + @apply bg-gray-700; +} + +body[data-theme='light'] .next-sidebar::after { + @apply bg-gray-200; } body[data-theme='dark'] .next-sidebar .next-sidebar-panel-container > .next-sidebar-panel > .next-sidebar-panel-toggle.next-content-panel-is-open .title { @@ -2357,7 +2457,7 @@ body[data-theme='dark'] .next-view-section-subheader { } body[data-theme='light'] .next-sidebar { - @apply bg-gray-50 border-r border-gray-200; + @apply bg-gray-50; } body[data-theme='light'] .next-sidebar .next-nav-item:hover .next-nav-item-icon-container svg { diff --git a/app/components/fleetbase-attribution.js b/app/components/fleetbase-attribution.js new file mode 100644 index 00000000..41329d0a --- /dev/null +++ b/app/components/fleetbase-attribution.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/fleetbase-attribution'; diff --git a/app/components/layout/sidebar/navigator.js b/app/components/layout/sidebar/navigator.js new file mode 100644 index 00000000..ae658d2f --- /dev/null +++ b/app/components/layout/sidebar/navigator.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/layout/sidebar/navigator'; diff --git a/app/components/modals/fleetbase-legal-notice.js b/app/components/modals/fleetbase-legal-notice.js new file mode 100644 index 00000000..f1ff10fa --- /dev/null +++ b/app/components/modals/fleetbase-legal-notice.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/modals/fleetbase-legal-notice'; diff --git a/app/components/table/empty-state.js b/app/components/table/empty-state.js new file mode 100644 index 00000000..a75d4922 --- /dev/null +++ b/app/components/table/empty-state.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/empty-state'; diff --git a/app/services/sidebar-navigator.js b/app/services/sidebar-navigator.js new file mode 100644 index 00000000..b6253c7c --- /dev/null +++ b/app/services/sidebar-navigator.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/services/sidebar-navigator'; diff --git a/app/services/table-context.js b/app/services/table-context.js new file mode 100644 index 00000000..c95afcad --- /dev/null +++ b/app/services/table-context.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/services/table-context'; diff --git a/package.json b/package.json index 039756ea..866d583a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.3.33", + "version": "0.3.34", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", diff --git a/tests/integration/components/activity-log-test.js b/tests/integration/components/activity-log-test.js index eb79e18e..7e1cfd24 100644 --- a/tests/integration/components/activity-log-test.js +++ b/tests/integration/components/activity-log-test.js @@ -1,26 +1,246 @@ +import { helper } from '@ember/component/helper'; +import Service from '@ember/service'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | activity-log', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.activities = []; - await render(hbs``); + const activities = this.activities; + const translations = { + 'common.activity': 'Activity', + 'common.date': 'Date', + 'common.filter-by-field': 'Filter by Date', + 'common.refresh': 'Refresh', + }; - assert.dom().hasText(''); + class StoreStub extends Service { + query() { + return Promise.resolve({ + toArray() { + return activities; + }, + }); + } + } - // Template block usage: + this.owner.register('service:store', StoreStub); + this.owner.register( + 'helper:t', + helper(([key]) => translations[key] ?? key) + ); + }); + + test('it renders the empty state', async function (assert) { + await render(hbs``); + + assert.dom('.activity-log-title').hasText('Activity'); + assert.dom('.activity-log-empty').includesText('No activity yet'); + }); + + test('it renders a multi-attribute change row with relative timestamp', async function (assert) { + this.activities.push( + activity({ + event: 'updated', + causer: { name: 'Shiv Thakker' }, + subject_type: 'Fleetbase\\Models\\User', + properties: { + old: { status: 'pending', email: 'old@example.com' }, + attributes: { status: 'active', email: 'new@example.com' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-item').exists({ count: 1 }); + assert.dom('.activity-log-sentence').includesText('Shiv Thakker changed 2 attributes'); + assert.dom('.activity-log-time').hasAttribute('datetime', '2026-06-01T12:00:00Z'); + assert.dom('.activity-log-time').includesText('ago'); + }); + + test('it keeps subject scoped multi-attribute rows compact', async function (assert) { + this.activities.push( + activity({ + event: 'updated', + subject: { name: 'Ron' }, + subject_type: 'Fleetbase\\Models\\Driver', + properties: { + old: { status: 'offline', phone: '555-0000' }, + attributes: { status: 'online', phone: '555-1111' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-sentence').includesText('Shiv Thakker changed 2 attributes'); + assert.dom('.activity-log-sentence').doesNotIncludeText('driver (Ron)'); + }); + + test('it shows subject context for company scoped multi-attribute rows', async function (assert) { + this.activities.push( + activity({ + event: 'updated', + subject: { name: 'Production Key' }, + subject_type: 'Fleetbase\\Models\\ApiKey', + properties: { + old: { name: 'Old Key', status: 'inactive', description: 'Old' }, + attributes: { name: 'Production Key', status: 'active', description: 'Current' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-sentence').includesText('Shiv Thakker updated an api key (Production Key) with 3 attributes'); + }); + + test('it shows subject context for causer scoped multi-attribute rows', async function (assert) { + this.activities.push( + activity({ + event: 'created', + subject: {}, + subject_type: 'Fleetbase\\Models\\ApiKey', + properties: { + attributes: { name: 'Production Key', status: 'active', description: 'Current', token: 'secret' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-sentence').includesText('Shiv Thakker created a new api key with 4 attributes'); + }); + + test('it can suppress subject context with an explicit override', async function (assert) { + this.activities.push( + activity({ + event: 'updated', + subject: { name: 'Production Key' }, + subject_type: 'Fleetbase\\Models\\ApiKey', + properties: { + old: { status: 'inactive', description: 'Old' }, + attributes: { status: 'active', description: 'Current' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-sentence').includesText('Shiv Thakker changed 2 attributes'); + assert.dom('.activity-log-sentence').doesNotIncludeText('api key'); + }); + + test('it can force subject context with an explicit override', async function (assert) { + this.activities.push( + activity({ + event: 'updated', + subject: { name: 'Production Key' }, + subject_type: 'Fleetbase\\Models\\ApiKey', + properties: { + old: { status: 'inactive', description: 'Old' }, + attributes: { status: 'active', description: 'Current' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-sentence').includesText('Shiv Thakker updated an api key (Production Key) with 2 attributes'); + }); + + test('it shows changed attributes with previous and new values in a hover popover', async function (assert) { + this.activities.push( + activity({ + properties: { + old: { status: 'pending', email: 'old@example.com' }, + attributes: { status: 'active', email: 'new@example.com' }, + }, + }) + ); + + await render(hbs``); + await triggerEvent('.activity-log-change-trigger', 'mouseenter'); + + assert.dom('.activity-log-changes-popover').exists(); + assert.dom('.activity-log-changes-popover').includesText('Attribute'); + assert.dom('.activity-log-changes-popover').includesText('Previous value'); + assert.dom('.activity-log-changes-popover').includesText('New value'); + assert.dom('.activity-log-changes-popover').includesText('Status'); + assert.dom('.activity-log-changes-popover').includesText('pending'); + assert.dom('.activity-log-changes-popover').includesText('active'); + }); + + test('it renders a single attribute change inline', async function (assert) { + this.activities.push( + activity({ + properties: { + old: { status: 'pending' }, + attributes: { status: 'active' }, + }, + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-inline-change').includesText('Status from pending to active'); + assert.dom('.activity-log-sentence').includesText('on User'); + }); + + test('it renders created and deleted rows with object labels', async function (assert) { + this.activities.push( + activity({ + event: 'created', + created_at: '2026-06-02T12:00:00Z', + subject: { name: 'User' }, + }), + activity({ + event: 'deleted', + created_at: '2026-06-01T12:00:00Z', + subject: { name: 'Order' }, + subject_type: 'Fleetbase\\Models\\Order', + }) + ); + + await render(hbs``); + + assert.dom('.activity-log-item').exists({ count: 2 }); + assert.dom('.activity-log-list').includesText('Shiv Thakker created User'); + assert.dom('.activity-log-list').includesText('Shiv Thakker deleted Order'); + }); + + test('it preserves named slots and the default block', async function (assert) { await render(hbs` - - template block text - - `); + + <:viewAll>View all + <:filters>Custom filter + <:default>Default content + + `); - assert.dom().hasText('template block text'); + assert.dom('a').hasText('View all'); + assert.dom('[data-test-filter]').doesNotExist('filters are hidden when controls are disabled'); + assert.dom('[data-test-default]').hasText('Default content'); }); }); + +function activity(options = {}) { + return { + event: 'updated', + created_at: '2026-06-01T12:00:00Z', + causer: { name: 'Shiv Thakker' }, + subject: { name: 'User' }, + subject_type: 'Fleetbase\\Models\\User', + properties: { + old: {}, + attributes: {}, + }, + ...options, + }; +} diff --git a/tests/integration/components/date-time-input-test.js b/tests/integration/components/date-time-input-test.js index a801d152..c93a0013 100644 --- a/tests/integration/components/date-time-input-test.js +++ b/tests/integration/components/date-time-input-test.js @@ -1,26 +1,71 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { fillIn, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | date-time-input', function (hooks) { setupRenderingTest(hooks); test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - await render(hbs``); assert.dom(this.element).hasText(''); + }); + + test('it renders a date instance value', async function (assert) { + this.set('value', new Date(2026, 5, 18, 18, 47)); + + await render(hbs``); + + assert.dom('[aria-label="Date Input"]').hasValue('2026-06-18'); + assert.dom('[aria-label="Time Input"]').hasValue('18:47'); + }); + + test('it renders a persisted date-time string value', async function (assert) { + this.set('value', '2026-06-18 18:47'); + + await render(hbs``); + + assert.dom('[aria-label="Date Input"]').hasValue('2026-06-18'); + assert.dom('[aria-label="Time Input"]').hasValue('18:47'); + }); + + test('it renders empty inputs for an invalid date-time string value', async function (assert) { + this.set('value', 'not a date'); + + await render(hbs``); + + assert.dom('[aria-label="Date Input"]').hasValue(''); + assert.dom('[aria-label="Time Input"]').hasValue(''); + }); + + test('it calls onUpdate with a date instance and formatted date-time string', async function (assert) { + assert.expect(4); + + this.set('value', '2026-06-18 18:47'); + this.set('onUpdate', (dateTimeInstance, dateTime) => { + assert.true(dateTimeInstance instanceof Date); + assert.strictEqual(dateTimeInstance.getFullYear(), 2026); + assert.strictEqual(dateTimeInstance.getMonth(), 5); + assert.strictEqual(dateTime, '2026-06-19 18:47'); + }); + + await render(hbs``); + await fillIn('[aria-label="Date Input"]', '2026-06-19'); + }); + + test('it calls onUpdate when the time input changes', async function (assert) { + assert.expect(4); - // Template block usage: - await render(hbs` - - template block text - - `); + this.set('value', '2026-06-18 18:47'); + this.set('onUpdate', (dateTimeInstance, dateTime) => { + assert.true(dateTimeInstance instanceof Date); + assert.strictEqual(dateTimeInstance.getFullYear(), 2026); + assert.strictEqual(dateTimeInstance.getMonth(), 5); + assert.strictEqual(dateTime, '2026-06-18 19:12'); + }); - assert.dom(this.element).hasText('template block text'); + await render(hbs``); + await fillIn('[aria-label="Time Input"]', '19:12'); }); }); diff --git a/tests/integration/components/docs-panel-test.js b/tests/integration/components/docs-panel-test.js index f079fdf7..463e4db7 100644 --- a/tests/integration/components/docs-panel-test.js +++ b/tests/integration/components/docs-panel-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, waitFor } from '@ember/test-helpers'; +import { render, triggerEvent, waitFor } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | docs-panel', function (hooks) { @@ -18,12 +18,29 @@ module('Integration | Component | docs-panel', function (hooks) { test('it renders official docs in a right overlay', async function (assert) { const docsPanel = this.owner.lookup('service:docs-panel'); - docsPanel.open('https://www.fleetbase.io/docs/fleet-ops/orders', { title: 'Orders docs' }); + docsPanel.open('https://www.fleetbase.io/docs/fleet-ops/orders', { title: 'Orders docs', theme: 'light' }); await render(hbs``); await waitFor('.fleetbase-docs-panel-overlay'); assert.dom('.fleetbase-docs-panel-overlay').exists(); - assert.dom('.fleetbase-docs-panel-overlay iframe').hasAttribute('src', 'https://www.fleetbase.io/docs/fleet-ops/orders'); + assert.dom('.fleetbase-docs-panel-overlay iframe').hasAttribute('src', 'https://www.fleetbase.io/docs/fleet-ops/orders?embed=console&theme=light'); + assert.dom('.fleetbase-docs-panel-loading').exists(); + + await triggerEvent('.fleetbase-docs-panel-overlay iframe', 'load'); + + assert.dom('.fleetbase-docs-panel-loading').doesNotExist(); + }); + + test('it renders fallback without a loading overlay for external urls', async function (assert) { + const docsPanel = this.owner.lookup('service:docs-panel'); + docsPanel.open('https://example.com/help', { title: 'External docs' }); + + await render(hbs``); + await waitFor('.fleetbase-docs-panel-overlay'); + + assert.dom('.fleetbase-docs-panel-loading').doesNotExist(); + assert.dom('.fleetbase-docs-panel-overlay iframe').doesNotExist(); + assert.dom('.fleetbase-docs-panel-overlay').includesText('This page cannot be embedded here'); }); }); diff --git a/tests/integration/components/floating-test.js b/tests/integration/components/floating-test.js index 6ce6a8ec..243ceeb3 100644 --- a/tests/integration/components/floating-test.js +++ b/tests/integration/components/floating-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | floating', function (hooks) { @@ -23,4 +23,40 @@ module('Integration | Component | floating', function (hooks) { assert.dom(this.element).hasText('template block text'); }); + + test('offset does not accumulate when position is recomputed', async function (assert) { + this.registerAPI = (api) => { + this.api = api; + }; + + await render(hbs` +
Target
+ + Panel + + `); + + await waitUntil(() => this.api?.floatingElement?.style.transform); + const initialTransform = this.api.floatingElement.style.transform; + + this.api.computePosition(this.api.floatingTarget, this.api.floatingElement); + await waitForFrame(); + + this.api.computePosition(this.api.floatingTarget, this.api.floatingElement); + await waitForFrame(); + + assert.strictEqual(this.api.floatingElement.style.transform, initialTransform, 'transform remains stable across repeated position calculations'); + }); }); + +function waitForFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); +} diff --git a/tests/integration/components/layout/sidebar-test.js b/tests/integration/components/layout/sidebar-test.js index 4ddca23d..237d453b 100644 --- a/tests/integration/components/layout/sidebar-test.js +++ b/tests/integration/components/layout/sidebar-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, settled } from '@ember/test-helpers'; +import { render, settled, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | layout/sidebar', function (hooks) { @@ -10,6 +10,31 @@ module('Integration | Component | layout/sidebar', function (hooks) { this.sidebarService = this.owner.lookup('service:sidebar'); }); + function useInlineSidebarWidth(sidebar) { + sidebar.getBoundingClientRect = () => { + return { + width: Number.parseFloat(sidebar.style.width) || 220, + }; + }; + } + + async function waitForResizeFrame() { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + + function dispatchMouseup(clientX) { + document.dispatchEvent(new MouseEvent('mouseup', { clientX, bubbles: true })); + } + + function renderSidebarInViewContainer() { + return render(hbs` +
+ +
+
+ `); + } + test('it registers with the sidebar service and initializes visible state', async function (assert) { await render(hbs``); @@ -19,6 +44,259 @@ module('Integration | Component | layout/sidebar', function (hooks) { assert.dom('nav.next-sidebar').doesNotHaveClass('sidebar-hidden'); }); + test('it yields contextual sidebar components', async function (assert) { + this.set('items', [{ label: 'Orders', icon: 'box' }]); + + await render(hbs` + + + + `); + + assert.dom('.next-sidebar-navigator').exists(); + assert.dom('.next-sidebar-navigator-item').hasText('Orders'); + }); + + test('it keeps the resize gutter overlaid on the sidebar edge', async function (assert) { + await render(hbs``); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const content = this.element.querySelector('.next-sidebar-content'); + const contentInner = this.element.querySelector('.next-sidebar-content-inner'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + const sidebarStyles = window.getComputedStyle(sidebar); + const contentInnerStyles = window.getComputedStyle(contentInner); + const gutterStyles = window.getComputedStyle(gutter); + const gutterIndicatorStyles = window.getComputedStyle(gutter, '::before'); + + assert.strictEqual(sidebarStyles.overflowY, 'hidden', 'outer sidebar does not own vertical scrolling'); + assert.strictEqual(contentInnerStyles.overflowY, 'auto', 'inner content owns vertical scrolling'); + assert.strictEqual(gutterStyles.position, 'absolute', 'gutter overlays instead of consuming flex width'); + assert.strictEqual(gutterStyles.right, '0px', 'gutter sits on the outside resize edge'); + assert.strictEqual(gutterStyles.backgroundColor, 'rgba(0, 0, 0, 0)', 'gutter hit area is visually transparent'); + assert.strictEqual(gutterStyles.backgroundImage, 'none', 'gutter clears the global splitter background image'); + assert.strictEqual(gutterStyles.borderRightWidth, '0px', 'gutter does not create a wide visual border'); + assert.strictEqual(gutterStyles.boxShadow, 'none', 'gutter does not create a visual lane'); + assert.strictEqual(gutterIndicatorStyles.width, '2px', 'gutter indicator is a narrow hover edge'); + assert.strictEqual(gutterIndicatorStyles.backgroundColor, 'rgba(0, 0, 0, 0)', 'gutter indicator is hidden until hover or resize'); + assert.ok(content.getBoundingClientRect().width <= sidebar.getBoundingClientRect().width, 'content remains within the sidebar shell'); + }); + + test('it uses the light theme sidebar edge color', async function (assert) { + const originalTheme = document.body.dataset.theme; + document.body.dataset.theme = 'light'; + + await render(hbs``); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const edgeStyles = window.getComputedStyle(sidebar, '::after'); + + assert.strictEqual(edgeStyles.width, '1px'); + assert.strictEqual(edgeStyles.backgroundColor, 'rgb(229, 231, 235)'); + + if (originalTheme) { + document.body.dataset.theme = originalTheme; + } else { + delete document.body.dataset.theme; + } + }); + + test('it uses the dark theme sidebar edge color', async function (assert) { + const originalTheme = document.body.dataset.theme; + document.body.dataset.theme = 'dark'; + + await render(hbs``); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const edgeStyles = window.getComputedStyle(sidebar, '::after'); + + assert.strictEqual(edgeStyles.width, '1px'); + assert.strictEqual(edgeStyles.backgroundColor, 'rgb(55, 65, 81)'); + + if (originalTheme) { + document.body.dataset.theme = originalTheme; + } else { + delete document.body.dataset.theme; + } + }); + + test('it shrinks the shell while pushing the minimum-width drawer during collapse drag', async function (assert) { + await renderSidebarInViewContainer(); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const viewContainer = this.element.querySelector('.next-view-container'); + const content = this.element.querySelector('.next-sidebar-content'); + const contentInner = this.element.querySelector('.next-sidebar-content-inner'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '220px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 220 }); + await triggerEvent(document, 'mousemove', { clientX: 140 }); + await waitForResizeFrame(); + + assert.dom('nav.next-sidebar').hasClass('sidebar-is-resizing'); + assert.dom(viewContainer).hasClass('sidebar-is-resizing'); + assert.dom(document.body).hasClass('next-sidebar-is-resizing'); + assert.dom('nav.next-sidebar').hasClass('sidebar-resizing-to-collapse'); + assert.strictEqual(sidebar.style.width, '140px', 'shell slot keeps tracking below the release threshold'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-drawer-width'), '200px', 'drawer keeps the minimum readable width'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '-60px', 'drawer is pushed inside the shrinking shell'); + assert.ok(Number(sidebar.style.getPropertyValue('--sidebar-collapse-progress')) < 1, 'collapse progress does not reach full fade at the release threshold'); + assert.strictEqual(window.getComputedStyle(sidebar).transitionDuration, '0s', 'active resize does not lag behind cursor movement'); + assert.strictEqual(window.getComputedStyle(sidebar).transform, 'none', 'collapse transform is not applied to the shell'); + assert.notStrictEqual(window.getComputedStyle(content).transform, 'none', 'collapse transform is applied to the drawer'); + assert.ok(Number(window.getComputedStyle(contentInner).opacity) > 0, 'content remains visible while shell is wider than 50px'); + + dispatchMouseup(140); + + assert.true(this.sidebarService.isHidden); + assert.dom('nav.next-sidebar').doesNotHaveClass('sidebar-is-resizing'); + assert.dom(viewContainer).doesNotHaveClass('sidebar-is-resizing'); + assert.dom(document.body).doesNotHaveClass('next-sidebar-is-resizing'); + assert.dom('nav.next-sidebar').hasClass('sidebar-hide'); + assert.strictEqual(sidebar.style.width, '140px', 'width is not restored before the hide transition starts'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '-60px', 'collapse offset remains during the hide transition'); + + await settled(); + + assert.dom('nav.next-sidebar').hasClass('sidebar-hidden'); + assert.strictEqual(sidebar.style.width, '220px', 'comfortable width is restored only after the sidebar is hidden'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '', 'collapse state is cleared after hidden width restore'); + }); + + test('it keeps a one pixel active rail when collapse drag overshoots past zero', async function (assert) { + await renderSidebarInViewContainer(); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const viewContainer = this.element.querySelector('.next-view-container'); + const content = this.element.querySelector('.next-sidebar-content'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '220px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 220 }); + await triggerEvent(document, 'mousemove', { clientX: -20 }); + await waitForResizeFrame(); + + assert.strictEqual(sidebar.style.width, '1px', 'shell keeps a stable active rail while the cursor overshoots'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '-199px', 'drawer offset clamps to the active rail'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-progress'), '1'); + assert.notStrictEqual(window.getComputedStyle(content).transform, 'none'); + assert.dom(viewContainer).hasClass('sidebar-is-resizing'); + + await triggerEvent(document, 'mouseup', { clientX: -20 }); + + assert.true(this.sidebarService.isHidden); + assert.dom(viewContainer).doesNotHaveClass('sidebar-is-resizing'); + assert.dom(document.body).doesNotHaveClass('next-sidebar-is-resizing'); + }); + + test('it prevents horizontal document autoscroll during resize overshoot', async function (assert) { + await renderSidebarInViewContainer(); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const viewContainer = this.element.querySelector('.next-view-container'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '220px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 220 }); + + document.documentElement.scrollLeft = 40; + document.body.scrollLeft = 30; + viewContainer.scrollLeft = 20; + + await triggerEvent(document, 'mousemove', { clientX: -80 }); + await waitForResizeFrame(); + + assert.strictEqual(document.documentElement.scrollLeft, 0, 'document element horizontal scroll is reset during drag'); + assert.strictEqual(document.body.scrollLeft, 0, 'body horizontal scroll is reset during drag'); + assert.strictEqual(viewContainer.scrollLeft, 0, 'view container horizontal scroll is reset during drag'); + assert.dom(document.body).hasClass('next-sidebar-is-resizing'); + + await triggerEvent(document, 'mouseup', { clientX: -80 }); + + assert.dom(document.body).doesNotHaveClass('next-sidebar-is-resizing'); + }); + + test('it reaches full fade near 50px of visible shell width', async function (assert) { + await render(hbs``); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const contentInner = this.element.querySelector('.next-sidebar-content-inner'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '220px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 220 }); + await triggerEvent(document, 'mousemove', { clientX: 50 }); + await waitForResizeFrame(); + + assert.strictEqual(sidebar.style.width, '50px', 'shell slot keeps tracking to the fade endpoint'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '-150px'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-progress'), '1'); + assert.strictEqual(window.getComputedStyle(contentInner).opacity, '0'); + + await triggerEvent(document, 'mouseup', { clientX: 50 }); + + assert.true(this.sidebarService.isHidden); + }); + + test('it restores the minimum visible width when released during push-out above the collapse threshold', async function (assert) { + await renderSidebarInViewContainer(); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const viewContainer = this.element.querySelector('.next-view-container'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '220px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 220 }); + await triggerEvent(document, 'mousemove', { clientX: 180 }); + await waitForResizeFrame(); + + assert.strictEqual(sidebar.style.width, '180px', 'shell slot follows the drag during push-out'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-drawer-width'), '200px'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), '-20px'); + + await triggerEvent(document, 'mouseup', { clientX: 180 }); + + assert.true(this.sidebarService.isVisible); + assert.dom(viewContainer).doesNotHaveClass('sidebar-is-resizing'); + assert.dom(document.body).doesNotHaveClass('next-sidebar-is-resizing'); + assert.dom('nav.next-sidebar').doesNotHaveClass('sidebar-hidden'); + assert.dom('nav.next-sidebar').doesNotHaveClass('sidebar-hide'); + assert.dom('nav.next-sidebar').doesNotHaveClass('sidebar-resizing-to-collapse'); + assert.strictEqual(sidebar.style.width, '200px'); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-drawer-width'), ''); + assert.strictEqual(sidebar.style.getPropertyValue('--sidebar-collapse-offset'), ''); + }); + + test('it restores the last comfortable width after resize collapse', async function (assert) { + await render(hbs``); + + const sidebar = this.element.querySelector('nav.next-sidebar'); + const gutter = this.element.querySelector('.next-sidebar-content + .gutter'); + + sidebar.style.width = '240px'; + useInlineSidebarWidth(sidebar); + + await triggerEvent(gutter, 'mousedown', { clientX: 240 }); + await triggerEvent(document, 'mousemove', { clientX: 120 }); + await triggerEvent(document, 'mouseup', { clientX: 120 }); + + this.sidebarService.show(); + + assert.strictEqual(sidebar.style.width, '240px'); + assert.true(this.sidebarService.isVisible); + }); + test('it initializes hidden state when rendered with @hide', async function (assert) { await render(hbs``); diff --git a/tests/integration/components/layout/sidebar/navigator-test.js b/tests/integration/components/layout/sidebar/navigator-test.js new file mode 100644 index 00000000..625c2a8e --- /dev/null +++ b/tests/integration/components/layout/sidebar/navigator-test.js @@ -0,0 +1,308 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { click, fillIn, render, settled, triggerEvent, triggerKeyEvent, waitFor } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | layout/sidebar/navigator', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.wormholeRoot = document.getElementById('application-root-wormhole'); + + if (!this.wormholeRoot) { + this.wormholeRoot = document.createElement('div'); + this.wormholeRoot.id = 'application-root-wormhole'; + document.body.appendChild(this.wormholeRoot); + this.createdWormholeRoot = true; + } + + this.set('items', [ + { + label: 'Orders', + description: 'Dispatch and fulfillment work.', + icon: 'box', + keywords: ['dispatch'], + onClick: () => this.set('selected', 'orders'), + }, + { + label: 'Settings', + description: 'Workspace configuration.', + tooltip: true, + icon: 'gear', + children: [ + { + label: 'Service Rates', + description: 'Pricing rules for operations.', + showDescription: true, + tooltip: 'Manage service pricing', + icon: 'file-invoice-dollar', + keywords: ['pricing'], + onClick: () => this.set('selected', 'service-rates'), + }, + ], + }, + ]); + }); + + hooks.afterEach(function () { + this.wormholeRoot?.querySelectorAll('.next-sidebar-navigator-search-portal').forEach((element) => element.remove()); + + if (this.createdWormholeRoot) { + this.wormholeRoot.remove(); + } + }); + + test('it transitions between root and nested menus with directional classes', async function (assert) { + await render(hbs``); + + assert.dom('.next-sidebar-navigator-item').exists({ count: 2 }); + assert.dom('.next-sidebar-navigator').includesText('Settings'); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + assert.dom('.next-sidebar-navigator-viewport').hasClass('is-transitioning'); + assert.dom('.next-sidebar-navigator-viewport').hasClass('is-forward'); + assert.dom('.next-sidebar-navigator-view-out').includesText('Orders'); + assert.dom('.next-sidebar-navigator-back').includesText('Settings'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').includesText('Service Rates'); + assert.dom('.next-sidebar-navigator-back').hasClass('next-sidebar-navigator-back'); + + await click('.next-sidebar-navigator-back'); + + assert.dom('.next-sidebar-navigator-viewport').hasClass('is-back'); + assert.dom('.next-sidebar-navigator-view-in').includesText('Orders'); + }); + + test('it renders compact rows and only shows descriptions when requested', async function (assert) { + await render(hbs``); + + assert.dom('.next-sidebar-navigator-item:first-of-type').doesNotHaveClass('has-description'); + assert.dom('.next-sidebar-navigator-item:first-of-type .next-sidebar-navigator-item-description').doesNotExist(); + assert.dom('.next-sidebar-navigator-item:nth-of-type(2)').hasAttribute('title', 'Workspace configuration.'); + assert.dom('.next-sidebar-navigator-item:nth-of-type(2) .next-sidebar-navigator-item-caret').exists(); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').hasClass('has-description'); + assert.dom('.next-sidebar-navigator-item-description').hasText('Pricing rules for operations.'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').hasAttribute('title', 'Manage service pricing'); + }); + + test('it updates an open nested menu when item children change', async function (assert) { + await render(hbs``); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + assert.dom('.next-sidebar-navigator-back').includesText('Settings'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').includesText('Service Rates'); + + this.set('items', [ + { + label: 'Orders', + icon: 'box', + onClick: () => this.set('selected', 'orders'), + }, + { + label: 'Settings', + icon: 'gear', + children: [ + { + label: 'Notifications', + icon: 'bell', + onClick: () => this.set('selected', 'notifications'), + }, + ], + }, + ]); + await settled(); + + assert.dom('.next-sidebar-navigator-back').includesText('Settings'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').includesText('Notifications'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').doesNotIncludeText('Service Rates'); + }); + + test('it morphs search into a portal command panel without replacing the menu body', async function (assert) { + await render(hbs``); + + await fillIn('.next-sidebar-navigator-search input', 'pricing'); + await waitFor('.next-sidebar-navigator-search-popover'); + + assert.dom('.next-sidebar-navigator-search-popover').exists(); + assert.dom('.next-sidebar-navigator-search').hasClass('is-morphing'); + assert.dom('.next-sidebar .next-sidebar-navigator-search-popover').doesNotExist(); + assert.dom('#application-root-wormhole .next-sidebar-navigator-search-portal').exists(); + assert.dom('#application-root-wormhole .next-sidebar-navigator-search-popover').exists(); + assert.dom('.next-sidebar-navigator-search-overlay').exists(); + assert.dom('.next-sidebar-navigator-search-popover').hasStyle({ position: 'fixed', width: '440px', zIndex: '900' }); + assert.dom('.next-sidebar-navigator-search-popover').hasStyle({ borderColor: 'rgb(209, 213, 219)' }); + assert.notStrictEqual(getComputedStyle(document.querySelector('.next-sidebar-navigator-search-popover')).boxShadow, 'none', 'popover keeps a reduced explicit shadow'); + assert.true( + document.querySelector('.next-sidebar-navigator-search-popover').classList.contains('is-opening') || + document.querySelector('.next-sidebar-navigator-search-popover').classList.contains('is-open'), + 'popover is in the opening/open state' + ); + assert.dom('.next-sidebar-navigator-search-popover-input input').hasValue('pricing'); + assert.dom('.next-sidebar-navigator-search-popover-input input').hasStyle({ appearance: 'none' }); + assert.dom('.next-sidebar-navigator-search-result .next-sidebar-navigator-search-result-label').hasText('Service Rates'); + assert.dom('.next-sidebar-navigator-search-result').includesText('Settings > Service Rates'); + assert.dom('.next-sidebar-navigator-view-in').includesText('Orders'); + + await click('.next-sidebar-navigator-search-result'); + + assert.strictEqual(this.selected, 'service-rates'); + }); + + test('it renders a morph-ready portal on focus', async function (assert) { + await render(hbs``); + + await click('.next-sidebar-navigator-search input'); + + assert.dom('.next-sidebar-navigator-search-popover').exists(); + assert.dom('.next-sidebar-navigator-search-overlay').exists(); + assert.true(document.querySelector('.next-sidebar-navigator-search-popover').getAttribute('style').includes('--search-source-scale:'), 'popover has morph source vars'); + }); + + test('it caps search results and syncs mouse and keyboard active states', async function (assert) { + this.set( + 'items', + Array.from({ length: 16 }, (_, index) => { + return { + label: `Result ${index + 1}`, + description: `Description ${index + 1}`, + icon: 'box', + keywords: ['bulk'], + onClick: () => this.set('selected', index + 1), + }; + }) + ); + + await render(hbs``); + await fillIn('.next-sidebar-navigator-search input', 'bulk'); + await waitFor('.next-sidebar-navigator-search-result'); + + assert.dom('.next-sidebar-navigator-search-result').exists({ count: 12 }); + assert.dom('.next-sidebar-navigator-search-result:first-of-type').hasClass('is-active'); + assert.dom('.next-sidebar-navigator-search-result').hasStyle({ alignItems: 'flex-start' }); + assert.dom('.next-sidebar-navigator-search-result-icon').hasStyle({ marginTop: '2px' }); + + await triggerKeyEvent('.next-sidebar-navigator-search-popover-input input', 'keydown', 'ArrowDown'); + + assert.dom('[data-search-result-index="1"]').hasClass('is-active'); + + await triggerEvent('[data-search-result-index="3"]', 'mouseenter'); + + assert.dom('[data-search-result-index="3"]').hasClass('is-active'); + + await triggerKeyEvent('.next-sidebar-navigator-search-popover-input input', 'keydown', 'Enter'); + + assert.strictEqual(this.selected, 4); + }); + + test('it renders compact search input styles', async function (assert) { + await render(hbs``); + + assert.dom('.next-sidebar-navigator-search').hasStyle({ + paddingTop: '8px', + paddingRight: '8px', + paddingBottom: '8px', + paddingLeft: '8px', + backgroundColor: 'rgb(249, 250, 251)', + }); + assert.dom('.next-sidebar-navigator-search input').hasStyle({ + paddingTop: '0px', + paddingBottom: '0px', + paddingLeft: '0px', + lineHeight: '16px', + fontSize: '12px', + appearance: 'none', + }); + }); + + test('it uses gray active styling for light mode', async function (assert) { + await render(hbs``); + + const item = this.element.querySelector('.next-sidebar-navigator-item:first-of-type'); + item.classList.add('is-active'); + + assert.dom(item).hasStyle({ backgroundColor: 'rgb(229, 231, 235)', color: 'rgb(17, 24, 39)' }); + assert.dom('.next-sidebar-navigator-item:first-of-type').hasStyle({ cursor: 'default' }); + }); + + test('it focuses search with the keyboard shortcut', async function (assert) { + await render(hbs``); + + await triggerKeyEvent(document, 'keydown', 'k', { metaKey: true }); + await waitFor('.next-sidebar-navigator-search-popover-input input'); + + assert.dom('.next-sidebar-navigator-search-popover-input input').isFocused(); + assert.dom('.next-sidebar-navigator-search-popover').exists(); + }); + + test('it renders provider search results', async function (assert) { + this.set('searchNavigation', ({ query, limit }) => { + assert.strictEqual(query, 'tyler'); + assert.strictEqual(limit, 12); + + return [ + { + label: 'Tyler Demo', + description: 'Console user', + icon: 'user', + type: 'User', + onClick: () => this.set('selected', 'tyler'), + }, + ]; + }); + + await render(hbs``); + + await fillIn('.next-sidebar-navigator-search input', 'tyler'); + await waitFor('.next-sidebar-navigator-search-result'); + + assert.dom('.next-sidebar-navigator-search-result').includesText('Tyler Demo'); + assert.dom('.next-sidebar-navigator-search-result').includesText('User'); + assert.dom('.next-sidebar-navigator-search-result').includesText('Console user'); + + await click('.next-sidebar-navigator-search-result'); + + assert.strictEqual(this.selected, 'tyler'); + }); + + test('it recovers when a provider throws synchronously', async function (assert) { + assert.expect(3); + + this.set('searchNavigation', () => { + throw new Error('provider failed before returning a promise'); + }); + + await render(hbs``); + + await fillIn('.next-sidebar-navigator-search input', 'tyler'); + + assert.dom('.next-sidebar-navigator-search-popover').exists(); + assert.dom('.next-sidebar-navigator-search-result').doesNotExist(); + assert.dom('.next-sidebar-navigator-search-status').includesText('No navigation results found.'); + }); + + test('it closes the command panel with escape', async function (assert) { + await render(hbs``); + + await fillIn('.next-sidebar-navigator-search input', 'pricing'); + await waitFor('.next-sidebar-navigator-search-popover'); + + await triggerKeyEvent('.next-sidebar-navigator-search-popover-input input', 'keydown', 'Escape'); + + assert.dom('.next-sidebar-navigator-search-popover').hasClass('is-closing'); + }); + + test('it closes the command panel from the page overlay', async function (assert) { + await render(hbs``); + + await fillIn('.next-sidebar-navigator-search input', 'pricing'); + await waitFor('.next-sidebar-navigator-search-overlay'); + + await click('.next-sidebar-navigator-search-overlay'); + + assert.dom('.next-sidebar-navigator-search-popover').hasClass('is-closing'); + }); +}); diff --git a/tests/integration/components/table-test.js b/tests/integration/components/table-test.js index 69372d00..c02c04d3 100644 --- a/tests/integration/components/table-test.js +++ b/tests/integration/components/table-test.js @@ -6,6 +6,11 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | table', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.set('columns', [{ label: 'Name', valuePath: 'name' }]); + this.set('rows', []); + }); + test('it renders', async function (assert) { // Set any properties with this.set('myProperty', 'value'); // Handle any actions with this.set('myAction', function(val) { ... }); @@ -23,4 +28,44 @@ module('Integration | Component | table', function (hooks) { assert.dom(this.element).hasText('template block text'); }); + + test('it renders fallback empty state content', async function (assert) { + await render(hbs` + + `); + + assert.dom('.next-table-empty-state-title').hasText('No orders yet'); + assert.dom('.next-table-empty-state-description').hasText('Create your first order.'); + }); + + test('it renders an empty state component', async function (assert) { + this.owner.register('template:components/test-empty-state', hbs`
Component empty: {{@context.rows.length}}
`); + + await render(hbs` +
+ `); + + assert.dom('[data-test-empty-state-component]').hasText('Component empty: 0'); + }); + + test('it renders a named empty state block', async function (assert) { + await render(hbs` +
+ <:emptyState as |state|> +
Block empty: {{state.rows.length}}
+ +
+ `); + + assert.dom('[data-test-empty-state-block]').hasText('Block empty: 0'); + }); }); diff --git a/tests/integration/components/table/empty-state-test.js b/tests/integration/components/table/empty-state-test.js new file mode 100644 index 00000000..97c07349 --- /dev/null +++ b/tests/integration/components/table/empty-state-test.js @@ -0,0 +1,121 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; + +class StubDocsPanelService extends Service { + lastTarget = null; + lastOptions = null; + + open(target, options) { + this.lastTarget = target; + this.lastOptions = options; + } +} + +module('Integration | Component | table/empty-state', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:docs-panel', StubDocsPanelService); + }); + + test('it renders a centered empty state with actions', async function (assert) { + this.set('createRecord', () => { + assert.step('create'); + }); + + this.set('refreshRecords', () => { + assert.step('refresh'); + }); + + await render(hbs` + + `); + + assert.dom('.next-table-empty-state').exists(); + assert.dom('.next-table-empty-state-title').hasText('Add your first vehicle'); + assert.dom('.next-table-empty-state-description').includesText('Vehicles connect assignments'); + assert.dom('.next-table-empty-state-description').hasStyle({ 'text-align': 'center' }); + assert.dom('.next-table-empty-state-actions button:last-child').hasClass('btn-md'); + + await click('.next-table-empty-state-actions button'); + await click('.next-table-empty-state-actions button:last-child'); + + assert.verifySteps(['refresh', 'create']); + }); + + test('it renders filtered copy when context includes search', async function (assert) { + this.set('context', { searchQuery: 'alpha' }); + + await render(hbs` + + `); + + assert.dom('.next-table-empty-state').includesText('No records match your search'); + assert.dom('.next-table-empty-state').includesText('Adjust search or filters.'); + assert.dom('.next-table-empty-state').doesNotIncludeText('Create the first record.'); + }); + + test('it renders the compact variant', async function (assert) { + this.set('refreshRecords', () => {}); + + await render(hbs` + + `); + + assert.dom('.next-table-empty-state-compact').exists(); + assert.dom('.next-table-empty-state-compact').includesText('No device events'); + assert.dom('.next-table-empty-state-compact .next-table-empty-state-actions button').hasClass('btn-sm'); + }); + + test('it opens documentation in the docs panel from a slug', async function (assert) { + await render(hbs` + + `); + + assert.dom('.next-table-empty-state-docs .btn-wrapper').doesNotExist(); + + await click('.next-table-empty-state-docs-action'); + + const docsPanel = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(docsPanel.lastTarget, 'fleet-ops/resources/vehicles/overview'); + assert.deepEqual(docsPanel.lastOptions, { + title: 'Vehicles guide', + source: 'fleet-ops-empty-vehicles', + }); + }); +}); diff --git a/tests/unit/services/docs-panel-test.js b/tests/unit/services/docs-panel-test.js new file mode 100644 index 00000000..6361f18c --- /dev/null +++ b/tests/unit/services/docs-panel-test.js @@ -0,0 +1,62 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | docs-panel', function (hooks) { + setupTest(hooks); + + test('it normalizes documentation slugs and urls', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual(service.normalizeUrl(), 'https://www.fleetbase.io/docs/?embed=console'); + assert.strictEqual(service.normalizeUrl('fleet-ops/resources/vehicles/overview'), 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console'); + assert.strictEqual(service.normalizeUrl('/docs/fleet-ops/resources/vehicles/overview'), 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console'); + assert.strictEqual(service.normalizeUrl('docs/fleet-ops/resources/vehicles/overview'), 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console'); + assert.strictEqual( + service.normalizeUrl('https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview'), + 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console' + ); + assert.strictEqual(service.normalizeUrl('https://example.com/help'), 'https://example.com/help'); + }); + + test('it appends theme only to Fleetbase docs urls', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + assert.strictEqual( + service.normalizeUrl('fleet-ops/resources/vehicles/overview', { theme: 'light' }), + 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console&theme=light' + ); + assert.strictEqual( + service.normalizeUrl('https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?foo=bar', { theme: 'dark' }), + 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?foo=bar&embed=console&theme=dark' + ); + assert.strictEqual(service.normalizeUrl('https://example.com/help', { theme: 'light' }), 'https://example.com/help'); + }); + + test('it can embed normalized Fleetbase docs urls', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/resources/vehicles/overview', { title: 'Vehicles guide' }); + + assert.strictEqual(service.url, 'https://www.fleetbase.io/docs/fleet-ops/resources/vehicles/overview?embed=console&theme=light'); + assert.strictEqual(service.title, 'Vehicles guide'); + assert.true(service.canEmbed); + assert.true(service.isIframeLoading); + }); + + test('it tracks loading state for iframe lifecycle', function (assert) { + const service = this.owner.lookup('service:docs-panel'); + + service.open('fleet-ops/resources/vehicles/overview'); + assert.true(service.isIframeLoading, 'docs iframe starts loading'); + + service.markIframeLoaded(); + assert.false(service.isIframeLoading, 'load clears loading state'); + + service.open('https://example.com/help'); + assert.false(service.isIframeLoading, 'external fallback does not keep loading'); + + service.open('fleet-ops/resources/vehicles/overview'); + service.markIframeFailed(); + assert.false(service.isIframeLoading, 'error clears loading state'); + }); +});