diff --git a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.spec.ts b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.spec.ts
index 6f4afe43..b5223b98 100644
--- a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.spec.ts
+++ b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.spec.ts
@@ -1,4 +1,5 @@
import { DashboardCard } from './dashboard-card.component';
+import { DASHBOARD_CARD_DRAG_ORIGIN_CLASS } from '../constants';
import { DashboardI18nService } from '../i18n';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -129,6 +130,38 @@ describe('DashboardCard', () => {
expect(root(fixture).querySelector('.component-card')).toBeNull();
});
+ it('marks the dynamic component host as the drag origin surface', () => {
+ const { fixture } = setup();
+
+ fixture.componentRef.setInput('card', {
+ id: 'card-1',
+ component: 'demo-widget',
+ });
+ fixture.detectChanges();
+
+ expect(
+ root(fixture).querySelector(
+ `.component-host.${DASHBOARD_CARD_DRAG_ORIGIN_CLASS}`,
+ ),
+ ).not.toBeNull();
+ });
+
+ it('marks the fallback card body as the drag origin surface', () => {
+ const { fixture } = setup();
+
+ fixture.componentRef.setInput('card', {
+ id: 'card-1',
+ component: '',
+ });
+ fixture.detectChanges();
+
+ expect(
+ root(fixture).querySelector(
+ `.card__body.${DASHBOARD_CARD_DRAG_ORIGIN_CLASS}`,
+ ),
+ ).not.toBeNull();
+ });
+
describe('data-testid attributes', () => {
it('sets data-testid on the component-card wrapper when card has a component', () => {
const { fixture } = setup();
diff --git a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.ts b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.ts
index a07188d9..ad7ba852 100644
--- a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.ts
+++ b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.ts
@@ -1,3 +1,4 @@
+import { DASHBOARD_CARD_DRAG_ORIGIN_CLASS } from '../constants';
import { DASHBOARD_I18N_KEYS, DashboardI18nService } from '../i18n';
import { CARD_TYPES, CardConfig } from '../models';
import { mountAngularCard, mountSapCard, mountWcCard } from './utils';
@@ -34,6 +35,8 @@ export class DashboardCard {
readonly removeCard = output
();
protected readonly i18n = inject(DashboardI18nService);
protected readonly i18nKeys = DASHBOARD_I18N_KEYS;
+ protected readonly componentHostClasses = `component-host ${DASHBOARD_CARD_DRAG_ORIGIN_CLASS}`;
+ protected readonly cardBodyClasses = `card__body ${DASHBOARD_CARD_DRAG_ORIGIN_CLASS}`;
protected readonly gridColumn = computed(() => {
const width = this.card().w ?? 12;
return this.createGridTrack(this.card().x, width);
diff --git a/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss b/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss
index 7336a013..afc85d19 100644
--- a/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss
+++ b/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss
@@ -6,17 +6,6 @@
// Keep these values identical to DASHBOARD_BREAKPOINTS in breakpoints.ts.
// ─────────────────────────────────────────────────────────────────────────────
-// Each breakpoint's max-width (in px) and the corresponding column count
-// applied at that breakpoint. Ordered ascending by `w` so it's easy to read
-// off as a series of (max-width, columns) pairs.
-$dashboard-breakpoints: (
- (599, 1),
- (1023, 8),
- (1439, 12),
- // Above 1439: 14 columns. Implemented as the default (no @container rule);
- // the 4000 ceiling exists in the TS table for Gridstack's benefit only.
-);
-
/// Convenience accessors for the four breakpoint widths used by the grid.
$dashboard-bp-sm: 599px;
$dashboard-bp-md: 1023px;
@@ -24,9 +13,9 @@ $dashboard-bp-lg: 1439px;
/// Column counts at each breakpoint.
$dashboard-cols-sm: 1;
-$dashboard-cols-md: 8;
-$dashboard-cols-lg: 12;
-$dashboard-cols-xl: 14;
+$dashboard-cols-md: 4;
+$dashboard-cols-lg: 4;
+$dashboard-cols-xl: 4;
/// Emit `grid-template-columns: repeat(N, 1fr)` rules wrapped in @container
/// queries against the `mfp-dashboard` named container. Every dashboard grid
diff --git a/projects/ngx/declarative-ui/dashboard/constants/breakpoints.ts b/projects/ngx/declarative-ui/dashboard/constants/breakpoints.ts
index 2f3f3b51..24b257db 100644
--- a/projects/ngx/declarative-ui/dashboard/constants/breakpoints.ts
+++ b/projects/ngx/declarative-ui/dashboard/constants/breakpoints.ts
@@ -19,6 +19,72 @@
// ─────────────────────────────────────────────────────────────────────────────
import type { Breakpoint } from 'gridstack';
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
/**
* Layout strategy applied at each breakpoint when Gridstack changes column
* count. See ColumnOptions in gridstack/dist/types.d.ts:27 for the full set.
@@ -26,9 +92,13 @@ import type { Breakpoint } from 'gridstack';
type LayoutStrategy = 'compact' | 'list' | 'none';
/** Single source of truth for grid + section breakpoints (TypeScript half). */
-export const DASHBOARD_BREAKPOINTS: readonly Readonly> & { layout: LayoutStrategy }>[] = [
- { w: 4000, c: 14, layout: 'compact' },
- { w: 1439, c: 12, layout: 'compact' },
- { w: 1023, c: 8, layout: 'compact' },
+export const DASHBOARD_BREAKPOINTS: readonly Readonly<
+ Required> & { layout: LayoutStrategy }
+>[] = [
+ { w: 4000, c: 4, layout: 'compact' },
+ { w: 1439, c: 4, layout: 'compact' },
+ { w: 1023, c: 4, layout: 'compact' },
{ w: 599, c: 1, layout: 'list' },
] as const;
+
+export const XL_PAGE = 1440;
diff --git a/projects/ngx/declarative-ui/dashboard/constants/constants.ts b/projects/ngx/declarative-ui/dashboard/constants/constants.ts
index 2de102c8..b3af22b5 100644
--- a/projects/ngx/declarative-ui/dashboard/constants/constants.ts
+++ b/projects/ngx/declarative-ui/dashboard/constants/constants.ts
@@ -1,2 +1,6 @@
export const COMPACT_BREAKPOINT = 726;
export const CELL_HEIGHT = 10;
+export const DASHBOARD_CARD_DRAG_ORIGIN_CLASS =
+ 'mfp-dashboard-card-drag-origin';
+export const DASHBOARD_CARD_DRAG_ORIGIN_SELECTOR =
+ `.${DASHBOARD_CARD_DRAG_ORIGIN_CLASS}`;
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html
index 952aa4c5..bf5354f4 100644
--- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html
@@ -179,7 +179,12 @@
(addedCB)="onGridChange()"
(changeCB)="onGridChange()"
(removedCB)="onGridChange()"
+ (dragStartCB)="onDragStart($event)"
+ (dragStopCB)="onDragStop()"
>
+ @if (dragOriginStyle()) {
+
+ }
@for (card of looseCards(); track card.id) {
({}));
@@ -50,6 +54,23 @@ function root(fixture: Fixture): ShadowRoot | HTMLElement {
return fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
}
+function mockRect(
+ element: Element,
+ rect: Pick,
+): void {
+ element.getBoundingClientRect = vi.fn(
+ () =>
+ ({
+ ...rect,
+ x: rect.left,
+ y: rect.top,
+ right: rect.left + rect.width,
+ bottom: rect.top + rect.height,
+ toJSON: () => rect,
+ }) as DOMRect,
+ );
+}
+
describe('Dashboard', () => {
beforeEach(async () => {
resetDashboardCardRegistry();
@@ -184,8 +205,18 @@ describe('Dashboard', () => {
expect(
(component as unknown as { cardsSnapshot: CardConfig[] }).cardsSnapshot,
).toEqual(cards);
- expect(component.cardsPosition.get('card-1')).toEqual({ x: 4, y: 2, w: 6, h: 20 });
- expect(component.cardsPosition.get('card-2')).toEqual({ x: 1, y: 3, w: 4, h: 10 });
+ expect(component.cardsPosition.get('card-1')).toEqual({
+ x: 4,
+ y: 2,
+ w: 6,
+ h: 20,
+ });
+ expect(component.cardsPosition.get('card-2')).toEqual({
+ x: 1,
+ y: 3,
+ w: 4,
+ h: 10,
+ });
});
it('emits the saved payload and persists the latest order on save', () => {
@@ -199,7 +230,9 @@ describe('Dashboard', () => {
(component as unknown as { gridStackItems: () => unknown }).gridStackItems =
() => ({
gridstackItems: {
- toArray: () => [{ options: { id: 'card-1', x: 7, y: 5, w: 8, h: 30 } }],
+ toArray: () => [
+ { options: { id: 'card-1', x: 7, y: 5, w: 8, h: 30 } },
+ ],
},
});
component.editMode.set(true);
@@ -214,20 +247,29 @@ describe('Dashboard', () => {
cards: [{ id: 'card-1', component: 'mfp-a', x: 7, y: 5, w: 8, h: 30 }],
},
]);
- expect(component.cardsPosition.get('card-1')).toEqual({ x: 7, y: 5, w: 8, h: 30 });
+ expect(component.cardsPosition.get('card-1')).toEqual({
+ x: 7,
+ y: 5,
+ w: 8,
+ h: 30,
+ });
expect(component.editMode()).toBe(false);
});
it('emits updated w and h in the saved payload when a card is resized', () => {
const { component } = setup();
- const cards: CardConfig[] = [{ id: 'card-1', component: 'mfp-a', w: 6, h: 20 }];
+ const cards: CardConfig[] = [
+ { id: 'card-1', component: 'mfp-a', w: 6, h: 20 },
+ ];
const emitted: { sections: SectionConfig[]; cards: CardConfig[] }[] = [];
component.cards.set(cards);
(component as unknown as { gridStackItems: () => unknown }).gridStackItems =
() => ({
gridstackItems: {
- toArray: () => [{ options: { id: 'card-1', x: 0, y: 0, w: 3, h: 10 } }],
+ toArray: () => [
+ { options: { id: 'card-1', x: 0, y: 0, w: 3, h: 10 } },
+ ],
},
});
component.editMode.set(true);
@@ -242,13 +284,17 @@ describe('Dashboard', () => {
it('preserves saved w/h after save-then-re-enter-then-cancel (regression)', () => {
const { component } = setup();
- const cards: CardConfig[] = [{ id: 'card-1', component: 'mfp-a', w: 6, h: 20 }];
+ const cards: CardConfig[] = [
+ { id: 'card-1', component: 'mfp-a', w: 6, h: 20 },
+ ];
component.cards.set(cards);
(component as unknown as { gridStackItems: () => unknown }).gridStackItems =
() => ({
gridstackItems: {
- toArray: () => [{ options: { id: 'card-1', x: 0, y: 0, w: 3, h: 10 } }],
+ toArray: () => [
+ { options: { id: 'card-1', x: 0, y: 0, w: 3, h: 10 } },
+ ],
},
});
@@ -313,8 +359,9 @@ describe('Dashboard', () => {
it('opens the discard popup instead of reverting when cancelEdit is called with unsaved changes', () => {
const { component } = setup();
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
@@ -330,8 +377,9 @@ describe('Dashboard', () => {
it('reverts immediately when cancelEdit is called without unsaved changes', () => {
const { component } = setup();
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
@@ -345,8 +393,9 @@ describe('Dashboard', () => {
const { component } = setup();
const sections: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }];
component.sections.set(sections);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
@@ -363,8 +412,9 @@ describe('Dashboard', () => {
it('cancelDiscard closes the popup and keeps the user in edit mode with their changes', () => {
const { component } = setup();
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
@@ -441,6 +491,130 @@ describe('Dashboard', () => {
expect(component.cardDialogOpen()).toBe(false);
});
+ it('positions the drag origin placeholder from the marked card surface', () => {
+ const { component } = setup();
+ const gridEl = document.createElement('div');
+ const gridItemEl = document.createElement('div');
+ const dragOriginEl = document.createElement('div');
+
+ dragOriginEl.className = DASHBOARD_CARD_DRAG_ORIGIN_CLASS;
+ gridItemEl.appendChild(dragOriginEl);
+ mockRect(gridEl, { left: 50, top: 100, width: 400, height: 600 });
+ mockRect(dragOriginEl, { left: 70, top: 130, width: 220, height: 180 });
+ (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
+ () => ({ el: gridEl });
+
+ component.onDragStart({ el: gridItemEl });
+
+ expect(
+ (
+ component as unknown as {
+ dragOriginStyle: () => {
+ top: string;
+ left: string;
+ width: string;
+ height: string;
+ } | null;
+ }
+ ).dragOriginStyle(),
+ ).toEqual({
+ top: '30px',
+ left: '20px',
+ width: '220px',
+ height: '180px',
+ });
+ });
+
+ it('falls back to the GridStack item when no drag origin marker exists', () => {
+ const { component } = setup();
+ const gridEl = document.createElement('div');
+ const gridItemEl = document.createElement('div');
+
+ mockRect(gridEl, { left: 10, top: 20, width: 400, height: 600 });
+ mockRect(gridItemEl, { left: 25, top: 45, width: 120, height: 90 });
+ (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
+ () => ({ el: gridEl });
+
+ component.onDragStart({ el: gridItemEl });
+
+ expect(
+ (
+ component as unknown as {
+ dragOriginStyle: () => {
+ top: string;
+ left: string;
+ width: string;
+ height: string;
+ } | null;
+ }
+ ).dragOriginStyle(),
+ ).toEqual({
+ top: '25px',
+ left: '15px',
+ width: '120px',
+ height: '90px',
+ });
+ });
+
+ it('syncs stale z-flow order from the current layout before a new drag starts', () => {
+ const { component } = setup();
+ const gridEl = document.createElement('div');
+ const gridItemEl = document.createElement('div');
+ const dragOriginEl = document.createElement('div');
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'favorites', x: 0, y: 0, w: 1, h: 10, zFlowOrder: 0 },
+ { id: 'recent', x: 1, y: 0, w: 1, h: 10, zFlowOrder: 1 },
+ { id: 'resource', x: 2, y: 0, w: 1, h: 10, zFlowOrder: 2 },
+ { id: 'cost', x: 3, y: 0, w: 1, h: 10, zFlowOrder: 4 },
+ { id: 'team', x: 0, y: 10, w: 1, h: 10, zFlowOrder: 3 },
+ { id: 'quick', x: 1, y: 10, w: 1, h: 10, zFlowOrder: 5 },
+ ];
+ const engine = new SteppedResizeGridStackEngine({ column: 4, nodes });
+
+ dragOriginEl.className = DASHBOARD_CARD_DRAG_ORIGIN_CLASS;
+ gridItemEl.appendChild(dragOriginEl);
+ mockRect(gridEl, { left: 0, top: 0, width: 400, height: 200 });
+ mockRect(dragOriginEl, { left: 200, top: 0, width: 100, height: 100 });
+ (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
+ () => ({
+ el: gridEl,
+ grid: { engine },
+ });
+
+ component.onDragStart({ el: gridItemEl });
+
+ expect(nodes.map((node) => [node.id, node.zFlowOrder])).toEqual([
+ ['favorites', 0],
+ ['recent', 1],
+ ['resource', 2],
+ ['cost', 3],
+ ['team', 4],
+ ['quick', 5],
+ ]);
+
+ const source = nodes[2] as GridStackNode & { _moving: boolean };
+ source._moving = true;
+
+ const changed = engine.moveNodeCheck(source, {
+ cellWidth: 100,
+ cellHeight: 10,
+ rect: { x: 200, y: 0, w: 100, h: 100 },
+ } as GridStackMoveOpts);
+
+ expect(changed).toBe(false);
+ expect(engine.commitZFlowLayout()).toBe(false);
+ expect(
+ nodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
+ ).toEqual([
+ { id: 'favorites', x: 0, y: 0 },
+ { id: 'recent', x: 1, y: 0 },
+ { id: 'resource', x: 2, y: 0 },
+ { id: 'cost', x: 3, y: 0 },
+ { id: 'team', x: 0, y: 10 },
+ { id: 'quick', x: 1, y: 10 },
+ ]);
+ });
+
it('preserves card constraint fields (maxH/maxW/minH/minW) through saveEdit', () => {
const { component } = setup();
const cards: CardConfig[] = [
@@ -622,26 +796,36 @@ describe('Dashboard', () => {
it('save button has data-testid="dashboard-save-btn" in edit mode', () => {
const { fixture, component } = setup();
- fixture.componentRef.setInput('config', { title: 'Operations', editable: true });
+ fixture.componentRef.setInput('config', {
+ title: 'Operations',
+ editable: true,
+ });
fixture.detectChanges();
component.editMode.set(true);
fixture.detectChanges();
- const btn = root(fixture).querySelector('[data-testid="dashboard-save-btn"]');
+ const btn = root(fixture).querySelector(
+ '[data-testid="dashboard-save-btn"]',
+ );
expect(btn).not.toBeNull();
});
it('cancel button has data-testid="dashboard-cancel-btn" in edit mode', () => {
const { fixture, component } = setup();
- fixture.componentRef.setInput('config', { title: 'Operations', editable: true });
+ fixture.componentRef.setInput('config', {
+ title: 'Operations',
+ editable: true,
+ });
fixture.detectChanges();
component.editMode.set(true);
fixture.detectChanges();
- const btn = root(fixture).querySelector('[data-testid="dashboard-cancel-btn"]');
+ const btn = root(fixture).querySelector(
+ '[data-testid="dashboard-cancel-btn"]',
+ );
expect(btn).not.toBeNull();
});
@@ -651,8 +835,12 @@ describe('Dashboard', () => {
fixture.componentRef.setInput('config', { title: 'Operations' });
fixture.detectChanges();
- expect(root(fixture).querySelector('[data-testid="dashboard-save-btn"]')).toBeNull();
- expect(root(fixture).querySelector('[data-testid="dashboard-cancel-btn"]')).toBeNull();
+ expect(
+ root(fixture).querySelector('[data-testid="dashboard-save-btn"]'),
+ ).toBeNull();
+ expect(
+ root(fixture).querySelector('[data-testid="dashboard-cancel-btn"]'),
+ ).toBeNull();
});
});
@@ -802,8 +990,9 @@ describe('Dashboard', () => {
describe('navigation guard (requestNavigation)', () => {
function enterEditWithDirty(component: Dashboard): void {
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
// Make the snapshot diverge so hasUnsavedChanges() flips to true.
component.sections.set([{ id: 'beta', title: 'Beta' }]);
@@ -852,8 +1041,9 @@ describe('Dashboard', () => {
const { component } = setup();
const original: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }];
component.sections.set(original);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
let ran = 0;
@@ -901,8 +1091,9 @@ describe('Dashboard', () => {
fixture.componentRef.setInput('config', { title: 'Operations' });
fixture.detectChanges();
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
@@ -934,8 +1125,9 @@ describe('Dashboard', () => {
fixture.componentRef.setInput('config', { title: 'Operations' });
fixture.detectChanges();
component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
- (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
- () => ({ gridstackItems: { toArray: () => [] } });
+ (
+ component as unknown as { gridStackItems: () => unknown }
+ ).gridStackItems = () => ({ gridstackItems: { toArray: () => [] } });
component.enterEditMode();
component.sections.set([{ id: 'beta', title: 'Beta' }]);
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts
index cb954433..1981645e 100644
--- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts
@@ -1,3 +1,24 @@
+import { ButtonSettings } from '../../models';
+import { DashboardCard } from '../card/dashboard-card.component';
+import { addComponentToRegistry } from '../card/utils';
+import {
+ CELL_HEIGHT,
+ COMPACT_BREAKPOINT,
+ DASHBOARD_BREAKPOINTS,
+ DASHBOARD_CARD_DRAG_ORIGIN_SELECTOR,
+ XL_PAGE,
+} from '../constants';
+import { DiscardChangesDialog } from '../discard-changes-dialog/discard-changes-dialog.component';
+import { EditCardsDialog } from '../edit-cards-dialog/edit-cards-dialog.component';
+import {
+ DASHBOARD_I18N_KEYS,
+ DashboardI18nService,
+ DashboardLanguage,
+} from '../i18n';
+import { CardConfig, DashboardConfig, SectionConfig } from '../models';
+import { DashboardSection } from '../section/dashboard-section.component';
+import { UnsavedChangesDialog } from '../unsaved-changes-dialog/unsaved-changes-dialog.component';
+import { SteppedResizeGridStackEngine } from './stepped-resize-engine';
import {
Component,
ElementRef,
@@ -33,24 +54,6 @@ import {
GridstackComponent,
GridstackItemComponent,
} from 'gridstack/dist/angular';
-import { ButtonSettings } from '../../models';
-import { DashboardCard } from '../card/dashboard-card.component';
-import { addComponentToRegistry } from '../card/utils';
-import {
- CELL_HEIGHT,
- COMPACT_BREAKPOINT,
- DASHBOARD_BREAKPOINTS,
-} from '../constants';
-import { DiscardChangesDialog } from '../discard-changes-dialog/discard-changes-dialog.component';
-import { EditCardsDialog } from '../edit-cards-dialog/edit-cards-dialog.component';
-import {
- DASHBOARD_I18N_KEYS,
- DashboardI18nService,
- DashboardLanguage,
-} from '../i18n';
-import { CardConfig, DashboardConfig, SectionConfig } from '../models';
-import { DashboardSection } from '../section/dashboard-section.component';
-import { UnsavedChangesDialog } from '../unsaved-changes-dialog/unsaved-changes-dialog.component';
document.body.classList.add('ui5-content-density-compact');
@@ -104,6 +107,13 @@ export class Dashboard implements OnInit, OnDestroy {
compactToolbar = signal(false);
toolbarMenuOpen = signal(false);
+ protected dragOriginStyle = signal<{
+ top: string;
+ left: string;
+ width: string;
+ height: string;
+ } | null>(null);
+
/** True once the user has dragged/resized any grid item while in edit mode. */
private gridDirty = signal(false);
@@ -161,6 +171,7 @@ export class Dashboard implements OnInit, OnDestroy {
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
+ engineClass: SteppedResizeGridStackEngine,
columnOpts: {
// Source of truth: ../models/breakpoints.ts (paired with
// ../models/_breakpoints.scss for the section grid's container queries).
@@ -212,8 +223,7 @@ export class Dashboard implements OnInit, OnDestroy {
{ x?: number; y?: number; w?: number; h?: number }
>();
looseCards = linkedSignal(() => this.cards().filter((c) => !c.sectionId));
-
- private newGridStackNodes: GridStackNode[] = [];
+ private isXLPage = signal(true);
constructor() {
effect(() => {
@@ -241,6 +251,7 @@ export class Dashboard implements OnInit, OnDestroy {
this.resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 0;
this.compactToolbar.set(width < COMPACT_BREAKPOINT);
+ this.changeCardSettingsForXlPage(width);
});
this.resizeObserver.observe(this.hostEl.nativeElement);
window.addEventListener('beforeunload', this.beforeUnloadHandler);
@@ -427,12 +438,40 @@ export class Dashboard implements OnInit, OnDestroy {
this.closeCardPanel();
}
+ onDragStart(event: { el: Element }): void {
+ this.getZFlowEngine()?.syncZFlowOrderFromLayout();
+
+ const el = this.getDragOriginElement(event.el);
+ const gridEl = this.gridStackItems().el as HTMLElement;
+ const gridRect = gridEl.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+
+ this.dragOriginStyle.set({
+ top: `${elRect.top - gridRect.top}px`,
+ left: `${elRect.left - gridRect.left}px`,
+ width: `${elRect.width}px`,
+ height: `${elRect.height}px`,
+ });
+ }
+
+ onDragStop(): void {
+ this.getZFlowEngine()?.commitZFlowLayout();
+ this.dragOriginStyle.set(null);
+ }
+
onGridChange(): void {
if (this.editMode()) {
this.gridDirty.set(true);
}
}
+ private getDragOriginElement(gridItemEl: Element): Element {
+ return (
+ gridItemEl.querySelector(DASHBOARD_CARD_DRAG_ORIGIN_SELECTOR) ??
+ gridItemEl
+ );
+ }
+
private saveCardsPosition(items: GridStackNode[]): void {
items.forEach((node) => {
if (node.id) {
@@ -455,4 +494,44 @@ export class Dashboard implements OnInit, OnDestroy {
this.saveCardsPosition(gridStackNodes);
}
}
+
+ private getZFlowEngine(): SteppedResizeGridStackEngine | null {
+ const engine = this.gridStackItems().grid?.engine;
+ return engine instanceof SteppedResizeGridStackEngine ? engine : null;
+ }
+
+ private updateCardsForBreakpoint(
+ updateCard: (card: CardConfig) => CardConfig,
+ ): void {
+ this.getZFlowEngine()?.syncZFlowOrderFromLayout();
+ this.cards.set(this.cards().map(updateCard));
+ afterNextRender(
+ () => {
+ this.getZFlowEngine()?.commitZFlowLayout();
+ },
+ { injector: this.injector },
+ );
+ }
+
+ private changeCardSettingsForXlPage(width: number): void {
+ if (width >= XL_PAGE) {
+ if (!this.isXLPage()) {
+ this.isXLPage.set(true);
+ this.updateCardsForBreakpoint((c) => ({
+ ...c,
+ w: c.w === 4 ? 3 : c.w,
+ maxW: c.maxW === 4 ? 3 : c.maxW,
+ }));
+ }
+ } else {
+ if (this.isXLPage()) {
+ this.isXLPage.set(false);
+ this.updateCardsForBreakpoint((c) => ({
+ ...c,
+ w: c.w === 3 ? 4 : c.w,
+ maxW: c.maxW === 3 ? 4 : c.maxW,
+ }));
+ }
+ }
+ }
}
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.spec.ts
new file mode 100644
index 00000000..6f23b736
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.spec.ts
@@ -0,0 +1,171 @@
+import { getAllowedResizeWidths, resolveResizeWidthStep } from './resize.helpers';
+
+describe('getAllowedResizeWidths', () => {
+ describe('normal case', () => {
+ it('returns [1, 2, maxWidth] sorted when all are distinct and within effectiveMax', () => {
+ expect(getAllowedResizeWidths(4, 4)).toEqual([1, 2, 4]);
+ });
+
+ it('returns values in ascending order', () => {
+ const result = getAllowedResizeWidths(6, 6);
+ expect(result).toEqual([...result].sort((a, b) => a - b));
+ });
+
+ it('all returned values are <= effectiveMax', () => {
+ const effectiveMax = 3;
+ const result = getAllowedResizeWidths(6, 6, 1, effectiveMax);
+ expect(result.every((w) => w <= effectiveMax)).toBe(true);
+ });
+ });
+
+ describe('deduplication', () => {
+ it('removes duplicate when maxWidth equals 1 — candidates [1,2,1] deduplicate to [1,2]', () => {
+ expect(getAllowedResizeWidths(1, 4)).toEqual([1, 2]);
+ });
+
+ it('removes duplicate when maxWidth equals 2', () => {
+ expect(getAllowedResizeWidths(2, 4)).toEqual([1, 2]);
+ });
+
+ it('returns no duplicate entries in output', () => {
+ const result = getAllowedResizeWidths(3, 4);
+ expect(new Set(result).size).toBe(result.length);
+ });
+ });
+
+ describe('minWidth clamping', () => {
+ it('raises candidates below minWidth to minWidth', () => {
+ // minWidth=2: candidates 1,2,4 → after clamp: 2,2,4 → deduplicated: [2,4]
+ expect(getAllowedResizeWidths(4, 4, 2)).toEqual([2, 4]);
+ });
+
+ it('raises all three candidates to minWidth when minWidth is large', () => {
+ // minWidth=4, maxWidth=4, columnCount=4: all clamp to 4 → deduplicated: [4]
+ expect(getAllowedResizeWidths(4, 4, 4)).toEqual([4]);
+ });
+
+ it('returns single value when minWidth equals maxWidth', () => {
+ expect(getAllowedResizeWidths(3, 4, 3)).toEqual([3]);
+ });
+ });
+
+ describe('columnCount clamping', () => {
+ it('caps candidates above columnCount to columnCount', () => {
+ // columnCount=3: maxWidth=10 is capped to 3; candidates 1,2,3 → [1,2,3]
+ expect(getAllowedResizeWidths(10, 3)).toEqual([1, 2, 3]);
+ });
+
+ it('caps when columnCount=1, all collapse to 1', () => {
+ expect(getAllowedResizeWidths(4, 1)).toEqual([1]);
+ });
+
+ it('caps when columnCount=2', () => {
+ expect(getAllowedResizeWidths(4, 2)).toEqual([1, 2]);
+ });
+ });
+
+ describe('effectiveMax filtering', () => {
+ it('excludes candidates above effectiveMax', () => {
+ // effectiveMax=2: maxWidth=4 is excluded
+ expect(getAllowedResizeWidths(4, 4, 1, 2)).toEqual([1, 2]);
+ });
+
+ it('returns only the single candidate that equals effectiveMax', () => {
+ expect(getAllowedResizeWidths(4, 4, 1, 1)).toEqual([1]);
+ });
+
+ it('returns empty array when effectiveMax is 0 and minWidth is 1', () => {
+ expect(getAllowedResizeWidths(4, 4, 1, 0)).toEqual([]);
+ });
+ });
+
+ describe('empty result', () => {
+ it('returns [] when minWidth > effectiveMax', () => {
+ expect(getAllowedResizeWidths(4, 4, 3, 2)).toEqual([]);
+ });
+
+ it('returns [] when minWidth > effectiveMax regardless of maxWidth', () => {
+ expect(getAllowedResizeWidths(1, 4, 5, 2)).toEqual([]);
+ });
+ });
+});
+
+describe('resolveResizeWidthStep', () => {
+ describe('nearest candidate selection', () => {
+ it('returns the higher candidate when rawWidth is equidistant between two', () => {
+ // allowed: [1,2,4], rawWidth=3 → |3-2|=1, |3-4|=1 → tie, higher (4) wins
+ expect(resolveResizeWidthStep(3, 4, 4)).toBe(4);
+ });
+
+ it('returns exact match when rawWidth equals a candidate', () => {
+ expect(resolveResizeWidthStep(2, 4, 4)).toBe(2);
+ });
+
+ it('returns candidate 1 when rawWidth is clearly closest to 1', () => {
+ // allowed: [1,2,4], rawWidth=1.1 → closest is 1
+ expect(resolveResizeWidthStep(1, 4, 4)).toBe(1);
+ });
+
+ it('returns candidate 4 when rawWidth is clearly closest to 4', () => {
+ // allowed: [1,2,4], rawWidth=4
+ expect(resolveResizeWidthStep(4, 4, 4)).toBe(4);
+ });
+ });
+
+ describe('tie-breaking: higher (later) candidate wins', () => {
+ it('picks the higher of two equidistant candidates', () => {
+ // allowed: [1,2,4] — rawWidth=3 is equidistant from 2 and 4
+ // reduce uses <=, so later candidate (4) replaces the current best (2) on a tie
+ expect(resolveResizeWidthStep(3, 4, 4)).toBe(4);
+ });
+
+ it('picks the higher when rawWidth is between 1 and 2', () => {
+ // allowed: [1,2,4] — rawWidth=1.5 equidistant from 1 and 2
+ // reduce uses <=, so candidate 2 replaces best 1 on equal distance → returns 2
+ expect(resolveResizeWidthStep(1.5, 4, 4)).toBe(2);
+ });
+ });
+
+ describe('empty allowed set fallback', () => {
+ it('returns effectiveMax when minWidth > effectiveMax produces empty allowed list', () => {
+ // minWidth=3, effectiveMax=2 → allowed=[] → fallback to effectiveMax=2
+ expect(resolveResizeWidthStep(1, 4, 4, 3, 2)).toBe(2);
+ });
+
+ it('returns the given effectiveMax value as fallback regardless of rawWidth', () => {
+ expect(resolveResizeWidthStep(99, 4, 4, 5, 2)).toBe(2);
+ });
+ });
+
+ describe('rawWidth outside the candidate range', () => {
+ it('returns the smallest candidate when rawWidth is below all candidates', () => {
+ // allowed: [2,4] (minWidth=2), rawWidth=0 → closest is 2
+ expect(resolveResizeWidthStep(0, 4, 4, 2)).toBe(2);
+ });
+
+ it('returns the largest candidate when rawWidth is above all candidates', () => {
+ // allowed: [1,2,4], rawWidth=100 → closest is 4
+ expect(resolveResizeWidthStep(100, 4, 4)).toBe(4);
+ });
+
+ it('returns the largest candidate when rawWidth exceeds columnCount', () => {
+ // allowed: [1,2,3] (columnCount=3), rawWidth=10 → closest is 3
+ expect(resolveResizeWidthStep(10, 10, 3)).toBe(3);
+ });
+ });
+
+ describe('single effective candidate', () => {
+ it('always returns the only effective candidate regardless of rawWidth', () => {
+ // maxWidth=1, columnCount=4 → candidates [1,2,1] → dedup → [1,2]
+ // rawWidth=99: largest is 2; rawWidth=0: smallest is 1
+ expect(resolveResizeWidthStep(99, 1, 4)).toBe(2);
+ expect(resolveResizeWidthStep(0, 1, 4)).toBe(1);
+ });
+
+ it('returns the single candidate when effectiveMax collapses allowed to one entry', () => {
+ // effectiveMax=1: only [1] passes the filter
+ expect(resolveResizeWidthStep(99, 4, 4, 1, 1)).toBe(1);
+ expect(resolveResizeWidthStep(0, 4, 4, 1, 1)).toBe(1);
+ });
+ });
+});
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts
new file mode 100644
index 00000000..c772a2a3
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts
@@ -0,0 +1,31 @@
+export function getAllowedResizeWidths(
+ maxWidth: number,
+ columnCount: number,
+ minWidth = 1,
+ effectiveMax = columnCount,
+): number[] {
+ return [1, 2, maxWidth]
+ .map((w) => Math.min(Math.max(w, minWidth), columnCount))
+ .filter((w, i, list) => list.indexOf(w) === i && w <= effectiveMax)
+ .sort((a, b) => a - b);
+}
+
+export function resolveResizeWidthStep(
+ rawWidth: number,
+ maxWidth: number,
+ columnCount: number,
+ minWidth = 1,
+ effectiveMax = columnCount,
+): number {
+ const allowed = getAllowedResizeWidths(maxWidth, columnCount, minWidth, effectiveMax);
+
+ if (!allowed.length) {
+ return effectiveMax;
+ }
+
+ return allowed.reduce((best, candidate) => {
+ return Math.abs(candidate - rawWidth) <= Math.abs(best - rawWidth)
+ ? candidate
+ : best;
+ }, allowed[0]);
+}
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.spec.ts
new file mode 100644
index 00000000..c52556f6
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.spec.ts
@@ -0,0 +1,178 @@
+import { SteppedResizeGridStackEngine } from './stepped-resize-engine';
+import type { ZFlowGridStackNode } from './z-flow.helpers';
+import type { GridStackMoveOpts, GridStackNode } from 'gridstack';
+
+function createEngine(
+ nodes: ZFlowGridStackNode[],
+): {
+ engine: SteppedResizeGridStackEngine;
+ onChange: ReturnType;
+} {
+ const onChange = vi.fn();
+ const engine = new SteppedResizeGridStackEngine({
+ column: 4,
+ nodes,
+ onChange,
+ });
+
+ return { engine, onChange };
+}
+
+describe('SteppedResizeGridStackEngine', () => {
+ it('keeps non-dragged nodes visually frozen during z-flow drag', () => {
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'a', x: 0, y: 0, w: 2, h: 10 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 10 },
+ { id: 'c', x: 0, y: 10, w: 2, h: 10 },
+ { id: 'd', x: 2, y: 10, w: 2, h: 10 },
+ ];
+ const { engine, onChange } = createEngine(nodes);
+ const source = nodes[2] as GridStackNode & { _moving: boolean };
+
+ source._moving = true;
+
+ const changed = engine.moveNodeCheck(source, {
+ cellWidth: 100,
+ cellHeight: 10,
+ rect: { x: 0, y: 0, w: 200, h: 100 },
+ } as GridStackMoveOpts);
+
+ expect(changed).toBe(true);
+ expect(
+ nodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
+ ).toEqual([
+ { id: 'a', x: 0, y: 0 },
+ { id: 'b', x: 2, y: 0 },
+ { id: 'c', x: 0, y: 0 },
+ { id: 'd', x: 2, y: 10 },
+ ]);
+ expect(nodes.map((node) => [node.id, node.zFlowOrder])).toEqual([
+ ['a', 1],
+ ['b', 2],
+ ['c', 0],
+ ['d', 3],
+ ]);
+ expect(onChange).toHaveBeenCalledWith([source]);
+ });
+
+ it('commits the full z-flow layout after frozen drag', () => {
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'a', x: 0, y: 0, w: 2, h: 10, zFlowOrder: 1 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 10, zFlowOrder: 2 },
+ { id: 'c', x: 0, y: 0, w: 2, h: 10, zFlowOrder: 0 },
+ { id: 'd', x: 2, y: 10, w: 2, h: 10, zFlowOrder: 3 },
+ ];
+ const { engine } = createEngine(nodes);
+
+ const changed = engine.commitZFlowLayout();
+
+ expect(changed).toBe(true);
+ expect(
+ nodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
+ ).toEqual([
+ { id: 'a', x: 2, y: 0 },
+ { id: 'b', x: 0, y: 10 },
+ { id: 'c', x: 0, y: 0 },
+ { id: 'd', x: 2, y: 10 },
+ ]);
+ });
+
+ it('snaps resize width and projects the affected nodes through z-flow', () => {
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'a', x: 0, y: 0, w: 2, h: 10, minW: 1, maxW: 4 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 10 },
+ { id: 'c', x: 0, y: 10, w: 2, h: 10 },
+ ];
+ const { engine, onChange } = createEngine(nodes);
+ const opts: GridStackMoveOpts = { w: 3, resizing: true };
+
+ const changed = engine.moveNodeCheck(nodes[0], opts);
+
+ expect(changed).toBe(true);
+ expect(opts.w).toBe(4);
+ expect(
+ nodes.map((node) => ({
+ id: node.id,
+ x: node.x,
+ y: node.y,
+ w: node.w,
+ })),
+ ).toEqual([
+ { id: 'a', x: 0, y: 0, w: 4 },
+ { id: 'b', x: 0, y: 10, w: 2 },
+ { id: 'c', x: 2, y: 10, w: 2 },
+ ]);
+ expect(onChange).toHaveBeenCalled();
+ });
+
+ it('does not pull the previous row tail down when dragging a wide card to the next row start', () => {
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'recent', x: 0, y: 0, w: 1, h: 10, zFlowOrder: 0 },
+ { id: 'quick', x: 1, y: 0, w: 1, h: 10, zFlowOrder: 1 },
+ { id: 'team', x: 2, y: 0, w: 1, h: 10, zFlowOrder: 2 },
+ { id: 'cost', x: 3, y: 0, w: 1, h: 10, zFlowOrder: 3 },
+ { id: 'favorites', x: 0, y: 10, w: 1, h: 10, zFlowOrder: 4 },
+ { id: 'resource', x: 1, y: 10, w: 2, h: 10, zFlowOrder: 5 },
+ ];
+ const { engine } = createEngine(nodes);
+ const source = nodes[5] as GridStackNode & { _moving: boolean };
+
+ source._moving = true;
+
+ const changed = engine.moveNodeCheck(source, {
+ cellWidth: 100,
+ cellHeight: 10,
+ rect: { x: 0, y: 100, w: 200, h: 100 },
+ } as GridStackMoveOpts);
+
+ expect(changed).toBe(true);
+ expect(nodes.map((node) => [node.id, node.zFlowOrder])).toEqual([
+ ['recent', 0],
+ ['quick', 1],
+ ['team', 2],
+ ['cost', 3],
+ ['favorites', 5],
+ ['resource', 4],
+ ]);
+ expect(
+ nodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
+ ).toEqual([
+ { id: 'recent', x: 0, y: 0 },
+ { id: 'quick', x: 1, y: 0 },
+ { id: 'team', x: 2, y: 0 },
+ { id: 'cost', x: 3, y: 0 },
+ { id: 'favorites', x: 0, y: 10 },
+ { id: 'resource', x: 0, y: 10 },
+ ]);
+
+ engine.commitZFlowLayout();
+
+ expect(
+ nodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
+ ).toEqual([
+ { id: 'recent', x: 0, y: 0 },
+ { id: 'quick', x: 1, y: 0 },
+ { id: 'team', x: 2, y: 0 },
+ { id: 'cost', x: 3, y: 0 },
+ { id: 'favorites', x: 2, y: 10 },
+ { id: 'resource', x: 0, y: 10 },
+ ]);
+ });
+
+ it('syncs z-flow order from the current visual layout', () => {
+ const nodes: ZFlowGridStackNode[] = [
+ { id: 'bottom', x: 0, y: 10, w: 2, h: 10, zFlowOrder: 0 },
+ { id: 'top-right', x: 2, y: 0, w: 2, h: 10, zFlowOrder: 1 },
+ { id: 'top-left', x: 0, y: 0, w: 2, h: 10, zFlowOrder: 2 },
+ ];
+ const { engine } = createEngine(nodes);
+
+ engine.syncZFlowOrderFromLayout();
+
+ expect(nodes.map((node) => [node.id, node.zFlowOrder])).toEqual([
+ ['bottom', 2],
+ ['top-right', 1],
+ ['top-left', 0],
+ ]);
+ });
+});
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts
new file mode 100644
index 00000000..52f2b3fa
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts
@@ -0,0 +1,258 @@
+import { resolveResizeWidthStep } from './resize.helpers';
+import {
+ type ZFlowGridStackNode,
+ applyProjectedLayout,
+ getZFlowRowHeight,
+ notifyEngine,
+ projectZFlowLayout,
+ reorderByInsertionSlot,
+ resolveDropRowFromRect,
+ resolveInsertionSlotFromProjectedRect,
+ seedNodeOrder,
+ sortNodesByZFlowOrder,
+ syncNodeOrderFromLayout,
+} from './z-flow.helpers';
+import type { GridStackMoveOpts, GridStackNode } from 'gridstack';
+import { GridStackEngine } from 'gridstack/dist/gridstack-engine';
+
+interface LayoutSnapshot {
+ node: ZFlowGridStackNode;
+ x?: number;
+ y?: number;
+ w?: number;
+ h?: number;
+}
+
+export class SteppedResizeGridStackEngine extends GridStackEngine {
+ override moveNodeCheck(
+ node: GridStackNode,
+ opts: GridStackMoveOpts,
+ ): boolean {
+ if (opts.resizing) {
+ return this.moveNodeCheckWithSteppedResize(node, opts);
+ }
+
+ if ((node as unknown as { _moving?: boolean })._moving && !opts.nested) {
+ return this.moveNodeCheckWithZFlowDrag(node, opts);
+ }
+
+ return super.moveNodeCheck(node, opts);
+ }
+
+ private moveNodeCheckWithSteppedResize(
+ node: GridStackNode,
+ opts: GridStackMoveOpts,
+ ): boolean {
+ if (!node.id || opts.w === undefined)
+ return super.moveNodeCheck(node, opts);
+
+ const nodes = this.nodes as ZFlowGridStackNode[];
+ const sourceNode = nodes.find((n) => n.id === node.id);
+ if (!sourceNode) return super.moveNodeCheck(node, opts);
+
+ syncNodeOrderFromLayout(nodes);
+
+ const snapshot = this.takeLayoutSnapshot(nodes);
+ const effectiveMax = this.column - (sourceNode.x ?? 0);
+ const nextWidth = resolveResizeWidthStep(
+ opts.w,
+ sourceNode.maxW ?? this.column,
+ this.column,
+ sourceNode.minW ?? 1,
+ effectiveMax,
+ );
+
+ sourceNode.w = nextWidth;
+ opts.w = nextWidth;
+ opts.x = sourceNode.x;
+ opts.y = sourceNode.y;
+ opts.h = sourceNode.h;
+
+ const ordered = sortNodesByZFlowOrder(nodes);
+ const projected = projectZFlowLayout(ordered, this.column);
+ applyProjectedLayout(nodes, projected);
+
+ const changed = this.markLayoutChangesDirty(snapshot);
+ if (!changed) return false;
+
+ notifyEngine(this);
+ return true;
+ }
+
+ commitZFlowLayout(): boolean {
+ const nodes = this.nodes as ZFlowGridStackNode[];
+ seedNodeOrder(nodes);
+
+ const snapshot = this.takeLayoutSnapshot(nodes);
+ const ordered = sortNodesByZFlowOrder(nodes);
+ const projected = projectZFlowLayout(ordered, this.column);
+ applyProjectedLayout(nodes, projected);
+
+ const changed = this.markLayoutChangesDirty(snapshot);
+ if (!changed) return false;
+
+ notifyEngine(this);
+ return true;
+ }
+
+ syncZFlowOrderFromLayout(): void {
+ syncNodeOrderFromLayout(this.nodes as ZFlowGridStackNode[]);
+ }
+
+ private takeLayoutSnapshot(nodes: ZFlowGridStackNode[]): LayoutSnapshot[] {
+ return nodes.map((n) => ({
+ node: n,
+ x: n.x,
+ y: n.y,
+ w: n.w,
+ h: n.h,
+ }));
+ }
+
+ private markLayoutChangesDirty(snapshot: LayoutSnapshot[]): boolean {
+ let changed = false;
+
+ for (const { node, x, y, w, h } of snapshot) {
+ if (node.x === x && node.y === y && node.w === w && node.h === h) {
+ continue;
+ }
+
+ (node as unknown as { _dirty: boolean })._dirty = true;
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ private moveNodeCheckWithZFlowDrag(
+ node: GridStackNode,
+ opts: GridStackMoveOpts,
+ ): boolean {
+ if (!node.id) return super.moveNodeCheck(node, opts);
+
+ const nodes = this.nodes as ZFlowGridStackNode[];
+ seedNodeOrder(nodes);
+
+ const ordered = sortNodesByZFlowOrder(nodes);
+ const orderedIds = ordered.filter((n) => n.id).map((n) => n.id as string);
+
+ const rect = opts.rect;
+ const cellWidth = opts.cellWidth ?? 1;
+ const cellHeight = opts.cellHeight ?? 60;
+ const rowHeight = getZFlowRowHeight(ordered);
+ const rowHeightPx = rowHeight * cellHeight;
+
+ const projected = projectZFlowLayout(ordered, this.column);
+ const rowCount =
+ projected.length > 0 ? Math.max(...projected.map((p) => p.row)) + 1 : 1;
+
+ const sourceNode = nodes.find((n) => n.id === node.id) as
+ | ZFlowGridStackNode
+ | undefined;
+ const sourceProjected = projected.find((p) => p.id === node.id);
+ const previousRow = sourceProjected?.row ?? 0;
+
+ let targetRow: number;
+ if (rect) {
+ const rectTop = (rect as unknown as { top?: number }).top ?? rect.y ?? 0;
+ const rectHeight =
+ (rect as unknown as { height?: number }).height ??
+ rect.h ??
+ rowHeightPx;
+ targetRow = resolveDropRowFromRect(
+ rectTop,
+ rectHeight,
+ rowHeightPx,
+ rowCount,
+ previousRow,
+ );
+ } else {
+ targetRow = Math.floor((opts.y ?? sourceNode?.y ?? 0) / rowHeight);
+ }
+
+ targetRow = Math.max(0, Math.min(targetRow, rowCount - 1));
+
+ const rowNodes = projected
+ .filter((p) => p.row === targetRow)
+ .map((p) => ({ id: p.id, x: p.x, w: p.w }));
+
+ const idsWithoutSource = orderedIds.filter((id) => id !== node.id);
+ let absoluteSlot: number;
+
+ if (rect) {
+ const rectLeft =
+ (rect as unknown as { left?: number }).left ?? rect.x ?? 0;
+ const rectTop = (rect as unknown as { top?: number }).top ?? rect.y ?? 0;
+ const rectWidth =
+ (rect as unknown as { width?: number }).width ?? rect.w ?? cellWidth;
+ const rectHeight =
+ (rect as unknown as { height?: number }).height ??
+ rect.h ??
+ rowHeightPx;
+ absoluteSlot = resolveInsertionSlotFromProjectedRect(
+ ordered,
+ node.id as string,
+ this.column,
+ {
+ leftPx: rectLeft,
+ topPx: rectTop,
+ widthPx: rectWidth,
+ heightPx: rectHeight,
+ },
+ cellWidth,
+ cellHeight,
+ orderedIds.indexOf(node.id as string),
+ );
+ } else {
+ const targetX = opts.x ?? sourceNode?.x ?? 0;
+ const insertionSlotInRow = rowNodes.filter((n) => {
+ const proj = projected.find((p) => p.id === n.id);
+ return proj && proj.x <= targetX && n.id !== node.id;
+ }).length;
+
+ const rowIdsWithoutSource = rowNodes
+ .filter((n) => n.id && n.id !== node.id)
+ .map((n) => n.id as string);
+
+ if (rowIdsWithoutSource.length === 0) {
+ absoluteSlot = orderedIds.indexOf(node.id as string);
+ } else {
+ const firstRowIdxInFull = idsWithoutSource.indexOf(
+ rowIdsWithoutSource[0],
+ );
+ absoluteSlot = firstRowIdxInFull + insertionSlotInRow;
+ }
+ }
+
+ absoluteSlot = Math.max(0, Math.min(absoluteSlot, idsWithoutSource.length));
+
+ const newOrderedIds = reorderByInsertionSlot(
+ orderedIds,
+ node.id as string,
+ absoluteSlot,
+ );
+ const orderChanged = newOrderedIds.some((id, i) => id !== orderedIds[i]);
+ if (!orderChanged) return false;
+
+ newOrderedIds.forEach((id, idx) => {
+ const n = nodes.find((nn) => nn.id === id);
+ if (n) n.zFlowOrder = idx;
+ });
+
+ const projectedSource = projectZFlowLayout(
+ sortNodesByZFlowOrder(nodes),
+ this.column,
+ ).find((projected) => projected.id === node.id);
+
+ if (projectedSource) {
+ node.x = projectedSource.x;
+ node.y = projectedSource.y;
+ node.w = projectedSource.w;
+ node.h = projectedSource.h;
+ (node as unknown as { _dirty: boolean })._dirty = true;
+ notifyEngine(this);
+ }
+
+ return true;
+ }
+}
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts
new file mode 100644
index 00000000..e2f1841a
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts
@@ -0,0 +1,126 @@
+import {
+ projectZFlowLayout,
+ resolveDropRowFromRect,
+ resolveInsertionSlotFromProjectedRect,
+ resolveInsertionSlotFromRow,
+} from './z-flow.helpers';
+
+describe('z-flow helpers', () => {
+ it('projects wrapped rows by card height, not by a single grid row', () => {
+ const projected = projectZFlowLayout(
+ [
+ { id: 'a', x: 0, y: 0, w: 2, h: 17 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 17 },
+ { id: 'c', x: 0, y: 17, w: 2, h: 17 },
+ ],
+ 4,
+ );
+
+ expect(projected).toEqual([
+ { id: 'a', row: 0, x: 0, y: 0, w: 2, h: 17 },
+ { id: 'b', row: 0, x: 2, y: 0, w: 2, h: 17 },
+ { id: 'c', row: 1, x: 0, y: 17, w: 2, h: 17 },
+ ]);
+ });
+
+ it('chooses the row with the largest vertical overlap', () => {
+ const rowHeightPx = 170;
+
+ expect(resolveDropRowFromRect(70, 170, rowHeightPx, 2, 0)).toBe(0);
+ expect(resolveDropRowFromRect(90, 170, rowHeightPx, 2, 0)).toBe(1);
+ });
+
+ it('keeps the previous row when the drag preview does not overlap any row', () => {
+ expect(resolveDropRowFromRect(900, 100, 170, 2, 1)).toBe(1);
+ });
+
+ it('resolves insertion slots from projected row positions', () => {
+ const rowNodes = [
+ { id: 'a', x: 0, w: 2 },
+ { id: 'b', x: 2, w: 2 },
+ { id: 'c', x: 4, w: 2 },
+ ];
+
+ expect(resolveInsertionSlotFromRow(50, rowNodes, 'dragging', 100)).toBe(0);
+ expect(resolveInsertionSlotFromRow(250, rowNodes, 'dragging', 100)).toBe(1);
+ expect(resolveInsertionSlotFromRow(550, rowNodes, 'dragging', 100)).toBe(3);
+ });
+
+ it('resolves insertion slot from the projected card overlap', () => {
+ const nodes = [
+ { id: 'a', x: 0, y: 0, w: 2, h: 10, zFlowOrder: 0 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 10, zFlowOrder: 1 },
+ { id: 'c', x: 0, y: 10, w: 2, h: 10, zFlowOrder: 2 },
+ ];
+
+ const slot = resolveInsertionSlotFromProjectedRect(
+ nodes,
+ 'c',
+ 4,
+ {
+ leftPx: 200,
+ topPx: 0,
+ widthPx: 200,
+ heightPx: 100,
+ },
+ 100,
+ 10,
+ 2,
+ );
+
+ expect(slot).toBe(1);
+ });
+
+ it('keeps the previous projected slot until overlap crosses the threshold', () => {
+ const nodes = [
+ { id: 'a', x: 0, y: 0, w: 2, h: 10, zFlowOrder: 0 },
+ { id: 'b', x: 2, y: 0, w: 2, h: 10, zFlowOrder: 1 },
+ { id: 'c', x: 0, y: 10, w: 2, h: 10, zFlowOrder: 2 },
+ ];
+
+ const slot = resolveInsertionSlotFromProjectedRect(
+ nodes,
+ 'c',
+ 4,
+ {
+ leftPx: 200,
+ topPx: 0,
+ widthPx: 80,
+ heightPx: 100,
+ },
+ 100,
+ 10,
+ 2,
+ );
+
+ expect(slot).toBe(2);
+ });
+
+ it('prefers the later slot when a wide card has the same projected target position', () => {
+ const nodes = [
+ { id: 'recent', x: 0, y: 0, w: 1, h: 10, zFlowOrder: 0 },
+ { id: 'quick', x: 1, y: 0, w: 1, h: 10, zFlowOrder: 1 },
+ { id: 'team', x: 2, y: 0, w: 1, h: 10, zFlowOrder: 2 },
+ { id: 'cost', x: 3, y: 0, w: 1, h: 10, zFlowOrder: 3 },
+ { id: 'favorites', x: 0, y: 10, w: 1, h: 10, zFlowOrder: 4 },
+ { id: 'resource', x: 1, y: 10, w: 2, h: 10, zFlowOrder: 5 },
+ ];
+
+ const slot = resolveInsertionSlotFromProjectedRect(
+ nodes,
+ 'resource',
+ 4,
+ {
+ leftPx: 0,
+ topPx: 100,
+ widthPx: 200,
+ heightPx: 100,
+ },
+ 100,
+ 10,
+ 5,
+ );
+
+ expect(slot).toBe(4);
+ });
+});
diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts
new file mode 100644
index 00000000..eb60a927
--- /dev/null
+++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts
@@ -0,0 +1,298 @@
+import type { GridStackNode } from 'gridstack';
+import type { GridStackEngine } from 'gridstack/dist/gridstack-engine';
+
+export type ZFlowGridStackNode = GridStackNode & {
+ zFlowOrder?: number;
+};
+
+type NotifyableGridStackEngine = GridStackEngine & {
+ _notify?: () => unknown;
+};
+
+export function hasZFlowOrder(nodes: ZFlowGridStackNode[]): boolean {
+ return nodes.some((n) => n.zFlowOrder !== undefined);
+}
+
+export function syncNodeOrderFromLayout(nodes: ZFlowGridStackNode[]): void {
+ const sorted = [...nodes].sort(compareNodesByLayoutPosition);
+
+ sorted.forEach((n, i) => {
+ n.zFlowOrder = i;
+ });
+}
+
+export function seedNodeOrder(nodes: ZFlowGridStackNode[]): void {
+ if (hasZFlowOrder(nodes)) return;
+
+ syncNodeOrderFromLayout(nodes);
+}
+
+export function sortNodesByZFlowOrder(
+ nodes: ZFlowGridStackNode[],
+): ZFlowGridStackNode[] {
+ return [...nodes].sort((a, b) => {
+ if (a.zFlowOrder !== undefined && b.zFlowOrder !== undefined) {
+ return a.zFlowOrder - b.zFlowOrder;
+ }
+ if (a.zFlowOrder !== undefined) return -1;
+ if (b.zFlowOrder !== undefined) return 1;
+ const ay = a.y ?? 0;
+ const by = b.y ?? 0;
+ if (ay !== by) return ay - by;
+ return (a.x ?? 0) - (b.x ?? 0);
+ });
+}
+
+function compareNodesByLayoutPosition(
+ a: ZFlowGridStackNode,
+ b: ZFlowGridStackNode,
+): number {
+ const ay = a.y ?? 0;
+ const by = b.y ?? 0;
+ if (ay !== by) return ay - by;
+ return (a.x ?? 0) - (b.x ?? 0);
+}
+
+export interface ProjectedNode {
+ id: string;
+ row: number;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+export interface ZFlowRowItem {
+ id?: string;
+ x?: number;
+ w?: number;
+}
+
+export interface ZFlowDragRect {
+ leftPx: number;
+ topPx: number;
+ widthPx: number;
+ heightPx: number;
+}
+
+const PROJECTED_SLOT_OVERLAP_THRESHOLD = 0.5;
+
+export function getZFlowRowHeight(nodes: ZFlowGridStackNode[]): number {
+ return Math.max(...nodes.map((n) => n.h ?? 1), 1);
+}
+
+export function projectZFlowLayout(
+ nodes: ZFlowGridStackNode[],
+ columnCount: number,
+): ProjectedNode[] {
+ const result: ProjectedNode[] = [];
+ let curX = 0;
+ let curRow = 0;
+ const rowHeight = getZFlowRowHeight(nodes);
+
+ for (const node of nodes) {
+ if (!node.id) continue;
+ const w = Math.min(node.w ?? 1, columnCount);
+ const h = node.h ?? rowHeight;
+
+ if (curX > 0 && curX + w > columnCount) {
+ curX = 0;
+ curRow++;
+ }
+
+ result.push({
+ id: node.id,
+ row: curRow,
+ x: curX,
+ y: curRow * rowHeight,
+ w,
+ h,
+ });
+ curX += w;
+ }
+
+ return result;
+}
+
+export function resolveDropRowFromRect(
+ dragRectTopPx: number,
+ dragRectHeightPx: number,
+ rowHeightPx: number,
+ rowCount: number,
+ previousRow: number,
+): number {
+ if (rowHeightPx <= 0 || rowCount <= 0) return previousRow;
+
+ const clampedPreviousRow = Math.max(0, Math.min(previousRow, rowCount - 1));
+ const dragBottomPx = dragRectTopPx + dragRectHeightPx;
+ let bestRow = clampedPreviousRow;
+ let bestOverlap = 0;
+
+ for (let row = 0; row < rowCount; row++) {
+ const rowTopPx = row * rowHeightPx;
+ const rowBottomPx = rowTopPx + rowHeightPx;
+ const overlap = Math.max(
+ 0,
+ Math.min(dragBottomPx, rowBottomPx) - Math.max(dragRectTopPx, rowTopPx),
+ );
+
+ if (overlap > bestOverlap) {
+ bestOverlap = overlap;
+ bestRow = row;
+ }
+ }
+
+ return bestOverlap > 0 ? bestRow : clampedPreviousRow;
+}
+
+export function resolveInsertionSlotFromRow(
+ dragRectCenterXPx: number,
+ rowNodes: ZFlowRowItem[],
+ sourceId: string,
+ cellWidthPx: number,
+): number {
+ const candidates = rowNodes.filter((n) => n.id !== sourceId);
+ if (candidates.length === 0) return 0;
+
+ for (let i = 0; i < candidates.length; i++) {
+ const n = candidates[i];
+ const nodeX = (n.x ?? 0) * cellWidthPx;
+ const nodeW = (n.w ?? 1) * cellWidthPx;
+ const nodeMid = nodeX + nodeW / 2;
+
+ if (dragRectCenterXPx <= nodeMid) return i;
+ }
+
+ return candidates.length;
+}
+
+export function reorderByInsertionSlot(
+ orderedIds: string[],
+ sourceId: string,
+ targetSlot: number,
+): string[] {
+ const withoutSource = orderedIds.filter((id) => id !== sourceId);
+ const clamped = Math.max(0, Math.min(targetSlot, withoutSource.length));
+ withoutSource.splice(clamped, 0, sourceId);
+ return withoutSource;
+}
+
+export function resolveInsertionSlotFromProjectedRect(
+ orderedNodes: ZFlowGridStackNode[],
+ sourceId: string,
+ columnCount: number,
+ dragRect: ZFlowDragRect,
+ cellWidthPx: number,
+ cellHeightPx: number,
+ previousSlot: number,
+): number {
+ if (cellWidthPx <= 0 || cellHeightPx <= 0) {
+ return previousSlot;
+ }
+
+ const orderedIds = orderedNodes
+ .filter((node) => node.id)
+ .map((node) => node.id as string);
+ const nodesById = new Map(
+ orderedNodes
+ .filter((node): node is ZFlowGridStackNode & { id: string } => !!node.id)
+ .map((node) => [node.id, node]),
+ );
+ const idsWithoutSource = orderedIds.filter((id) => id !== sourceId);
+ const sourceNode = nodesById.get(sourceId);
+
+ if (!sourceNode) return previousSlot;
+
+ let bestSlot = previousSlot;
+ let bestOverlapRatio = 0;
+ let bestProjectedSource: ProjectedNode | undefined;
+
+ for (let slot = 0; slot <= idsWithoutSource.length; slot++) {
+ const candidateIds = reorderByInsertionSlot(orderedIds, sourceId, slot);
+ const candidateNodes = candidateIds
+ .map((id) => nodesById.get(id))
+ .filter(
+ (node): node is ZFlowGridStackNode & { id: string } => !!node,
+ );
+ const projectedSource = projectZFlowLayout(candidateNodes, columnCount).find(
+ (projected) => projected.id === sourceId,
+ );
+
+ if (!projectedSource) continue;
+
+ const overlapAreaPx = getRectOverlapAreaPx(dragRect, {
+ leftPx: projectedSource.x * cellWidthPx,
+ topPx: projectedSource.y * cellHeightPx,
+ widthPx: projectedSource.w * cellWidthPx,
+ heightPx: projectedSource.h * cellHeightPx,
+ });
+ const projectedAreaPx =
+ projectedSource.w *
+ cellWidthPx *
+ projectedSource.h *
+ cellHeightPx;
+ const overlapRatio =
+ projectedAreaPx > 0 ? overlapAreaPx / projectedAreaPx : 0;
+
+ if (
+ overlapRatio > bestOverlapRatio ||
+ (overlapRatio === bestOverlapRatio &&
+ bestProjectedSource &&
+ isSameProjectedPosition(projectedSource, bestProjectedSource) &&
+ slot > bestSlot)
+ ) {
+ bestOverlapRatio = overlapRatio;
+ bestProjectedSource = projectedSource;
+ bestSlot = slot;
+ }
+ }
+
+ if (bestOverlapRatio > PROJECTED_SLOT_OVERLAP_THRESHOLD) return bestSlot;
+
+ return Math.max(0, Math.min(previousSlot, idsWithoutSource.length));
+}
+
+function isSameProjectedPosition(a: ProjectedNode, b: ProjectedNode): boolean {
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
+}
+
+function getRectOverlapAreaPx(a: ZFlowDragRect, b: ZFlowDragRect): number {
+ const overlapWidthPx = Math.max(
+ 0,
+ Math.min(a.leftPx + a.widthPx, b.leftPx + b.widthPx) -
+ Math.max(a.leftPx, b.leftPx),
+ );
+ const overlapHeightPx = Math.max(
+ 0,
+ Math.min(a.topPx + a.heightPx, b.topPx + b.heightPx) -
+ Math.max(a.topPx, b.topPx),
+ );
+
+ return overlapWidthPx * overlapHeightPx;
+}
+
+export function applyProjectedLayout(
+ nodes: ZFlowGridStackNode[],
+ projected: ProjectedNode[],
+): void {
+ for (const proj of projected) {
+ const node = nodes.find((n) => n.id === proj.id);
+ if (!node) continue;
+ if (
+ node.x !== proj.x ||
+ node.y !== proj.y ||
+ node.w !== proj.w ||
+ node.h !== proj.h
+ ) {
+ node.x = proj.x;
+ node.y = proj.y;
+ node.w = proj.w;
+ node.h = proj.h;
+ (node as unknown as { _dirty: boolean })._dirty = true;
+ }
+ }
+}
+
+export function notifyEngine(engine: GridStackEngine): void {
+ (engine as NotifyableGridStackEngine)._notify?.();
+}