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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,25 @@ on:
pull_request:
branches: [ main ]

env:
NODE_VERSION: 22.x

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x] # Build on Node.js 20

steps:
- uses: actions/checkout@v2

- name: Setup Node.js ${{ matrix.node-version }}
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
node-version: ${{ env.NODE_VERSION }}

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.5
version: latest

- name: Install Dependencies
run: pnpm install
Expand All @@ -44,15 +43,15 @@ jobs:
steps:
- uses: actions/checkout@v2

- name: Setup Node.js 20.x
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: ${{ env.NODE_VERSION }}

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.5
version: latest

- name: Install Dependencies
run: pnpm install
Expand All @@ -70,15 +69,15 @@ jobs:
steps:
- uses: actions/checkout@v2

- name: Setup Node.js 20.x
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: ${{ env.NODE_VERSION }}

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.5
version: latest

- name: Install Dependencies
run: pnpm install
Expand All @@ -99,15 +98,15 @@ jobs:
steps:
- uses: actions/checkout@v2

- name: Setup Node.js 20.x
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: ${{ env.NODE_VERSION }}

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.5
version: latest

- name: Install Dependencies
run: pnpm install
Expand Down
2 changes: 1 addition & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module.exports = {
printWidth: 190,
overrides: [
{
files: '*.{hbs,js,ts}',
files: '*.hbs',
options: {
singleQuote: false,
},
Expand Down
15 changes: 12 additions & 3 deletions addon/components/dashboard.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
<span>{{t "component.dashboard.create-new-dashboard"}}</span>
</a>

{{#unless (eq this.dashboard.currentDashboard.user_uuid "system")}}
{{#if this.isSystemDashboard}}
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 italic border-t dark:border-gray-700 border-gray-200 mt-1">
{{t "component.dashboard.create-to-customize-hint"}}
</div>
{{else}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.onChangeEdit true)}}>
<div class="w-6">
<FaIcon @icon="edit" />
Expand All @@ -65,7 +69,7 @@
</div>
<span>{{t "component.dashboard.delete-dashboard"}}</span>
</a>
{{/unless}}
{{/if}}

</div>
</div>
Expand All @@ -80,7 +84,12 @@
</div>

<div class="{{@createWrapperClass}}">
<Dashboard::Create @isEdit={{this.dashboard.isEditingDashboard}} @isAddingWidget={{this.dashboard.isAddingWidget}} @dashboard={{this.dashboard.currentDashboard}} />
<Dashboard::Create
@isEdit={{and this.dashboard.isEditingDashboard (not this.isSystemDashboard)}}
@isAddingWidget={{this.dashboard.isAddingWidget}}
@isSystemDashboard={{this.isSystemDashboard}}
@dashboard={{this.dashboard.currentDashboard}}
/>
{{#if this.dashboard.isAddingWidget}}
<EmberWormhole @to="application-root-wormhole">
<Dashboard::WidgetPanel
Expand Down
15 changes: 10 additions & 5 deletions addon/components/dashboard.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { next } from '@ember/runloop';

/**
* DashboardComponent for managing dashboards in an Ember application.
Expand All @@ -25,10 +24,16 @@ export default class DashboardComponent extends Component {
constructor(owner, { defaultDashboardId = 'dashboard', defaultDashboardName = 'Default Dashboard', showPanelWhenZeroWidgets = false, extension = 'core' } = {}) {
super(...arguments);
this.dashboard.reset(); // ensure service is reset when re-rendering
next(() => {
this.dashboard.showPanelWhenZeroWidgets = showPanelWhenZeroWidgets;
this.dashboard.loadDashboards.perform({ defaultDashboardId, defaultDashboardName, extension });
});
this.dashboard.showPanelWhenZeroWidgets = showPanelWhenZeroWidgets;
this.dashboard.loadDashboards.perform({ defaultDashboardId, defaultDashboardName, extension });
}

/**
* True when the active dashboard is the virtual system/default record (not persisted).
* Used to hide edit affordances since save/destroy on these records 404s on the backend.
*/
get isSystemDashboard() {
return this.dashboard.currentDashboard?.user_uuid === 'system';
}

/**
Expand Down
50 changes: 31 additions & 19 deletions addon/components/dashboard/create.hbs
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
<div class="fleetbase-dashboard-grid" ...attributes>
<GridStack @options={{this.gridOptions}} @onChange={{this.onChangeGrid}}>
{{#each @dashboard.widgets as |widget|}}
{{#let (resolve-component widget.component) as |componentDefinition|}}
{{#if componentDefinition}}
<GridStackItem id={{widget.id}} @options={{spread-widget-options (hash id=widget.id options=widget.grid_options)}} class="relative">
<LazyEngineComponent @component={{componentDefinition}} as |resolvedComponent|>
{{component resolvedComponent widget=widget options=widget.options}}
</LazyEngineComponent>
{{!--
Keyed iteration over a single-element [dashboardId] array. When the
active dashboard changes the iteration key changes, ember destroys the
existing GridStack (running its willDestroyNode → gridstack.destroy,
and crucially removing the .grid-stack DOM node along with all its
inline `min-height`/`height` styles that gridstack stamps on it during
layout), and re-instantiates a fresh GridStack on the new dashboard's
widgets. This is the only reliable way to keep the empty band that
gridstack leaves behind from carrying over between dashboards.
--}}
{{#each this.dashboardKey key="@identity" as |dashId|}}
<GridStack @options={{this.gridOptions}} @onChange={{this.onChangeGrid}} data-id={{dashId}}>
{{#each @dashboard.widgets as |widget|}}
{{#let (resolve-component widget.component) as |componentDefinition|}}
{{#if componentDefinition}}
<GridStackItem id={{widget.id}} @options={{spread-widget-options (hash id=widget.id options=widget.grid_options)}} class="relative">
<LazyEngineComponent @component={{componentDefinition}} as |resolvedComponent|>
{{component resolvedComponent widget=widget options=widget.options}}
</LazyEngineComponent>

{{#if @isEdit}}
<div class="absolute top-2 right-2">
<Button @type="default" @icon="trash" @helpText="Remove widget from the dashboard" @onClick={{fn this.removeWidget widget}} />
</div>
{{/if}}
</GridStackItem>
{{/if}}
{{/let}}
{{/each}}
</GridStack>
</div>
{{#if @isEdit}}
<div class="absolute top-2 right-2">
<Button @type="default" @icon="trash" @helpText="Remove widget from the dashboard" @onClick={{fn this.removeWidget widget}} />
</div>
{{/if}}
</GridStackItem>
{{/if}}
{{/let}}
{{/each}}
</GridStack>
{{/each}}
</div>
46 changes: 36 additions & 10 deletions addon/components/dashboard/create.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, computed } from '@ember/object';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';

/**
Expand Down Expand Up @@ -70,21 +70,29 @@ export default class DashboardCreateComponent extends Component {
@action removeWidget(widget) {
const { dashboard } = this.args;

if (dashboard) {
dashboard.removeWidget(widget.id).catch((error) => {
if (!dashboard) return;

dashboard
.removeWidget(widget.id)
.then(() => this.compactGrid())
.catch((error) => {
this.notifications.serverError(error);
});
}
}

/**
* Computed property that returns grid options based on the current edit state.
* Configures grid behavior such as floating, animation, and drag and resize capabilities.
*
* @computed
* @returns {Object} An object containing grid configuration options.
* Trigger gridstack's compaction pass so the grid closes the empty cell left
* behind when a widget is removed. Without this, `float: true` leaves a
* persistent gap where the deleted widget used to sit.
*/
@computed('args.isEdit') get gridOptions() {
compactGrid() {
// gridstack attaches itself to the .grid-stack element as `el.gridstack`.
// Scoped query so we don't fight other grids on the page.
const root = document.querySelector('.fleetbase-dashboard-grid .grid-stack');
root?.gridstack?.compact?.();
}

get gridOptions() {
return {
float: true,
animate: true,
Expand All @@ -96,4 +104,22 @@ export default class DashboardCreateComponent extends Component {
cellHeight: 30,
};
}

/**
* Wrapping the GridStack in `{{#each (array @dashboard.id) key="@identity"}}`
* keys the entire subtree to the active dashboard's id. This getter exists
* to give the template a single-element array to iterate. When the id
* changes, ember treats the iteration item as a different key, destroys
* the existing GridStack (running its willDestroyNode → gridstack.destroy
* + DOM removal), and re-instantiates it for the new dashboard.
*
* This is the only reliable way to clear gridstack's internal engine state
* AND the inline `min-height/height` styles it stamps onto `.grid-stack`.
* Without a real DOM remount, switching from a tall dashboard to a short
* one leaves the empty band where the old widgets used to sit because
* gridstack's destroy(false) preserves DOM (and therefore those styles).
*/
get dashboardKey() {
return [this.args.dashboard?.id ?? '__empty__'];
}
}
68 changes: 68 additions & 0 deletions addon/components/dashboard/widget-card.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{{!--
Compact, fixed-height card. Whole card is clickable. Badges live on a row
BELOW the title (aligned left). Title is a single truncated line so every
card in the grid lines up cleanly.

template-lint's `require-presentational-children` rule disallows semantic
descendants under any `role="button"` element, but this is the standard
"clickable card" pattern — the visible icon, name, description, and badges
are required content for the user to identify the widget before activating.
The card remains keyboard-focusable via tabindex=0 and click-activatable.
--}}
{{! template-lint-disable require-presentational-children }}
<div
class="dashboard-widget-card group relative h-[88px] rounded-md border bg-white dark:bg-gray-800
transition-colors cursor-pointer overflow-hidden
{{if this.isAdded
'border-sky-300/70 dark:border-sky-700/70'
'border-gray-200 dark:border-gray-700'}}
hover:border-sky-500 dark:hover:border-sky-500 hover:bg-sky-50/30 dark:hover:bg-sky-900/10"
data-widget-key={{@widget.id}}
role="button"
tabindex="0"
{{on "mouseenter" @onHover}}
{{on "mouseleave" @onUnhover}}
{{on "click" @onAdd}}
>
<div class="flex items-start gap-2 p-2 h-full">
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 {{this.iconAccent}}">
<FaIcon @icon={{this.iconName}} class="text-[11px]" />
</div>

<div class="flex-1 min-w-0 flex flex-col">
<div class="text-[12px] font-semibold text-black dark:text-gray-100 truncate leading-tight">
{{@widget.name}}
</div>
<p class="text-[10.5px] leading-snug text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">
{{@widget.description}}
</p>

{{#if this.hasBadges}}
<div class="flex items-center gap-1 mt-auto pt-1">
{{#if this.isDefault}}
<span class="text-[8.5px] uppercase font-bold tracking-wide px-1 py-px rounded leading-none
bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
{{t "dashboard-widget-panel.badge-default"}}
</span>
{{/if}}
{{#if this.isAdded}}
<span class="text-[8.5px] uppercase font-bold tracking-wide px-1 py-px rounded leading-none
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
{{this.addedShortBadge}}
</span>
{{/if}}
</div>
{{/if}}
</div>

<div class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{{#if @isAdding}}
<Spinner @class="w-3 h-3 text-sky-600 dark:text-sky-400" />
{{else}}
<div class="w-5 h-5 rounded-full flex items-center justify-center bg-sky-600 text-white shadow-sm">
<FaIcon @icon="plus" class="text-[9px]" />
</div>
{{/if}}
</div>
</div>
</div>
Loading
Loading