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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 21 additions & 22 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
DashboardConfig,
SectionConfig,
VisitedServiceCard,
} from '@openmfp/webcomponents';
} from '@openmfp/ngx';

Dashboard.registerAngularComponents([VisitedServiceCard]);

Expand Down Expand Up @@ -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 `<mfp-wc-dashboard>`, 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.
Comment on lines 220 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not state that the static registration method is forwarded to the DOM node.

Dashboard.registerAngularComponents(types[]) is static. defineDashboardElementMethods() only defines requestNavigation and the void instance handlers on elementCtor.prototype. The current text promises that element.registerAngularComponents(...) is available, but it is not.

Change “all of the methods above” to “the instance methods above”, or add an explicit static proxy.

🧰 Tools
🪛 LanguageTool

[style] ~222-~222: Consider removing “of” to be more concise
Context: ...p-wc-dashboard.js) explicitly forwards all of the methods above onto `...

(ALL_OF_THE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashboard.md` around lines 220 - 222, Update the Web-component consumers
note to state that the WC bundle forwards only the instance methods above onto
the custom element, not the static Dashboard.registerAngularComponents(types[])
method. Keep the existing behavior and examples for requestNavigation and the
void instance handlers unchanged.


### Reactive state

| Signal | Type | Description |
| ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `hasUnsavedChanges()` | `computed<boolean>` | `true` while the user is in edit mode AND has changed sections, cards, or grid positions. Resets after save / discard. |
| `editMode()` | `signal<boolean>` | `true` while the user is in the dashboard's edit mode. |
| `unsavedNavDialogOpen()`| `signal<boolean>` | `true` while the unsaved-changes navigation popup is shown. Driven by `requestNavigation()`; consumers normally don't read it directly. |
| `discardDialogOpen()` | `signal<boolean>` | `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
Expand Down Expand Up @@ -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

`<mfp-discard-changes-dialog>`, `<mfp-unsaved-changes-dialog>`, and `<mfp-edit-cards-dialog>` 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 `<mfp-dashboard>`, the input is ignored and the dashboard's shared language wins.

---

## EditCardsDialog
Expand All @@ -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<string>` | `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 `<mfp-dashboard>`. |

### Outputs

Expand All @@ -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
Expand Down Expand Up @@ -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:
Comment thread
Sobyt483 marked this conversation as resolved.
- `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.
Expand All @@ -441,30 +440,29 @@ 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" (`<ui5-title>`; 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)

#### Edit-bar Cancel — `DiscardChangesDialog`

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" (`<ui5-title>`; 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)

---

## DiscardChangesDialog

`<mfp-discard-changes-dialog>` — confirmation popup the dashboard pops when the user clicks Cancel on the edit-bar with unsaved changes. It is rendered automatically by `<mfp-dashboard>`; the standalone component is exported so it can be reused outside the dashboard if you need the same confirmation pattern elsewhere.
`<mfp-discard-changes-dialog>` — confirmation popup the dashboard pops when the user clicks Cancel on the edit-bar with unsaved changes. It is rendered automatically by `<mfp-dashboard>` 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 `<mfp-dashboard>`. |

### Outputs

Expand All @@ -477,14 +475,13 @@ Two-button popup shown when the user clicks the Cancel button on the in-page edi

## UnsavedChangesDialog

`<mfp-unsaved-changes-dialog>` — 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.
`<mfp-unsaved-changes-dialog>` — three-button popup the dashboard pops when an in-app navigation is intercepted via `requestNavigation()`. Like `DiscardChangesDialog`, it is rendered automatically by `<mfp-dashboard>` 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 `<mfp-dashboard>`. |

### Outputs

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, (...args: unknown[]) => 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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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 `<mfp-wc-dashboard>` 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,
});
}
}
Loading