From eb01501458737aa0728c0bca7f278dfc2731e5fb Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 21 Jul 2026 17:15:13 +0400 Subject: [PATCH 1/7] feat: change column layout Signed-off-by: Sobyt483 --- .../dashboard/constants/_breakpoints.scss | 6 +- .../dashboard/constants/breakpoints.ts | 78 ++++++++++++++++++- .../dashboard/dashboard.component.ts | 52 ++++++++----- 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss b/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss index 7336a013..efb05f96 100644 --- a/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss +++ b/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss @@ -24,9 +24,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/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index cb954433..7d98a62b 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -1,3 +1,22 @@ +import { ButtonSettings } from '../../models'; +import { DashboardCard } from '../card/dashboard-card.component'; +import { addComponentToRegistry } from '../card/utils'; +import { + CELL_HEIGHT, + COMPACT_BREAKPOINT, + DASHBOARD_BREAKPOINTS, + 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 { Component, ElementRef, @@ -33,24 +52,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'); @@ -212,8 +213,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 +241,18 @@ export class Dashboard implements OnInit, OnDestroy { this.resizeObserver = new ResizeObserver((entries) => { const width = entries[0]?.contentRect.width ?? 0; this.compactToolbar.set(width < COMPACT_BREAKPOINT); + if (width >= XL_PAGE) { + if (!this.isXLPage()) { + this.isXLPage.set(true); + this.cards.set(this.cards().map((c) => ({ ...c, maxW: 3 }))); + } + } else { + if (this.isXLPage()) { + console.log('m'); + this.isXLPage.set(false); + this.cards.set(this.cards().map((c) => ({ ...c, maxW: 4 }))); + } + } }); this.resizeObserver.observe(this.hostEl.nativeElement); window.addEventListener('beforeunload', this.beforeUnloadHandler); From e8a362af75220c11e60da901caf16d46766099dd Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 21 Jul 2026 18:14:46 +0400 Subject: [PATCH 2/7] feat: implement resize changes Signed-off-by: Sobyt483 --- .../dashboard/dashboard.component.scss | 2 ++ .../dashboard/dashboard.component.ts | 2 ++ .../dashboard/dashboard/resize.helpers.ts | 25 +++++++++++++++++++ .../dashboard/stepped-resize-engine.ts | 18 +++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss index 88f8696b..c186e184 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss @@ -8,6 +8,8 @@ top: calc(var(--gs-item-margin-top) * 2) !important; width: calc(100% - 20px); left: 10px !important; + background: var(--sapInformationColor, #0070f2); + opacity: 0.25; } } diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index 7d98a62b..53877a94 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -17,6 +17,7 @@ import { 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, @@ -162,6 +163,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). 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..cb44cd40 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts @@ -0,0 +1,25 @@ +export function getAllowedResizeWidths( + maxWidth: number, + columnCount: number, + minWidth = 1, +): number[] { + return [1, 2, maxWidth] + .map((w) => Math.min(Math.max(w, minWidth), columnCount)) + .filter((w, i, list) => list.indexOf(w) === i) + .sort((a, b) => a - b); +} + +export function resolveResizeWidthStep( + rawWidth: number, + maxWidth: number, + columnCount: number, + minWidth = 1, +): number { + const allowed = getAllowedResizeWidths(maxWidth, columnCount, minWidth); + + 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.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts new file mode 100644 index 00000000..cd5af0f4 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts @@ -0,0 +1,18 @@ +import { GridStackEngine } from 'gridstack/dist/gridstack-engine'; +import type { GridStackMoveOpts, GridStackNode } from 'gridstack'; +import { resolveResizeWidthStep } from './resize.helpers'; + +export class SteppedResizeGridStackEngine extends GridStackEngine { + override moveNodeCheck(node: GridStackNode, opts: GridStackMoveOpts): boolean { + if (opts.resizing && opts.w !== undefined) { + opts.w = resolveResizeWidthStep( + opts.w, + node.maxW ?? this.column, + this.column, + node.minW ?? 1, + ); + } + + return super.moveNodeCheck(node, opts); + } +} From c04e8cda5c7738d8bcd6802b730ba8ef3776972a Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 21 Jul 2026 22:50:22 +0400 Subject: [PATCH 3/7] feat: finish resize task Signed-off-by: Sobyt483 --- .../dashboard/dashboard.component.ts | 39 +++++++++++++------ .../dashboard/dashboard/resize.helpers.ts | 6 ++- .../dashboard/stepped-resize-engine.ts | 2 + 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index 53877a94..a23e5b71 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -243,18 +243,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); - if (width >= XL_PAGE) { - if (!this.isXLPage()) { - this.isXLPage.set(true); - this.cards.set(this.cards().map((c) => ({ ...c, maxW: 3 }))); - } - } else { - if (this.isXLPage()) { - console.log('m'); - this.isXLPage.set(false); - this.cards.set(this.cards().map((c) => ({ ...c, maxW: 4 }))); - } - } + this.changeCardSettingsForXlPage(width); }); this.resizeObserver.observe(this.hostEl.nativeElement); window.addEventListener('beforeunload', this.beforeUnloadHandler); @@ -469,4 +458,30 @@ export class Dashboard implements OnInit, OnDestroy { this.saveCardsPosition(gridStackNodes); } } + + private changeCardSettingsForXlPage(width: number): void { + if (width >= XL_PAGE) { + if (!this.isXLPage()) { + this.isXLPage.set(true); + this.cards.set( + this.cards().map((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.cards.set( + this.cards().map((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.ts b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts index cb44cd40..33643f5e 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts @@ -2,10 +2,11 @@ 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) + .filter((w, i, list) => list.indexOf(w) === i && w <= effectiveMax) .sort((a, b) => a - b); } @@ -14,8 +15,9 @@ export function resolveResizeWidthStep( maxWidth: number, columnCount: number, minWidth = 1, + effectiveMax = columnCount, ): number { - const allowed = getAllowedResizeWidths(maxWidth, columnCount, minWidth); + const allowed = getAllowedResizeWidths(maxWidth, columnCount, minWidth, effectiveMax); return allowed.reduce((best, candidate) => { return Math.abs(candidate - rawWidth) <= Math.abs(best - rawWidth) diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts index cd5af0f4..403f3a33 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts @@ -5,11 +5,13 @@ import { resolveResizeWidthStep } from './resize.helpers'; export class SteppedResizeGridStackEngine extends GridStackEngine { override moveNodeCheck(node: GridStackNode, opts: GridStackMoveOpts): boolean { if (opts.resizing && opts.w !== undefined) { + const effectiveMax = this.column - (node.x ?? 0); opts.w = resolveResizeWidthStep( opts.w, node.maxW ?? this.column, this.column, node.minW ?? 1, + effectiveMax, ); } From fc82ba402f5e3a28253ddbd17a4ed5f84ea5b5eb Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Wed, 22 Jul 2026 00:00:58 +0400 Subject: [PATCH 4/7] feat: add basic implementation z-flow Signed-off-by: Sobyt483 --- .../dashboard/dashboard.component.html | 5 + .../dashboard/dashboard.component.scss | 13 ++ .../dashboard/dashboard.component.ts | 25 ++ .../dashboard/stepped-resize-engine.ts | 219 +++++++++++++++++- .../dashboard/z-flow.helpers.spec.ts | 47 ++++ .../dashboard/dashboard/z-flow.helpers.ts | 186 +++++++++++++++ 6 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts 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) { (null); + /** True once the user has dragged/resized any grid item while in edit mode. */ private gridDirty = signal(false); @@ -430,6 +437,24 @@ export class Dashboard implements OnInit, OnDestroy { this.closeCardPanel(); } + onDragStart(event: { el: Element }): void { + const el = event.el as HTMLElement; + 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.dragOriginStyle.set(null); + } + onGridChange(): void { if (this.editMode()) { this.gridDirty.set(true); diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts index 403f3a33..dd33827f 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts @@ -1,20 +1,215 @@ -import { GridStackEngine } from 'gridstack/dist/gridstack-engine'; -import type { GridStackMoveOpts, GridStackNode } from 'gridstack'; import { resolveResizeWidthStep } from './resize.helpers'; +import { + type ZFlowGridStackNode, + applyProjectedLayout, + getZFlowRowHeight, + notifyEngine, + projectZFlowLayout, + reorderByInsertionSlot, + resolveDropRowFromRect, + resolveInsertionSlotFromRow, + seedNodeOrder, + sortNodesByZFlowOrder, +} 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 && opts.w !== undefined) { - const effectiveMax = this.column - (node.x ?? 0); - opts.w = resolveResizeWidthStep( - opts.w, - node.maxW ?? this.column, - this.column, - node.minW ?? 1, - effectiveMax, - ); + 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); + + seedNodeOrder(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; + } + + 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; + + snapshot.forEach(({ node, x, y, w, h }) => { + if (node.x === x && node.y === y && node.w === w && node.h === h) { + return; + } + + (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 })); + + let insertionSlotInRow: number; + if (rect) { + const rectLeft = + (rect as unknown as { left?: number }).left ?? rect.x ?? 0; + const rectWidth = + (rect as unknown as { width?: number }).width ?? rect.w ?? cellWidth; + const dragCenterX = rectLeft + rectWidth / 2; + insertionSlotInRow = resolveInsertionSlotFromRow( + dragCenterX, + rowNodes, + node.id as string, + cellWidth, + ); + } else { + const targetX = opts.x ?? sourceNode?.x ?? 0; + insertionSlotInRow = rowNodes.filter((n) => { + const proj = projected.find((p) => p.id === n.id); + return proj && proj.x <= targetX && n.id !== node.id; + }).length; + } + + const idsWithoutSource = orderedIds.filter((id) => id !== node.id); + const rowIdsWithoutSource = rowNodes + .filter((n) => n.id && n.id !== node.id) + .map((n) => n.id as string); + + let absoluteSlot: number; + 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 newOrdered = sortNodesByZFlowOrder(nodes); + const newProjected = projectZFlowLayout(newOrdered, this.column); + applyProjectedLayout(nodes, newProjected); + 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..b38b3ad7 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts @@ -0,0 +1,47 @@ +import { + projectZFlowLayout, + resolveDropRowFromRect, + 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); + }); +}); 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..e3f975bc --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts @@ -0,0 +1,186 @@ +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 seedNodeOrder(nodes: ZFlowGridStackNode[]): void { + if (hasZFlowOrder(nodes)) return; + + const sorted = [...nodes].sort((a, b) => { + const ay = a.y ?? 0; + const by = b.y ?? 0; + if (ay !== by) return ay - by; + return (a.x ?? 0) - (b.x ?? 0); + }); + + sorted.forEach((n, i) => { + n.zFlowOrder = i; + }); +} + +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); + }); +} + +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 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 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?.(); +} From d6f41579fecf4f346766636e833035b38374052b Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Wed, 22 Jul 2026 01:28:26 +0400 Subject: [PATCH 5/7] feat: finish z-flow Signed-off-by: Sobyt483 --- .../card/dashboard-card.component.html | 4 +- .../card/dashboard-card.component.ts | 3 + .../dashboard/constants/constants.ts | 4 + .../dashboard/dashboard.component.scss | 18 ++- .../dashboard/dashboard.component.ts | 53 +++++--- .../dashboard/stepped-resize-engine.ts | 103 ++++++++++----- .../dashboard/dashboard/z-flow.helpers.ts | 118 ++++++++++++++++-- 7 files changed, 244 insertions(+), 59 deletions(-) diff --git a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.html b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.html index 7c806b2d..11b460cc 100644 --- a/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.html +++ b/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.html @@ -23,7 +23,7 @@ /> }
@@ -32,7 +32,7 @@ } @else {
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/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.scss b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss index 322499f2..93a37368 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss @@ -2,6 +2,9 @@ @import 'gridstack/dist/gridstack.min.css'; .grid-stack-placeholder { + z-index: 2; + pointer-events: none; + .placeholder-content { border-radius: 16px; height: calc(100% - 30px); @@ -10,6 +13,7 @@ left: 10px !important; background: var(--sapInformationColor, #0070f2); opacity: 0.25; + z-index: 2 !important; } } @@ -175,11 +179,19 @@ mfp-wc-dashboard { .mfp-dashboard__drag-origin-placeholder { position: absolute; - background: var(--sapNeutralColor, #6a6d75); - opacity: 0.3; - border-radius: 16px; pointer-events: none; z-index: 1; + + &::before { + content: ''; + position: absolute; + top: -5px; + width: calc(100% - 10px); + height: 100%; + background: var(--sapNeutralColor, #6a6d75); + border-radius: 16px; + opacity: 0.3; + } } gridstack { diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index be51bde9..649e9520 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -5,6 +5,7 @@ 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'; @@ -438,7 +439,7 @@ export class Dashboard implements OnInit, OnDestroy { } onDragStart(event: { el: Element }): void { - const el = event.el as HTMLElement; + const el = this.getDragOriginElement(event.el); const gridEl = this.gridStackItems().el as HTMLElement; const gridRect = gridEl.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); @@ -452,6 +453,7 @@ export class Dashboard implements OnInit, OnDestroy { } onDragStop(): void { + this.getZFlowEngine()?.commitZFlowLayout(); this.dragOriginStyle.set(null); } @@ -461,6 +463,13 @@ export class Dashboard implements OnInit, OnDestroy { } } + 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) { @@ -484,28 +493,42 @@ export class Dashboard implements OnInit, OnDestroy { } } + 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.cards.set( - this.cards().map((c) => ({ - ...c, - w: c.w === 4 ? 3 : c.w, - maxW: c.maxW === 4 ? 3 : c.maxW, - })), - ); + 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.cards.set( - this.cards().map((c) => ({ - ...c, - w: c.w === 3 ? 4 : c.w, - maxW: c.maxW === 3 ? 4 : c.maxW, - })), - ); + 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/stepped-resize-engine.ts b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts index dd33827f..52f2b3fa 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.ts @@ -7,9 +7,10 @@ import { projectZFlowLayout, reorderByInsertionSlot, resolveDropRowFromRect, - resolveInsertionSlotFromRow, + resolveInsertionSlotFromProjectedRect, seedNodeOrder, sortNodesByZFlowOrder, + syncNodeOrderFromLayout, } from './z-flow.helpers'; import type { GridStackMoveOpts, GridStackNode } from 'gridstack'; import { GridStackEngine } from 'gridstack/dist/gridstack-engine'; @@ -42,13 +43,14 @@ export class SteppedResizeGridStackEngine extends GridStackEngine { node: GridStackNode, opts: GridStackMoveOpts, ): boolean { - if (!node.id || opts.w === undefined) return super.moveNodeCheck(node, opts); + 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); - seedNodeOrder(nodes); + syncNodeOrderFromLayout(nodes); const snapshot = this.takeLayoutSnapshot(nodes); const effectiveMax = this.column - (sourceNode.x ?? 0); @@ -77,6 +79,26 @@ export class SteppedResizeGridStackEngine extends GridStackEngine { 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, @@ -90,14 +112,14 @@ export class SteppedResizeGridStackEngine extends GridStackEngine { private markLayoutChangesDirty(snapshot: LayoutSnapshot[]): boolean { let changed = false; - snapshot.forEach(({ node, x, y, w, h }) => { + for (const { node, x, y, w, h } of snapshot) { if (node.x === x && node.y === y && node.w === w && node.h === h) { - return; + continue; } (node as unknown as { _dirty: boolean })._dirty = true; changed = true; - }); + } return changed; } @@ -154,40 +176,52 @@ export class SteppedResizeGridStackEngine extends GridStackEngine { .filter((p) => p.row === targetRow) .map((p) => ({ id: p.id, x: p.x, w: p.w })); - let insertionSlotInRow: number; + 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 dragCenterX = rectLeft + rectWidth / 2; - insertionSlotInRow = resolveInsertionSlotFromRow( - dragCenterX, - rowNodes, + 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; - insertionSlotInRow = rowNodes.filter((n) => { + 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 idsWithoutSource = orderedIds.filter((id) => id !== node.id); - const rowIdsWithoutSource = rowNodes - .filter((n) => n.id && n.id !== node.id) - .map((n) => n.id as string); - let absoluteSlot: number; - if (rowIdsWithoutSource.length === 0) { - absoluteSlot = orderedIds.indexOf(node.id as string); - } else { - const firstRowIdxInFull = idsWithoutSource.indexOf( - rowIdsWithoutSource[0], - ); - absoluteSlot = firstRowIdxInFull + insertionSlotInRow; + 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)); @@ -205,10 +239,19 @@ export class SteppedResizeGridStackEngine extends GridStackEngine { if (n) n.zFlowOrder = idx; }); - const newOrdered = sortNodesByZFlowOrder(nodes); - const newProjected = projectZFlowLayout(newOrdered, this.column); - applyProjectedLayout(nodes, newProjected); - notifyEngine(this); + 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.ts b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts index e3f975bc..a05a7301 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts @@ -13,21 +13,20 @@ export function hasZFlowOrder(nodes: ZFlowGridStackNode[]): boolean { return nodes.some((n) => n.zFlowOrder !== undefined); } -export function seedNodeOrder(nodes: ZFlowGridStackNode[]): void { - if (hasZFlowOrder(nodes)) return; - - const sorted = [...nodes].sort((a, b) => { - const ay = a.y ?? 0; - const by = b.y ?? 0; - if (ay !== by) return ay - by; - return (a.x ?? 0) - (b.x ?? 0); - }); +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[] { @@ -44,6 +43,16 @@ export function sortNodesByZFlowOrder( }); } +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; @@ -59,6 +68,15 @@ export interface ZFlowRowItem { 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); } @@ -159,6 +177,88 @@ export function reorderByInsertionSlot( 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; + + 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) { + bestOverlapRatio = overlapRatio; + bestSlot = slot; + } + } + + if (bestOverlapRatio > PROJECTED_SLOT_OVERLAP_THRESHOLD) return bestSlot; + + return Math.max(0, Math.min(previousSlot, idsWithoutSource.length)); +} + +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[], From b58a42b36802ac5b2d0fa0de5bdf7bcd106a8793 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Wed, 22 Jul 2026 22:35:19 +0400 Subject: [PATCH 6/7] feat: fix some bugs & add tests Signed-off-by: Sobyt483 --- .../card/dashboard-card.component.spec.ts | 33 +++ .../dashboard/dashboard.component.spec.ts | 254 +++++++++++++++--- .../dashboard/dashboard.component.ts | 2 + .../dashboard/stepped-resize-engine.spec.ts | 178 ++++++++++++ .../dashboard/z-flow.helpers.spec.ts | 79 ++++++ .../dashboard/dashboard/z-flow.helpers.ts | 14 +- 6 files changed, 528 insertions(+), 32 deletions(-) create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/stepped-resize-engine.spec.ts 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/dashboard/dashboard.component.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts index ff5f1c94..2c284357 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts @@ -1,7 +1,11 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { resetDashboardCardRegistry } from '../card/utils/dashboard-card-registry'; +import { DASHBOARD_CARD_DRAG_ORIGIN_CLASS } from '../constants'; import { CardConfig, SectionConfig } from '../models'; import { Dashboard } from './dashboard.component'; +import { SteppedResizeGridStackEngine } from './stepped-resize-engine'; +import type { ZFlowGridStackNode } from './z-flow.helpers'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { GridStackMoveOpts, GridStackNode } from 'gridstack'; vi.mock('gridstack', () => ({})); @@ -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 649e9520..1981645e 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -439,6 +439,8 @@ export class Dashboard implements OnInit, OnDestroy { } 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(); 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/z-flow.helpers.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts index b38b3ad7..e2f1841a 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.spec.ts @@ -1,6 +1,7 @@ import { projectZFlowLayout, resolveDropRowFromRect, + resolveInsertionSlotFromProjectedRect, resolveInsertionSlotFromRow, } from './z-flow.helpers'; @@ -44,4 +45,82 @@ describe('z-flow helpers', () => { 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 index a05a7301..eb60a927 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/z-flow.helpers.ts @@ -205,6 +205,7 @@ export function resolveInsertionSlotFromProjectedRect( let bestSlot = previousSlot; let bestOverlapRatio = 0; + let bestProjectedSource: ProjectedNode | undefined; for (let slot = 0; slot <= idsWithoutSource.length; slot++) { const candidateIds = reorderByInsertionSlot(orderedIds, sourceId, slot); @@ -233,8 +234,15 @@ export function resolveInsertionSlotFromProjectedRect( const overlapRatio = projectedAreaPx > 0 ? overlapAreaPx / projectedAreaPx : 0; - if (overlapRatio > bestOverlapRatio) { + if ( + overlapRatio > bestOverlapRatio || + (overlapRatio === bestOverlapRatio && + bestProjectedSource && + isSameProjectedPosition(projectedSource, bestProjectedSource) && + slot > bestSlot) + ) { bestOverlapRatio = overlapRatio; + bestProjectedSource = projectedSource; bestSlot = slot; } } @@ -244,6 +252,10 @@ export function resolveInsertionSlotFromProjectedRect( 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, From 641b14106263f70ecb07abba6d62f65eb85ba5cc Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Thu, 23 Jul 2026 18:48:11 +0400 Subject: [PATCH 7/7] feat: address pr coments Signed-off-by: Sobyt483 --- .../dashboard/constants/_breakpoints.scss | 11 -- .../dashboard/resize.helpers.spec.ts | 171 ++++++++++++++++++ .../dashboard/dashboard/resize.helpers.ts | 4 + 3 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.spec.ts diff --git a/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss b/projects/ngx/declarative-ui/dashboard/constants/_breakpoints.scss index efb05f96..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; 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 index 33643f5e..c772a2a3 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/resize.helpers.ts @@ -19,6 +19,10 @@ export function resolveResizeWidthStep( ): 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