diff --git a/docs/dashboard.md b/docs/dashboard.md index bcc6d261..145f1273 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -51,7 +51,7 @@ import { DashboardConfig, SectionConfig, VisitedServiceCard, -} from '@openmfp/webcomponents'; +} from '@openmfp/ngx'; Dashboard.registerAngularComponents([VisitedServiceCard]); @@ -204,23 +204,33 @@ const cards: CardConfig[] = [ | -------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | | `saved` | `{ sections: SectionConfig[]; cards: CardConfig[] }` | Emits when the user saves edits | | `actionButtonClick` | `{ event: MouseEvent; action: ButtonSettings }` | Emits when a custom action button from `config.customActions` is clicked | +| `unsavedChangesChange` | `boolean` | Emits whenever the unsaved-changes state flips — `true` when the user first makes an unsaved edit, `false` after save/discard. Use this to drive your own navigation guard (see [Showing your own dialog instead](#showing-your-own-dialog-instead)). | ### Public methods | Method | Returns | Description | | ------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------ | | `requestNavigation(proceed: () => void)` | `boolean` | Framework-agnostic navigation guard — see [Unsaved-changes guard](#unsaved-changes-guard). | +| `saveEdit()` | `void` | Persists changes (fires the `saved` event) and exits edit mode. | +| `cancelEdit()` | `void` | Requests to leave edit mode. Opens `DiscardChangesDialog` if there are unsaved changes; otherwise discards immediately. | +| `confirmDiscard()` | `void` | Confirms the discard, closes `DiscardChangesDialog`, and reverts to the snapshot taken on entering edit mode. | +| `onUnsavedNavSave()` | `void` | Save handler for a custom in-app-navigation dialog — closes the popup, saves, then resumes the queued navigation. | +| `onUnsavedNavDiscard()` | `void` | Discard handler for a custom in-app-navigation dialog — closes the popup, reverts, then resumes the queued navigation. | +| `onUnsavedNavCancel()` | `void` | Cancel handler for a custom in-app-navigation dialog — closes the popup and drops the queued navigation. | | `Dashboard.registerAngularComponents(types[])` | `void` | Static — registers standalone Angular card components by their element selector name. | +> **Web-component consumers:** `@angular/elements` only proxies inputs and outputs onto the custom element — instance methods are **not** reachable on the DOM node by default. The dashboard's WC bundle (`mfp-wc-dashboard.js`) explicitly forwards all of the methods above onto ``, so they are callable directly on the DOM element (e.g. `document.querySelector('mfp-wc-dashboard').saveEdit()`). If the Angular component has not been created yet, `requestNavigation()` runs its callback synchronously and returns `true`, and the void handlers are no-ops. + ### Reactive state | Signal | Type | Description | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `hasUnsavedChanges()` | `computed` | `true` while the user is in edit mode AND has changed sections, cards, or grid positions. Resets after save / discard. | | `editMode()` | `signal` | `true` while the user is in the dashboard's edit mode. | | `unsavedNavDialogOpen()`| `signal` | `true` while the unsaved-changes navigation popup is shown. Driven by `requestNavigation()`; consumers normally don't read it directly. | | `discardDialogOpen()` | `signal` | `true` while the discard-confirmation popup (Cancel button on the edit-bar) is shown. | +> **Note:** The dashboard also tracks a `hasUnsavedChanges` computed internally (`true` while the user is in edit mode AND has changed sections, cards, or grid positions; resets after save / discard), but it is `protected` and **not** readable from a consumer's dashboard reference. To react to that state from your own code, listen to the [`unsavedChangesChange`](#outputs) output instead. + --- ## Localization @@ -272,10 +282,6 @@ The dashboard does **not** translate consumer-supplied strings — those are pas Translate these in your application before passing them to the dashboard. -### Standalone dialog reuse - -``, ``, and `` are exported on the public API for reuse outside the dashboard. When mounted standalone, each dialog accepts its own `language` input (default falls back to `'en'`); when nested inside ``, the input is ignored and the dashboard's shared language wins. - --- ## EditCardsDialog @@ -289,7 +295,6 @@ The `EditCardsDialog` component (`mfp-edit-cards-dialog`) is rendered inside the | `availableCards` | `CardConfig[]` | `[]` | Full list of cards the user may add/remove | | `addedCardsIds` | `Set` | `new Set()` | IDs of cards currently on the dashboard | | `open` | `boolean` | `false` | Controls dialog visibility | -| `language` | `'en' \| 'de' \| null` | `null` | Optional standalone-only override; ignored when nested in ``. | ### Outputs @@ -298,12 +303,6 @@ The `EditCardsDialog` component (`mfp-edit-cards-dialog`) is rendered inside the | `confirm` | `{ added: CardConfig[]; removed: string[] }` | Emits the diff when the user clicks **Save** | | `cancelled` | `void` | Emits when the user clicks **Cancel** or presses Esc | -### Static methods - -| Method | Description | -| ----------------------------- | --------------------------------------------------------------------------- | -| `registerAngularComponents()` | Registers standalone Angular card components by their element selector name | - --- ## Unsaved-changes guard @@ -419,8 +418,8 @@ const proceeded = dashboardEl.requestNavigation(() => { The built-in `UnsavedChangesDialog` covers the common case (Save / Discard / Cancel). If the host app needs a different look, copy, or behaviour, the dashboard exposes the primitives so you can replace the popup entirely: -1. Read the `hasUnsavedChanges()` computed signal in your own navigation interceptor. -2. If it is `true`, suppress the navigation, render your own dialog (any framework, any styling), and based on the user's choice call one of: +1. Track the unsaved-changes state via the `unsavedChangesChange` output. +2. While it is `true`, suppress the navigation, render your own dialog (any framework, any styling), and based on the user's choice call one of: - `dashboard.saveEdit()` — persist (fires the `saved` event) and exit edit mode. - The dashboard does not currently expose a public `discardEdit()` method. The simplest way to discard from outside is `dashboard.cancelEdit()` — it opens `DiscardChangesDialog` if there are unsaved changes; you can then drive `confirmDiscard()` programmatically. If you want to discard without any popup at all, prefer skipping the in-app navigation and relying on `requestNavigation()` instead. 3. If you do want to keep the dashboard in charge of the popup but swap the **dialog UI only**, you can hide the default dialog by overriding its CSS in your shadow-DOM-piercing stylesheet and rendering your own component bound to `unsavedNavDialogOpen()`, then calling `onUnsavedNavSave()`, `onUnsavedNavDiscard()`, or `onUnsavedNavCancel()` from your buttons. The handlers are the same ones the built-in dialog uses, so behaviour stays consistent. @@ -441,7 +440,7 @@ This fires only while `hasUnsavedChanges()` is true; the listener is removed whe Three-button popup driven by `requestNavigation()`: -- Header: warning icon + "Unsaved Changes" +- Header: "Unsaved Changes" (``; the dialog uses `state="Critical"` for the accent — there is no icon) - Body: "You are leaving this page. Save or discard the changes to proceed. This action cannot be undone." - Buttons: **Save** (Emphasized) / **Discard** (Transparent) / **Cancel** (Transparent) @@ -449,7 +448,7 @@ Three-button popup driven by `requestNavigation()`: Two-button popup shown when the user clicks the Cancel button on the in-page edit toolbar with unsaved changes: -- Header: warning icon + "Discard Changes" +- Header: "Discard Changes" (``; the dialog uses `state="Critical"` for the accent — there is no icon) - Body: "Discard the changes? This action cannot be undone." - Buttons: **Discard** (Emphasized) / **Cancel** (Transparent) @@ -457,14 +456,13 @@ Two-button popup shown when the user clicks the Cancel button on the in-page edi ## DiscardChangesDialog -`` — confirmation popup the dashboard pops when the user clicks Cancel on the edit-bar with unsaved changes. It is rendered automatically by ``; the standalone component is exported so it can be reused outside the dashboard if you need the same confirmation pattern elsewhere. +`` — confirmation popup the dashboard pops when the user clicks Cancel on the edit-bar with unsaved changes. It is rendered automatically by `` and is not part of the public API — the tag and API below document the dashboard's internal behaviour. ### Inputs | Input | Type | Default | Description | | ---------- | ----------------------- | ------- | ---------------------------------------------------------------------------- | | `open` | `boolean` | `false` | Controls dialog visibility | -| `language` | `'en' \| 'de' \| null` | `null` | Optional standalone-only override; ignored when nested in ``. | ### Outputs @@ -477,14 +475,13 @@ Two-button popup shown when the user clicks the Cancel button on the in-page edi ## UnsavedChangesDialog -`` — three-button popup the dashboard pops when an in-app navigation is intercepted via `requestNavigation()`. Like `DiscardChangesDialog`, the component is exported standalone and can be reused. +`` — three-button popup the dashboard pops when an in-app navigation is intercepted via `requestNavigation()`. Like `DiscardChangesDialog`, it is rendered automatically by `` and is not part of the public API. ### Inputs | Input | Type | Default | Description | | ---------- | ----------------------- | ------- | ---------------------------------------------------------------------------- | | `open` | `boolean` | `false` | Controls dialog visibility | -| `language` | `'en' \| 'de' \| null` | `null` | Optional standalone-only override; ignored when nested in ``. | ### Outputs @@ -675,7 +672,7 @@ interface CardConfig { ``` For sections, `w` controls the column span while height is determined by the section content. -For cards, `w` and `h` control the initial rendered grid span. When edit mode is saved, `x`, `y`, `w`, and `h` are all persisted in the `saved` event payload — resizing a card updates its dimensions and dragging updates its position. `minH`/`minW` and `maxH`/`maxW` set hard resize bounds enforced by the grid — the user cannot drag a card below the minimum or above the maximum size in edit mode. +For cards, `w` and `h` control the initial rendered grid span. When edit mode is saved, each card's `w` and `h` are persisted in the `saved` event payload. Position (`x`, `y`) is only persisted for **loose** cards (those without a `sectionId`) — section cards are laid out by their section and do not carry `x`/`y`. Note that a loose card's `h` may be recomputed by the grid's `sizeToContent` behaviour. `minH`/`minW` and `maxH`/`maxW` set hard resize bounds enforced by the grid — the user cannot drag a card below the minimum or above the maximum size in edit mode. `component` and `type` work together to determine how the card is rendered: @@ -717,6 +714,8 @@ If `window.sap` is not available when the card is rendered, an error is logged a All interactive elements carry `data-testid` attributes for reliable E2E targeting. See [docs/test-ids.md](./test-ids.md) for the full naming convention. +> **Shadow DOM caveat:** The three dialogs (`EditCardsDialog`, `DiscardChangesDialog`, `UnsavedChangesDialog`) use `ViewEncapsulation.ShadowDom`, so their `data-testid` elements live inside a shadow root. A plain `getByTestId()` / `document.querySelector('[data-testid=…]')` will **not** reach them — you must first query the dialog's host element and then pierce its `shadowRoot` (or use a testing tool that traverses shadow boundaries). + ### Main component | Element | `data-testid` | Notes | diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.spec.ts new file mode 100644 index 00000000..984f26b3 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.spec.ts @@ -0,0 +1,97 @@ +import { defineDashboardElementMethods } from './dashboard-element-methods'; +import type { Dashboard } from './dashboard.component'; + +/** + * Builds a fake custom-element class plus a stub {@link Dashboard} instance, and + * wires the two together the way `@angular/elements` does (`ngElementStrategy. + * componentRef.instance`). `connectInstance` lets a test decide whether the + * element already has a live component behind it. + */ +function setup() { + class FakeElement { + ngElementStrategy?: { componentRef?: { instance?: Dashboard } }; + } + defineDashboardElementMethods(FakeElement as unknown as CustomElementConstructor); + + const instance = { + // Mimic the "unsaved changes → dialog opened, do not navigate" path. + requestNavigation: vi.fn(() => false), + saveEdit: vi.fn(), + cancelEdit: vi.fn(), + confirmDiscard: vi.fn(), + onUnsavedNavSave: vi.fn(), + onUnsavedNavDiscard: vi.fn(), + onUnsavedNavCancel: vi.fn(), + } as unknown as Dashboard; + + const element = new FakeElement() as FakeElement & + Record unknown>; + + const connectInstance = () => { + element.ngElementStrategy = { componentRef: { instance } }; + }; + + return { element, instance, connectInstance }; +} + +const VOID_METHODS = [ + 'saveEdit', + 'cancelEdit', + 'confirmDiscard', + 'onUnsavedNavSave', + 'onUnsavedNavDiscard', + 'onUnsavedNavCancel', +] as const; + +describe('defineDashboardElementMethods', () => { + it('defines requestNavigation and every void handler on the prototype', () => { + const { element } = setup(); + + expect(typeof element['requestNavigation']).toBe('function'); + for (const name of VOID_METHODS) { + expect(typeof element[name]).toBe('function'); + } + }); + + describe('when the Angular component instance exists', () => { + it('delegates requestNavigation to the instance and returns its result', () => { + const { element, instance, connectInstance } = setup(); + connectInstance(); + const proceed = vi.fn(); + + const result = element['requestNavigation'](proceed); + + expect(instance.requestNavigation).toHaveBeenCalledWith(proceed); + // The stub reports "dialog opened", so the DOM call must NOT navigate. + expect(result).toBe(false); + expect(proceed).not.toHaveBeenCalled(); + }); + + it.each(VOID_METHODS)('delegates %s to the instance', (name) => { + const { element, instance, connectInstance } = setup(); + connectInstance(); + + element[name](); + + expect(instance[name]).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the Angular component instance is not yet created', () => { + it('runs the requestNavigation callback synchronously and returns true', () => { + const { element } = setup(); + const proceed = vi.fn(); + + const result = element['requestNavigation'](proceed); + + expect(proceed).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it.each(VOID_METHODS)('no-ops %s without throwing', (name) => { + const { element } = setup(); + + expect(() => element[name]()).not.toThrow(); + }); + }); +}); diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.ts new file mode 100644 index 00000000..5421b6f9 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard-element-methods.ts @@ -0,0 +1,65 @@ +import type { Dashboard } from './dashboard.component'; + +/** + * Public no-argument, `void`-returning handlers on {@link Dashboard} that we + * forward onto the custom element so non-Angular consumers can drive the + * edit-mode / unsaved-changes flow directly on the DOM node. + */ +const VOID_METHODS = [ + 'saveEdit', + 'cancelEdit', + 'confirmDiscard', + 'onUnsavedNavSave', + 'onUnsavedNavDiscard', + 'onUnsavedNavCancel', +] as const satisfies readonly (keyof Dashboard)[]; + +/** Reads the live Angular component instance backing an `@angular/elements` custom element. */ +function getInstance(element: unknown): Dashboard | undefined { + return ( + element as { + ngElementStrategy?: { componentRef?: { instance?: Dashboard } }; + } + ).ngElementStrategy?.componentRef?.instance; +} + +/** + * `createCustomElement` only proxies `@Input()`/`output()` — public methods on + * the component class are NOT reachable from the DOM. This forwards the + * dashboard's public methods onto the custom-element prototype so that + * non-Angular consumers (UI5, plain JS, Luigi, etc.) can call them directly on + * the `` DOM node. + * + * `requestNavigation` needs a synchronous fallback when the Angular component + * isn't created yet: run the navigation immediately (returning `true`) rather + * than silently blocking the user — this preserves the original, pre-guard + * behaviour. The remaining handlers no-op until the component exists. + */ +export function defineDashboardElementMethods( + elementCtor: CustomElementConstructor, +): void { + const proto = elementCtor.prototype; + + Object.defineProperty(proto, 'requestNavigation', { + value(proceed: () => void): boolean { + const instance = getInstance(this); + if (!instance) { + proceed(); + return true; + } + return instance.requestNavigation(proceed); + }, + configurable: true, + writable: true, + }); + + for (const name of VOID_METHODS) { + Object.defineProperty(proto, name, { + value(): void { + getInstance(this)?.[name](); + }, + configurable: true, + writable: true, + }); + } +} diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html index 5bb06b22..deddedc4 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html @@ -1,10 +1,20 @@ -
+
@if (config().title) { - + } @@ -22,7 +32,12 @@ }
@if (config().description) { - + @if (dragOriginStyle()) { -
+
} @for (card of looseCards(); track card.id) { @@ -196,16 +214,21 @@ } -
@if (editMode()) {
- {{ - i18n.getTranslation(i18nKeys.SAVE) - }} - {{ - i18n.getTranslation(i18nKeys.CANCEL) - }} + {{ i18n.getTranslation(i18nKeys.SAVE) }} + {{ i18n.getTranslation(i18nKeys.CANCEL) }}
} 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 f87470fb..631433e0 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts @@ -449,8 +449,9 @@ describe('Dashboard', () => { }); it('adds new cards and closes the panel', () => { - const { component } = setup(); + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'T' }); component.cards.set([{ id: 'card-1', component: 'mfp-a' }]); component.cardDialogOpen.set(true); @@ -478,6 +479,31 @@ describe('Dashboard', () => { expect(component.cardDialogOpen()).toBe(false); }); + it('preserves and commits z-flow around edit-card dialog changes', () => { + const { fixture, component } = setup(); + const engine = new ZflowGridStackEngine({ column: 4, nodes: [] }); + const syncOrder = vi.spyOn(engine, 'syncZFlowOrderFromLayout'); + const commitLayout = vi.spyOn(engine, 'commitZFlowLayout'); + + fixture.componentRef.setInput('config', { + title: 'T', + zFlow: { cardHeight: 40 }, + }); + component.cards.set([{ id: 'card-1', component: 'mfp-a' }]); + (component as unknown as { gridStack: () => unknown }).gridStack = () => ({ + grid: { engine }, + }); + + component.onCardsEdited({ + added: [{ id: 'card-2', component: 'mfp-b' }], + removed: [], + }); + component.onGridChange(); + + expect(syncOrder).toHaveBeenCalledOnce(); + expect(commitLayout).toHaveBeenCalledOnce(); + }); + it('positions the drag origin placeholder from the dragged grid item', () => { const { fixture, component } = setup(); fixture.componentRef.setInput('config', { @@ -612,8 +638,9 @@ describe('Dashboard', () => { }); it('still closes the panel when no changes are made', () => { - const { component } = setup(); + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'T' }); component.cardDialogOpen.set(true); component.cards.set([{ id: 'card-1', component: 'mfp-a' }]); @@ -624,8 +651,9 @@ describe('Dashboard', () => { }); it('removes cards by id and closes the panel when onCardsEdited receives removed ids', () => { - const { component } = setup(); + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'T' }); component.cards.set([ { id: 'card-1', component: 'mfp-a' }, { id: 'card-2', component: 'mfp-b' }, diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index 6ee9dd0f..3846fc76 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -449,6 +449,15 @@ export class Dashboard implements OnInit, OnDestroy { } onCardsEdited(event: { added: CardConfig[]; removed: string[] }): void { + const looseCardIds = new Set(this.looseCards().map((c) => c.id)); + + const hasLooseCardChanges = + event.added.some((card) => !card.sectionId) || + event.removed.some((id) => looseCardIds.has(id)); + if (hasLooseCardChanges) { + this.getZFlowEngine()?.syncZFlowOrderFromLayout(); + } + this.cards.update((list) => { const withoutRemoved = list.filter((c) => !event.removed.includes(c.id)); return [...withoutRemoved, ...event.added.map((ac) => ({ ...ac }))]; @@ -482,6 +491,7 @@ export class Dashboard implements OnInit, OnDestroy { } onGridChange(): void { + this.getZFlowEngine()?.commitZFlowLayout(); if (this.editMode()) { this.gridDirty.set(true); } diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.spec.ts b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.spec.ts index 3f1efd53..18f42a00 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.spec.ts @@ -16,7 +16,100 @@ function createEngine(nodes: ZFlowGridStackNode[]): { return { engine, onChange }; } +function withInternalIds(nodes: ZFlowGridStackNode[]): ZFlowGridStackNode[] { + nodes.forEach((node, index) => { + (node as unknown as { _id: number })._id = index + 1; + }); + return nodes; +} + describe('SteppedResizeGridStackEngine', () => { + it('preserves z-flow when a card is removed during a GridStack batch update', () => { + const nodes = withInternalIds([ + { id: 'favorites', x: 0, y: 0, w: 1, h: 10 }, + { id: 'recent', x: 1, y: 0, w: 1, h: 10 }, + { id: 'resource', x: 2, y: 0, w: 1, h: 10 }, + { id: 'cost', x: 3, y: 0, w: 1, h: 10 }, + { id: 'team', x: 0, y: 10, w: 1, h: 10 }, + { id: 'quick', x: 1, y: 10, w: 1, h: 10 }, + ]); + const { engine } = createEngine(nodes); + + engine.syncZFlowOrderFromLayout(); + engine.batchUpdate(); + engine.removeNode(nodes[0]); + engine.batchUpdate(false); + engine.commitZFlowLayout(); + + expect( + engine.nodes + .map((node) => ({ + id: node.id, + x: node.x, + y: node.y, + zFlowOrder: (node as ZFlowGridStackNode).zFlowOrder, + })) + .sort( + (a, b) => + (a.zFlowOrder ?? Number.MAX_SAFE_INTEGER) - + (b.zFlowOrder ?? Number.MAX_SAFE_INTEGER), + ), + ).toEqual([ + { id: 'recent', x: 0, y: 0, zFlowOrder: 0 }, + { id: 'resource', x: 1, y: 0, zFlowOrder: 1 }, + { id: 'cost', x: 2, y: 0, zFlowOrder: 2 }, + { id: 'team', x: 3, y: 0, zFlowOrder: 3 }, + { id: 'quick', x: 0, y: 10, zFlowOrder: 4 }, + ]); + }); + + it('appends a card added during a GridStack batch update to z-flow', () => { + const nodes = withInternalIds([ + { id: 'recent', x: 0, y: 0, w: 1, h: 10 }, + { id: 'resource', x: 1, y: 0, w: 1, h: 10 }, + { id: 'cost', x: 2, y: 0, w: 1, h: 10 }, + { id: 'team', x: 3, y: 0, w: 1, h: 10 }, + { id: 'quick', x: 0, y: 10, w: 1, h: 10 }, + ]); + const { engine } = createEngine(nodes); + const added: ZFlowGridStackNode = { + id: 'favorites', + x: 0, + y: 0, + w: 1, + h: 10, + }; + (added as unknown as { _id: number })._id = 6; + + engine.syncZFlowOrderFromLayout(); + engine.batchUpdate(); + engine.addNode(added); + engine.batchUpdate(false); + engine.commitZFlowLayout(); + + expect( + engine.nodes + .map((node) => ({ + id: node.id, + x: node.x, + y: node.y, + zFlowOrder: (node as ZFlowGridStackNode).zFlowOrder, + })) + .sort( + (a, b) => + (a.zFlowOrder ?? Number.MAX_SAFE_INTEGER) - + (b.zFlowOrder ?? Number.MAX_SAFE_INTEGER), + ), + ).toEqual([ + { id: 'recent', x: 0, y: 0, zFlowOrder: 0 }, + { id: 'resource', x: 1, y: 0, zFlowOrder: 1 }, + { id: 'cost', x: 2, y: 0, zFlowOrder: 2 }, + { id: 'team', x: 3, y: 0, zFlowOrder: 3 }, + { id: 'quick', x: 0, y: 10, zFlowOrder: 4 }, + { id: 'favorites', x: 1, y: 10, zFlowOrder: 5 }, + ]); + }); + it('keeps non-dragged nodes visually frozen during z-flow drag', () => { const nodes: ZFlowGridStackNode[] = [ { id: 'a', x: 0, y: 0, w: 2, h: 10 }, diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.ts b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.ts index b5e8d82b..462e420b 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow-engine.ts @@ -3,6 +3,7 @@ import { type ZFlowGridStackNode, applyProjectedLayout, getZFlowRowHeight, + normalizeNodeOrder, notifyEngine, projectZFlowLayout, reorderByInsertionSlot, @@ -81,7 +82,7 @@ export class ZflowGridStackEngine extends GridStackEngine { commitZFlowLayout(): boolean { const nodes = this.nodes as ZFlowGridStackNode[]; - seedNodeOrder(nodes); + normalizeNodeOrder(nodes); const snapshot = this.takeLayoutSnapshot(nodes); const ordered = sortNodesByZFlowOrder(nodes); diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow.helpers.ts b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow.helpers.ts index eb60a927..5809950c 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow.helpers.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/engines/zflow/z-flow.helpers.ts @@ -27,6 +27,13 @@ export function seedNodeOrder(nodes: ZFlowGridStackNode[]): void { syncNodeOrderFromLayout(nodes); } +export function normalizeNodeOrder(nodes: ZFlowGridStackNode[]): void { + seedNodeOrder(nodes); + sortNodesByZFlowOrder(nodes).forEach((node, index) => { + node.zFlowOrder = index; + }); +} + export function sortNodesByZFlowOrder( nodes: ZFlowGridStackNode[], ): ZFlowGridStackNode[] { @@ -211,12 +218,11 @@ export function resolveInsertionSlotFromProjectedRect( 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, - ); + .filter((node): node is ZFlowGridStackNode & { id: string } => !!node); + const projectedSource = projectZFlowLayout( + candidateNodes, + columnCount, + ).find((projected) => projected.id === sourceId); if (!projectedSource) continue; @@ -227,10 +233,7 @@ export function resolveInsertionSlotFromProjectedRect( heightPx: projectedSource.h * cellHeightPx, }); const projectedAreaPx = - projectedSource.w * - cellWidthPx * - projectedSource.h * - cellHeightPx; + projectedSource.w * cellWidthPx * projectedSource.h * cellHeightPx; const overlapRatio = projectedAreaPx > 0 ? overlapAreaPx / projectedAreaPx : 0; diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/index.ts b/projects/ngx/declarative-ui/dashboard/dashboard/index.ts index 28b29961..7daa7323 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/index.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/index.ts @@ -1 +1,2 @@ export * from './dashboard.component'; +export * from './dashboard-element-methods'; diff --git a/projects/ngx/declarative-ui/dashboard/models/dashboard.model.ts b/projects/ngx/declarative-ui/dashboard/models/dashboard.model.ts index 6349191e..1b874226 100644 --- a/projects/ngx/declarative-ui/dashboard/models/dashboard.model.ts +++ b/projects/ngx/declarative-ui/dashboard/models/dashboard.model.ts @@ -44,7 +44,7 @@ export interface CardConfig { type?: CardsType; /** Key/value pairs passed to the rendered card. Behaviour depends on `type`. */ componentInputs?: Record; - /** Human-readable label shown in the "Add Card" dialog. */ + /** Human-readable label shown in the "Edit Cards" dialog. */ label?: string; } @@ -81,7 +81,7 @@ export interface DashboardConfig { description?: string; /** URL of the background image applied to the dashboard host element. */ backgroundImageUrl?: string; - /** Overrides for the built-in Edit View and Add Card toolbar buttons. */ + /** Overrides for the built-in Edit View and Edit Cards toolbar buttons. */ buttonsSettings?: DashboardButtonsSettings; /** Extra action buttons rendered in the toolbar alongside the built-in ones. */ customActions?: ButtonSettings[]; diff --git a/projects/webcomponents-dashboard/main.ts b/projects/webcomponents-dashboard/main.ts index d593c37d..22e576ba 100644 --- a/projects/webcomponents-dashboard/main.ts +++ b/projects/webcomponents-dashboard/main.ts @@ -1,6 +1,9 @@ import { createCustomElement } from '@angular/elements'; import { createApplication } from '@angular/platform-browser'; -import { Dashboard } from '@openmfp/webcomponents/declarative-ui'; +import { + Dashboard, + defineDashboardElementMethods, +} from '@openmfp/webcomponents/declarative-ui'; import { ignoreCustomElements } from '@ui5/webcomponents-base/dist/IgnoreCustomElements.js'; ignoreCustomElements('mfp'); @@ -12,28 +15,11 @@ ignoreCustomElements('mfp'); injector: app.injector, }); - // `createCustomElement` only proxies @Input()/output() — public methods on the - // component class are NOT reachable from the DOM. Forward `requestNavigation` - // explicitly so non-Angular consumers (UI5, plain JS, Luigi, etc.) can route - // their navigation hooks through the dashboard's unsaved-changes guard. - Object.defineProperty(DashboardElement.prototype, 'requestNavigation', { - value(proceed: () => void): boolean { - const strategy = (this as unknown as { - ngElementStrategy?: { componentRef?: { instance?: Dashboard } }; - }).ngElementStrategy; - const instance = strategy?.componentRef?.instance; - if (!instance) { - // Element not yet connected / Angular component not yet created. - // Falling back to running the navigation immediately preserves the - // original (pre-guard) behaviour rather than silently blocking the user. - proceed(); - return true; - } - return instance.requestNavigation(proceed); - }, - configurable: true, - writable: true, - }); + // `createCustomElement` only proxies @Input()/output() — public methods on + // the component class are NOT reachable from the DOM. Forward them explicitly + // so non-Angular consumers (UI5, plain JS, Luigi, etc.) can drive the + // dashboard's edit-mode / unsaved-changes flow through the DOM node. + defineDashboardElementMethods(DashboardElement); customElements.define('mfp-wc-dashboard', DashboardElement); })();