diff --git a/docs/dashboard.md b/docs/dashboard.md index 0a583b9..590e11f 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -204,6 +204,22 @@ 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 | +### Public methods + +| Method | Returns | Description | +| ------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------ | +| `requestNavigation(proceed: () => void)` | `boolean` | Framework-agnostic navigation guard — see [Unsaved-changes guard](#unsaved-changes-guard). | +| `Dashboard.registerAngularComponents(types[])` | `void` | Static — registers standalone Angular card components by their element selector name. | + +### 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. | + --- ## EditCardsDialog @@ -233,6 +249,194 @@ The `EditCardsDialog` component (`mfp-edit-cards-dialog`) is rendered inside the --- +## Unsaved-changes guard + +When the user enters edit mode and starts changing the layout — toggling cards, dragging tiles, resizing, removing sections — the dashboard surfaces three independent confirmation paths so unsaved work is never lost silently: + +1. **Edit-bar Cancel** — clicking the Cancel button on the in-page edit toolbar with unsaved changes opens the [`DiscardChangesDialog`](#discardchangesdialog) (two buttons: Discard / Cancel). +2. **Closing the tab / typing a new URL** — a `beforeunload` listener triggers the browser's native generic confirmation prompt. Browsers do **not** allow custom HTML or button labels here; it's a security boundary, not a design choice. +3. **In-app navigation** — when the host app routes the user to a different page (Angular Router, Luigi, plain ``, history popstate, anything), the dashboard exposes a public method that opens a custom three-button popup ([`UnsavedChangesDialog`](#unsavedchangesdialog)) and resumes navigation only on Save or Discard. + +The first two are wired automatically as soon as `` is mounted — no consumer code is required. The third needs one line of glue per navigation hook the host app uses. + +### `dashboard.requestNavigation(proceed)` — the integration API + +The dashboard library is **framework-independent**: it does not import `@angular/router`, `@luigi-project/client`, or any other navigation framework. Instead, the consumer app calls a single method on the dashboard instance from inside whatever navigation hook it has, and lets the dashboard decide whether to allow, queue, or drop the navigation: + +```ts +const proceeded: boolean = dashboard.requestNavigation(() => { + // Your real navigation logic. Anything goes — router.navigateByUrl, + // LuigiClient.linkManager().navigate, window.location.href = ... +}); +``` + +Behaviour: + +| Dashboard state | What `requestNavigation` does | Return value | +| ---------------------------- | ------------------------------------------------------------------------------ | ------------ | +| No unsaved changes | Calls `proceed()` synchronously. The host can navigate immediately. | `true` | +| Unsaved changes | Opens `UnsavedChangesDialog` and stores `proceed` as a pending callback. Host **must NOT** navigate. | `false` | + +If the user picks… + +| Button in `UnsavedChangesDialog` | What the dashboard does | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Save** | Persists changes (emits `saved`), exits edit mode, then runs the queued `proceed()` callback. | +| **Discard** | Reverts to the snapshot taken on entering edit mode, exits edit mode, then runs the queued `proceed()` callback. | +| **Cancel** | Closes the popup and **drops** the pending callback. The user stays on the page in edit mode with their work intact. | + +A second `requestNavigation()` call while a request is already pending replaces the older callback — Cancel always means "stay here", so losing the older queued navigation is the right outcome. + +### Wiring examples + +The same `requestNavigation()` works from any navigation entry point. Typical wirings: + +#### Angular Router (`CanDeactivate` guard) + +```ts +import { CanDeactivateFn } from '@angular/router'; +import { Dashboard } from '@openmfp/ngx'; + +export const unsavedDashboardChangesGuard: CanDeactivateFn<{ + dashboard: Dashboard; +}> = (component, _current, _snapshot, nextState) => { + const proceeded = component.dashboard.requestNavigation(() => { + // The dashboard already decided we may go — just navigate. + location.assign(nextState.url); + }); + // Return `true` to allow Angular's own pending navigation through; return + // `false` to block it (the dashboard's dialog will resume navigation later). + return proceeded; +}; +``` + +Attach the guard to the route, and expose the dashboard as a `viewChild` on the page component: + +```ts +@Component({ + imports: [Dashboard], + template: ``, +}) +export class DashboardPage { + dashboard = viewChild.required(Dashboard); +} +``` + +#### Luigi navigation listener + +```ts +import LuigiClient from '@luigi-project/client'; + +LuigiClient.addNavigationListener((event) => { + const proceeded = dashboard.requestNavigation(() => { + LuigiClient.linkManager().navigate(event.params.path); + }); + // Block Luigi's default navigation when we've queued the dialog. + return !proceeded; +}); +``` + +#### Plain link / button click + +```ts +linkEl.addEventListener('click', (e) => { + e.preventDefault(); + dashboard.requestNavigation(() => { + window.location.href = linkEl.href; + }); +}); +``` + +#### Web-component consumers + +Because `` is the same Angular component wrapped as a custom element, the method is reachable on the DOM node: + +```js +const dashboardEl = document.querySelector('mfp-wc-dashboard'); +const proceeded = dashboardEl.requestNavigation(() => { + history.pushState(null, '', '/next'); +}); +``` + +### Showing your own dialog instead + +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: + - `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. + +In practice the recommended path is option 1 — drive everything through `requestNavigation()` and let the dashboard's built-in popup handle it. Reach for the lower-level signals only when your visual requirements demand it. + +### What the user sees + +#### Browser-level (closing tab, typing URL) + +The browser's native generic prompt — wording is fixed by the browser: + +> _Leave site? Changes you made may not be saved._ + +This fires only while `hasUnsavedChanges()` is true; the listener is removed when the dashboard is destroyed. + +#### In-app navigation — `UnsavedChangesDialog` + +Three-button popup driven by `requestNavigation()`: + +- Header: warning icon + "Unsaved Changes" +- 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" +- Body: "Discard the changes? This action cannot be undone." +- Buttons: **Discard** (Emphasized) / **Cancel** (Transparent) + +--- + +## 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. + +### Inputs + +| Input | Type | Default | Description | +| ------ | --------- | ------- | -------------------------- | +| `open` | `boolean` | `false` | Controls dialog visibility | + +### Outputs + +| Output | Payload | Description | +| ----------- | ------- | -------------------------------------------------------------------------- | +| `confirm` | `void` | Emits when the user clicks **Discard** | +| `cancelled` | `void` | Emits when the user clicks **Cancel** or closes the dialog (Esc / overlay) | + +--- + +## 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. + +### Inputs + +| Input | Type | Default | Description | +| ------ | --------- | ------- | -------------------------- | +| `open` | `boolean` | `false` | Controls dialog visibility | + +### Outputs + +| Output | Payload | Description | +| ----------- | ------- | -------------------------------------------------------------------------- | +| `save` | `void` | Emits when the user clicks **Save** | +| `discard` | `void` | Emits when the user clicks **Discard** | +| `cancelled` | `void` | Emits when the user clicks **Cancel** or closes the dialog (Esc / overlay) | + +--- + ## Configuration types ### `DashboardConfig` diff --git a/package.json b/package.json index 8e91190..75197fb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build:ngx": "ng build ngx", "build:wc": "ng build webcomponents && ng build webcomponents-dashboard && node scripts/bundle-wc.mjs", "build": "npm run build:ngx && npm run build:wc", - "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon --ignore dist --ignore public --ext js,yml,yaml,ts,html,css,scss,json,md --exec \"rimraf dist && npm run build && yalc publish dist/ngx --push --sig\"", + "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon --ignore dist --ignore public --ext js,yml,yaml,ts,html,css,scss,json,md --exec \"rimraf dist && npm run build && npm run yalc:publish\"", + "yalc:publish": "yalc publish dist/ngx --push --sig && yalc publish dist/webcomponents --push --sig", "test": "ng test ngx --watch=false", "test:watch": "ng test ngx", "test:cov": "ng test ngx --watch=false --coverage", diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html index 91ff5c8..adc70e2 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html @@ -2,11 +2,25 @@
- @if (config().title) { - - - - } +
+ @if (config().title) { + + + + } + @if (hasUnsavedChanges()) { +
+ + Unsaved Changes +
+ } +
@if (config().description) { @@ -139,3 +153,16 @@ (cancelled)="closeCardPanel()" (confirm)="onCardsEdited($event)" /> + + + + diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss index a7bb2d1..2e9a8bc 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss @@ -59,6 +59,35 @@ mfp-wc-dashboard { gap: 0.25rem; } +.mfp-dashboard__title-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.mfp-dashboard__unsaved-changes { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.mfp-dashboard__unsaved-changes-icon { + width: 16px; + height: 16px; + color: var(--sapContent_MarkerIconColor); +} + +.mfp-dashboard__unsaved-changes-text { + color: var(--sapContent_LabelColor); + text-shadow: 0 0 2px var(--sapContent_ContrastTextShadow, #fff); + font-family: var(--sapFontFamily); + font-size: var(--sapFontSize, 14px); + font-style: normal; + font-weight: 400; + line-height: normal; +} + .mfp-dashboard__description { font-weight: normal; font-family: var(--sapFontFamily); 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 1443c62..b5d32f9 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts @@ -257,6 +257,10 @@ describe('Dashboard', () => { component.cardDialogOpen.set(true); component.cancelEdit(); + // Unsaved changes were present, so cancelEdit opens the discard popup + // instead of reverting. Confirm the discard to actually revert. + expect(component.discardDialogOpen()).toBe(true); + component.confirmDiscard(); expect(component.sections()).toEqual(sections); expect(component.cards()).toEqual([ @@ -267,6 +271,76 @@ describe('Dashboard', () => { expect(component.editMode()).toBe(false); }); + describe('discard-changes confirmation', () => { + 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.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + + component.cancelEdit(); + + expect(component.discardDialogOpen()).toBe(true); + // No revert yet — user has not confirmed. + expect(component.editMode()).toBe(true); + expect(component.sections()).toEqual([{ id: 'beta', title: 'Beta' }]); + }); + + 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.enterEditMode(); + + component.cancelEdit(); + + expect(component.discardDialogOpen()).toBe(false); + expect(component.editMode()).toBe(false); + }); + + it('confirmDiscard reverts the snapshot and closes the popup', () => { + const { component } = setup(); + const sections: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }]; + component.sections.set(sections); + (component as unknown as { gridStackItems: () => unknown }).gridStackItems = + () => ({ gridstackItems: { toArray: () => [] } }); + + component.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + component.cancelEdit(); + expect(component.discardDialogOpen()).toBe(true); + + component.confirmDiscard(); + + expect(component.discardDialogOpen()).toBe(false); + expect(component.sections()).toEqual(sections); + expect(component.editMode()).toBe(false); + }); + + 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.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + component.cancelEdit(); + expect(component.discardDialogOpen()).toBe(true); + + component.cancelDiscard(); + + expect(component.discardDialogOpen()).toBe(false); + expect(component.editMode()).toBe(true); + expect(component.sections()).toEqual([{ id: 'beta', title: 'Beta' }]); + }); + }); + it('removes sections together with their cards and removes loose cards by id', () => { const { component } = setup(); @@ -491,4 +565,297 @@ describe('Dashboard', () => { expect(component.editCardsButton().design).toBe('Emphasized'); }); }); + + describe('hasUnsavedChanges indicator', () => { + function configureFor(component: Dashboard) { + ( + component as unknown as { gridStackItems: () => unknown } + ).gridStackItems = () => ({ + gridstackItems: { toArray: () => [] }, + }); + } + + it('is false when not in edit mode', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + expect(component.hasUnsavedChanges()).toBe(false); + }); + + it('is false right after entering edit mode without any modifications', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + + expect(component.editMode()).toBe(true); + expect(component.hasUnsavedChanges()).toBe(false); + }); + + it('flips to true when sections change while in edit mode', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + component.sections.set([{ id: 'alpha', title: 'Alpha' }]); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + expect(component.hasUnsavedChanges()).toBe(false); + + component.sections.set([{ id: 'beta', title: 'Beta' }]); + expect(component.hasUnsavedChanges()).toBe(true); + }); + + it('flips to true when cards change while in edit mode', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + component.cards.set([{ id: 'c1', component: 'mfp-a' }]); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + expect(component.hasUnsavedChanges()).toBe(false); + + component.removeCard('c1'); + expect(component.hasUnsavedChanges()).toBe(true); + }); + + it('flips to true when the gridstack reports a change while in edit mode', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + expect(component.hasUnsavedChanges()).toBe(false); + + component.onGridChange({ nodes: [{ id: 'c1', x: 1, y: 1 }] } as any); + expect(component.hasUnsavedChanges()).toBe(true); + }); + + it('ignores grid change events fired outside edit mode', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + component.onGridChange({ nodes: [{ id: 'c1', x: 1, y: 1 }] } as any); + + expect(component.hasUnsavedChanges()).toBe(false); + }); + + it('resets to false after saveEdit', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + component.sections.set([{ id: 'new', title: 'New' }]); + expect(component.hasUnsavedChanges()).toBe(true); + + component.saveEdit(); + + expect(component.editMode()).toBe(false); + expect(component.hasUnsavedChanges()).toBe(false); + }); + + it('resets to false after cancelEdit', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + component.sections.set([{ id: 'alpha', title: 'Alpha' }]); + configureFor(component); + fixture.detectChanges(); + + component.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + expect(component.hasUnsavedChanges()).toBe(true); + + component.cancelEdit(); + // cancelEdit now defers the revert behind the discard popup when there + // are unsaved changes; confirming finishes the cancel. + component.confirmDiscard(); + + expect(component.editMode()).toBe(false); + expect(component.hasUnsavedChanges()).toBe(false); + }); + + it('renders the indicator only when there are unsaved changes', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + configureFor(component); + fixture.detectChanges(); + + expect( + root(fixture).querySelector('.mfp-dashboard__unsaved-changes'), + ).toBeNull(); + + component.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + fixture.detectChanges(); + + const indicator = root(fixture).querySelector( + '.mfp-dashboard__unsaved-changes', + ) as HTMLElement | null; + expect(indicator).not.toBeNull(); + expect(indicator!.textContent).toContain('Unsaved Changes'); + expect( + indicator!.querySelector('ui5-icon[name="user-edit"]'), + ).not.toBeNull(); + }); + }); + + 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.enterEditMode(); + // Make the snapshot diverge so hasUnsavedChanges() flips to true. + component.sections.set([{ id: 'beta', title: 'Beta' }]); + } + + it('runs the proceed callback synchronously and returns true when there are no unsaved changes', () => { + const { component } = setup(); + let ran = 0; + + const result = component.requestNavigation(() => ran++); + + expect(result).toBe(true); + expect(ran).toBe(1); + expect(component.unsavedNavDialogOpen()).toBe(false); + }); + + it('queues the navigation and opens the unsaved-changes dialog when there are unsaved changes', () => { + const { component } = setup(); + enterEditWithDirty(component); + let ran = 0; + + const result = component.requestNavigation(() => ran++); + + expect(result).toBe(false); + expect(ran).toBe(0); + expect(component.unsavedNavDialogOpen()).toBe(true); + }); + + it('Save persists the changes, closes the dialog, and runs the queued navigation', () => { + const { component } = setup(); + enterEditWithDirty(component); + let ran = 0; + let savedPayload: unknown = null; + component.saved.subscribe((p) => (savedPayload = p)); + + component.requestNavigation(() => ran++); + component.onUnsavedNavSave(); + + expect(component.unsavedNavDialogOpen()).toBe(false); + expect(component.editMode()).toBe(false); + expect(savedPayload).not.toBeNull(); + expect(ran).toBe(1); + }); + + it('Discard reverts the snapshot, closes the dialog, and runs the queued navigation', () => { + const { component } = setup(); + const original: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }]; + component.sections.set(original); + (component as unknown as { gridStackItems: () => unknown }).gridStackItems = + () => ({ gridstackItems: { toArray: () => [] } }); + component.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + let ran = 0; + + component.requestNavigation(() => ran++); + component.onUnsavedNavDiscard(); + + expect(component.unsavedNavDialogOpen()).toBe(false); + expect(component.editMode()).toBe(false); + expect(component.sections()).toEqual(original); + expect(ran).toBe(1); + }); + + it('Cancel closes the dialog, drops the queued navigation, and keeps the user in edit mode', () => { + const { component } = setup(); + enterEditWithDirty(component); + let ran = 0; + + component.requestNavigation(() => ran++); + component.onUnsavedNavCancel(); + + expect(component.unsavedNavDialogOpen()).toBe(false); + expect(component.editMode()).toBe(true); + expect(ran).toBe(0); + }); + + it('a fresh requestNavigation replaces an already-pending one (the new callback wins on Save)', () => { + const { component } = setup(); + enterEditWithDirty(component); + let firstRan = 0; + let secondRan = 0; + + component.requestNavigation(() => firstRan++); + component.requestNavigation(() => secondRan++); + component.onUnsavedNavSave(); + + expect(firstRan).toBe(0); + expect(secondRan).toBe(1); + }); + }); + + describe('beforeunload listener', () => { + it('preventDefault is called when there are unsaved changes', () => { + const { fixture, component } = setup(); + 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.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + + const event = new Event('beforeunload', { cancelable: true }); + const preventSpy = vi.spyOn(event, 'preventDefault'); + + window.dispatchEvent(event); + + expect(preventSpy).toHaveBeenCalled(); + }); + + it('preventDefault is NOT called when there are no unsaved changes', () => { + const { fixture, component } = setup(); + fixture.componentRef.setInput('config', { title: 'Operations' }); + fixture.detectChanges(); + // Not in edit mode → hasUnsavedChanges() returns false unconditionally. + void component; + + const event = new Event('beforeunload', { cancelable: true }); + const preventSpy = vi.spyOn(event, 'preventDefault'); + + window.dispatchEvent(event); + + expect(preventSpy).not.toHaveBeenCalled(); + }); + + it('removes the listener on destroy', () => { + const { fixture, component } = setup(); + 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.enterEditMode(); + component.sections.set([{ id: 'beta', title: 'Beta' }]); + + fixture.destroy(); + + const event = new Event('beforeunload', { cancelable: true }); + const preventSpy = vi.spyOn(event, 'preventDefault'); + window.dispatchEvent(event); + + expect(preventSpy).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts index 3657776..cabe37f 100644 --- a/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +++ b/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts @@ -1,7 +1,9 @@ import { ButtonSettings } from '../../models/ui-definition'; import { DashboardCard } from '../card/dashboard-card.component'; import { addComponentToRegistry } from '../card/utils/dashboard-card-registry'; +import { DiscardChangesDialog } from '../discard-changes-dialog/discard-changes-dialog.component'; import { EditCardsDialog } from '../edit-cards-dialog/edit-cards-dialog.component'; +import { UnsavedChangesDialog } from '../unsaved-changes-dialog/unsaved-changes-dialog.component'; import { CardConfig, DashboardConfig, SectionConfig } from '../models'; import { CELL_HEIGHT, COMPACT_BREAKPOINT } from '../models/constants'; import { DashboardSection } from '../section/dashboard-section.component'; @@ -26,12 +28,14 @@ import { } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; +import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; import { Menu } from '@fundamental-ngx/ui5-webcomponents/menu'; import { MenuItem } from '@fundamental-ngx/ui5-webcomponents/menu-item'; import { MenuSeparator } from '@fundamental-ngx/ui5-webcomponents/menu-separator'; import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; import '@ui5/webcomponents-icons/dist/action-settings.js'; import '@ui5/webcomponents-icons/dist/menu2.js'; +import '@ui5/webcomponents-icons/dist/user-edit.js'; import { GridStackNode, GridStackOptions } from 'gridstack'; import { GridstackComponent, @@ -46,10 +50,13 @@ document.body.classList.add('ui5-content-density-compact'); imports: [ GridstackComponent, GridstackItemComponent, + DiscardChangesDialog, EditCardsDialog, + UnsavedChangesDialog, DashboardSection, DashboardCard, Button, + Icon, Menu, MenuItem, MenuSeparator, @@ -83,6 +90,27 @@ export class Dashboard implements OnInit, OnDestroy { compactToolbar = signal(false); toolbarMenuOpen = signal(false); + /** True once the user has dragged/resized any grid item while in edit mode. */ + private gridDirty = signal(false); + + /** JSON snapshots of sections/cards taken on entering edit mode, used to detect changes. */ + private sectionsSnapshotJson = ''; + private cardsSnapshotJson = ''; + + /** + * True when the user is in edit mode AND has made any change (sections/cards + * mutated, or grid items moved/resized). Resets when entering edit mode and + * after save/cancel. + */ + hasUnsavedChanges = computed(() => { + if (!this.editMode()) return false; + if (this.gridDirty()) return true; + return ( + JSON.stringify(this.sections()) !== this.sectionsSnapshotJson || + JSON.stringify(this.cards()) !== this.cardsSnapshotJson + ); + }); + private sectionsSnapshot: SectionConfig[] = []; private cardsSnapshot: CardConfig[] = []; private gridStackItems = viewChild.required('grid'); @@ -126,6 +154,19 @@ export class Dashboard implements OnInit, OnDestroy { ); cardDialogOpen = signal(false); + discardDialogOpen = signal(false); + unsavedNavDialogOpen = signal(false); + /** Callback that resumes the intercepted navigation once the user resolves the dialog. */ + private pendingNavigation: (() => void) | null = null; + /** beforeunload handler kept on instance so add/removeEventListener pair up. */ + private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => { + if (this.hasUnsavedChanges()) { + event.preventDefault(); + // Required by older browsers; the string itself is ignored — modern + // browsers always render their own generic prompt. + event.returnValue = ''; + } + }; customActions = computed(() => this.config().customActions ?? []); addedCardsIds = computed(() => new Set(this.cards().map((c) => c.id))); @@ -164,10 +205,12 @@ export class Dashboard implements OnInit, OnDestroy { this.compactToolbar.set(width < COMPACT_BREAKPOINT); }); this.resizeObserver.observe(this.hostEl.nativeElement); + window.addEventListener('beforeunload', this.beforeUnloadHandler); } ngOnDestroy(): void { this.resizeObserver?.disconnect(); + window.removeEventListener('beforeunload', this.beforeUnloadHandler); } onMenuItemClick(actionId: string, event: Event): void { @@ -192,6 +235,9 @@ export class Dashboard implements OnInit, OnDestroy { this.sectionsSnapshot = [...this.sections()]; this.cardsSnapshot = [...this.cards()]; + this.sectionsSnapshotJson = JSON.stringify(this.sections()); + this.cardsSnapshotJson = JSON.stringify(this.cards()); + this.gridDirty.set(false); this.editMode.set(true); afterNextRender( () => { @@ -216,10 +262,95 @@ export class Dashboard implements OnInit, OnDestroy { }; }), }); + this.gridDirty.set(false); this.editMode.set(false); } cancelEdit(): void { + if (this.hasUnsavedChanges()) { + this.discardDialogOpen.set(true); + return; + } + this.discardEdit(); + } + + /** + * Confirms abandoning unsaved edit-mode changes: closes the discard popup + * and reverts sections/cards to the snapshot taken on entering edit mode. + */ + confirmDiscard(): void { + this.discardDialogOpen.set(false); + this.discardEdit(); + } + + /** + * Cancels the discard popup and keeps the user in edit mode with their + * pending changes intact. + */ + cancelDiscard(): void { + this.discardDialogOpen.set(false); + } + + /** + * Public framework-agnostic navigation guard. Consumer apps (Angular Router + * CanDeactivate guard, Luigi navigation listener, plain `
` click handler, + * window history listener — anything) call this before performing their + * navigation: + * + * if (dashboard.requestNavigation(() => router.navigateByUrl(target))) { + * // already navigated synchronously — clean state + * } else { + * // dashboard popped the unsaved-changes dialog; the navigation will + * // resume from the user's choice (Save → proceed, Discard → proceed, + * // Cancel → drop the request entirely). + * } + * + * Returns `true` when navigation may proceed immediately (no unsaved + * changes — `proceed` was invoked synchronously). Returns `false` when the + * dialog has been opened and the caller must NOT navigate; the dashboard + * will run the callback later if the user picks Save or Discard. + * + * If a previous navigation is already pending, that one is dropped in + * favour of the new request — Cancel always means "stay here", so losing + * the older queued navigation is the correct outcome. + */ + requestNavigation(proceed: () => void): boolean { + if (!this.hasUnsavedChanges()) { + proceed(); + return true; + } + this.pendingNavigation = proceed; + this.unsavedNavDialogOpen.set(true); + return false; + } + + /** Save → persist changes, close the dialog, then resume navigation. */ + onUnsavedNavSave(): void { + this.unsavedNavDialogOpen.set(false); + this.saveEdit(); + this.runPendingNavigation(); + } + + /** Discard → revert to snapshot, close the dialog, then resume navigation. */ + onUnsavedNavDiscard(): void { + this.unsavedNavDialogOpen.set(false); + this.discardEdit(); + this.runPendingNavigation(); + } + + /** Cancel → drop the queued navigation and stay in edit mode. */ + onUnsavedNavCancel(): void { + this.unsavedNavDialogOpen.set(false); + this.pendingNavigation = null; + } + + private runPendingNavigation(): void { + const pending = this.pendingNavigation; + this.pendingNavigation = null; + pending?.(); + } + + private discardEdit(): void { this.sections.set(this.sectionsSnapshot); this.cards.set( this.cardsSnapshot.map((c) => { @@ -228,6 +359,7 @@ export class Dashboard implements OnInit, OnDestroy { }), ); this.cardDialogOpen.set(false); + this.gridDirty.set(false); this.editMode.set(false); } @@ -259,6 +391,9 @@ export class Dashboard implements OnInit, OnDestroy { onGridChange(event: nodesCB): void { this.newGridStackNodes = event.nodes; + if (this.editMode()) { + this.gridDirty.set(true); + } } private saveCardsPosition(items: GridStackNode[]): void { diff --git a/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.html b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.html new file mode 100644 index 0000000..abf1a2d --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.html @@ -0,0 +1,17 @@ + +
+ + Discard Changes +
+
+ Discard the changes? This action cannot be undone. +
+ +
diff --git a/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.scss b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.scss new file mode 100644 index 0000000..44e5162 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.scss @@ -0,0 +1,30 @@ +:host { + display: contents; +} + +.discard-changes-dialog { + &__header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + } + + &__icon { + color: var(--sapCriticalColor, #e76500); + } + + &__body { + padding: 1rem 0; + min-width: 320px; + } + + &__footer { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + gap: 0.5rem; + } +} diff --git a/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.spec.ts b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.spec.ts new file mode 100644 index 0000000..0ee1dc0 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.spec.ts @@ -0,0 +1,101 @@ +import { DiscardChangesDialog } from './discard-changes-dialog.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +type Fixture = ComponentFixture; + +function setup(): { fixture: Fixture; component: DiscardChangesDialog } { + const fixture = TestBed.createComponent(DiscardChangesDialog); + const component = fixture.componentInstance; + return { fixture, component }; +} + +function root(fixture: Fixture): ShadowRoot | HTMLElement { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + +describe('DiscardChangesDialog', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DiscardChangesDialog], + }).compileComponents(); + }); + + describe('template', () => { + it('renders the warning icon, title, body and both buttons', () => { + const { fixture } = setup(); + + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const r = root(fixture); + expect(r.querySelector('ui5-icon')?.getAttribute('name')).toBe('alert'); + expect(r.querySelector('ui5-title')?.textContent?.trim()).toBe( + 'Discard Changes', + ); + expect(r.textContent).toContain( + 'Discard the changes? This action cannot be undone.', + ); + + const buttons = r.querySelectorAll('ui5-button'); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent?.trim()).toBe('Discard'); + expect(buttons[1].textContent?.trim()).toBe('Cancel'); + }); + + it('reflects the open input on the underlying ui5-dialog', () => { + const { fixture } = setup(); + + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const dialog = root(fixture).querySelector( + 'ui5-dialog', + ); + expect(dialog?.open).toBe(true); + }); + }); + + describe('events', () => { + it('emits confirm when the Discard button is clicked', () => { + const { fixture, component } = setup(); + let confirmed = 0; + + component.confirm.subscribe(() => confirmed++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const buttons = root(fixture).querySelectorAll('ui5-button'); + buttons[0]?.dispatchEvent(new Event('click')); + + expect(confirmed).toBe(1); + }); + + it('emits cancelled when the Cancel button is clicked', () => { + const { fixture, component } = setup(); + let cancelled = 0; + + component.cancelled.subscribe(() => cancelled++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const buttons = root(fixture).querySelectorAll('ui5-button'); + buttons[1]?.dispatchEvent(new Event('click')); + + expect(cancelled).toBe(1); + }); + + it('emits cancelled when the dialog fires ui5BeforeClose', () => { + const { fixture, component } = setup(); + let cancelled = 0; + + component.cancelled.subscribe(() => cancelled++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const dialog = root(fixture).querySelector('ui5-dialog'); + dialog?.dispatchEvent(new Event('ui5BeforeClose')); + + expect(cancelled).toBe(1); + }); + }); +}); diff --git a/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.ts b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.ts new file mode 100644 index 0000000..197f0ce --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/discard-changes-dialog/discard-changes-dialog.component.ts @@ -0,0 +1,25 @@ +import { Component, ViewEncapsulation, input, output } from '@angular/core'; +import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; +import { Dialog } from '@fundamental-ngx/ui5-webcomponents/dialog'; +import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; +import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; +import '@ui5/webcomponents-icons/dist/alert.js'; + +/** + * Confirmation popup shown when the user tries to abandon edit mode while + * there are unsaved dashboard changes. Emits `confirm` when the user accepts + * the discard, `cancelled` when they back out. + */ +@Component({ + selector: 'mfp-discard-changes-dialog', + imports: [Button, Dialog, Icon, Title], + templateUrl: './discard-changes-dialog.component.html', + styleUrl: './discard-changes-dialog.component.scss', + encapsulation: ViewEncapsulation.ShadowDom, +}) +export class DiscardChangesDialog { + open = input(false); + + readonly confirm = output(); + readonly cancelled = output(); +} diff --git a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.html b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.html index 6f63b0a..6210f7c 100644 --- a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.html +++ b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.html @@ -5,13 +5,16 @@
@for (ac of availableCards(); track ac.id) { - +
{{ ac.label || ac.component }}
diff --git a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.scss b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.scss index bbdcf44..b338ad8 100644 --- a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.scss +++ b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.scss @@ -3,12 +3,15 @@ } .edit-cards-dialog { + padding: var(--Container-Spacing-Small, 16px) 0; margin: -1rem; min-width: 300px; &__header { display: flex; align-items: flex-start; + justify-content: flex-start; + width: 100%; padding: 0.75rem 1rem; } diff --git a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.spec.ts b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.spec.ts index c9a10e8..b554fc4 100644 --- a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.spec.ts +++ b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.spec.ts @@ -283,4 +283,88 @@ describe('EditCardsDialog', () => { expect(buttons[1].textContent?.trim()).toBe('Cancel'); }); }); + + describe('keyboard tab traversal', () => { + function tabEvent(target: HTMLElement, shiftKey = false): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'currentTarget', { value: target }); + return event; + } + + it('moves focus from one switch to the next on Tab', () => { + const { fixture, component } = setup(); + + fixture.componentRef.setInput('availableCards', [CARD_A, CARD_B]); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const switches = + root(fixture).querySelectorAll('ui5-switch'); + const focusSpy = vi.spyOn(switches[1], 'focus'); + + const event = tabEvent(switches[0]); + component.onSwitchKeydown(event, CARD_A.id); + + expect(event.defaultPrevented).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('hands focus from the last switch to the first footer button on Tab', () => { + const { fixture, component } = setup(); + + fixture.componentRef.setInput('availableCards', [CARD_A, CARD_B]); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const switches = + root(fixture).querySelectorAll('ui5-switch'); + const buttons = root(fixture).querySelectorAll('ui5-button'); + const focusSpy = vi.spyOn(buttons[0], 'focus'); + + const event = tabEvent(switches[1]); + component.onSwitchKeydown(event, CARD_B.id); + + expect(event.defaultPrevented).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('moves focus to the previous switch on Shift+Tab', () => { + const { fixture, component } = setup(); + + fixture.componentRef.setInput('availableCards', [CARD_A, CARD_B]); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const switches = + root(fixture).querySelectorAll('ui5-switch'); + const focusSpy = vi.spyOn(switches[0], 'focus'); + + const event = tabEvent(switches[1], true); + component.onSwitchKeydown(event, CARD_B.id); + + expect(event.defaultPrevented).toBe(true); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('lets Shift+Tab fall through on the first switch so the dialog focus trap can wrap', () => { + const { fixture, component } = setup(); + + fixture.componentRef.setInput('availableCards', [CARD_A, CARD_B]); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const switches = + root(fixture).querySelectorAll('ui5-switch'); + + const event = tabEvent(switches[0], true); + component.onSwitchKeydown(event, CARD_A.id); + + expect(event.defaultPrevented).toBe(false); + }); + }); }); diff --git a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.ts b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.ts index c6817af..88795b4 100644 --- a/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.ts +++ b/projects/ngx/declarative-ui/dashboard/edit-cards-dialog/edit-cards-dialog.component.ts @@ -1,8 +1,10 @@ import { CardConfig } from '../models'; import { Component, + ElementRef, ViewEncapsulation, effect, + inject, input, output, signal, @@ -22,6 +24,8 @@ import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; encapsulation: ViewEncapsulation.ShadowDom, }) export class EditCardsDialog { + private readonly host = inject(ElementRef); + availableCards = input([]); addedCardsIds = input>(new Set()); open = input(false); @@ -56,6 +60,51 @@ export class EditCardsDialog { }); } + /** + * Tab moves focus across the switches directly, skipping the wrapping + * ui5-li-custom rows. Past the last switch Tab goes to the first footer + * button; before the first switch Shift+Tab goes to the last footer button. + * The dialog's native focus trap takes care of cycling from the buttons + * back into the list. + */ + onSwitchKeydown(event: KeyboardEvent, _id: string): void { + if (event.key !== 'Tab') { + return; + } + const root: ParentNode = + this.host.nativeElement.shadowRoot ?? this.host.nativeElement; + const switches = Array.from( + root.querySelectorAll('ui5-switch'), + ) as HTMLElement[]; + const buttons = Array.from( + root.querySelectorAll('ui5-button'), + ) as HTMLElement[]; + const currentIndex = switches.indexOf(event.currentTarget as HTMLElement); + if (currentIndex === -1) { + return; + } + if (event.shiftKey) { + const prev = switches[currentIndex - 1]; + if (prev) { + event.preventDefault(); + event.stopPropagation(); + prev.focus(); + } + // First switch: let Shift+Tab fall through so the dialog's focus trap + // wraps to the last footer button. + return; + } + event.preventDefault(); + event.stopPropagation(); + const next = switches[currentIndex + 1]; + if (next) { + next.focus(); + return; + } + // Last switch: hand off to the first footer button. + buttons[0]?.focus(); + } + confirmSave(): void { const availableIds = new Set(this.availableCards().map((ac) => ac.id)); const added = this.availableCards().filter( diff --git a/projects/ngx/declarative-ui/dashboard/index.ts b/projects/ngx/declarative-ui/dashboard/index.ts index e84892a..8383fa6 100644 --- a/projects/ngx/declarative-ui/dashboard/index.ts +++ b/projects/ngx/declarative-ui/dashboard/index.ts @@ -1,5 +1,7 @@ export * from './dashboard'; export * from './edit-cards-dialog/edit-cards-dialog.component'; +export * from './discard-changes-dialog/discard-changes-dialog.component'; +export * from './unsaved-changes-dialog/unsaved-changes-dialog.component'; export * from './card/dashboard-card.component'; export * from './section/dashboard-section.component'; export * from './models'; diff --git a/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.html b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.html new file mode 100644 index 0000000..5aec50a --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.html @@ -0,0 +1,19 @@ + +
+ + Unsaved Changes +
+
+ You are leaving this page. Save or discard the changes to proceed. This + action cannot be undone. +
+ +
diff --git a/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.scss b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.scss new file mode 100644 index 0000000..e1c4be8 --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.scss @@ -0,0 +1,31 @@ +:host { + display: contents; +} + +.unsaved-changes-dialog { + &__header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + } + + &__icon { + color: var(--sapCriticalColor, #e76500); + } + + &__body { + padding: 1rem 0; + min-width: 360px; + max-width: 32rem; + } + + &__footer { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + gap: 0.5rem; + } +} diff --git a/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.spec.ts b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.spec.ts new file mode 100644 index 0000000..5f9923f --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.spec.ts @@ -0,0 +1,119 @@ +import { UnsavedChangesDialog } from './unsaved-changes-dialog.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +type Fixture = ComponentFixture; + +function setup(): { fixture: Fixture; component: UnsavedChangesDialog } { + const fixture = TestBed.createComponent(UnsavedChangesDialog); + const component = fixture.componentInstance; + return { fixture, component }; +} + +function root(fixture: Fixture): ShadowRoot | HTMLElement { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + +describe('UnsavedChangesDialog', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UnsavedChangesDialog], + }).compileComponents(); + }); + + describe('template', () => { + it('renders the warning icon, title, body and three buttons in the right order', () => { + const { fixture } = setup(); + + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const r = root(fixture); + expect(r.querySelector('ui5-icon')?.getAttribute('name')).toBe('alert'); + expect(r.querySelector('ui5-title')?.textContent?.trim()).toBe( + 'Unsaved Changes', + ); + expect(r.textContent).toContain( + 'You are leaving this page. Save or discard the changes to proceed.', + ); + + const buttons = r.querySelectorAll('ui5-button'); + expect(buttons).toHaveLength(3); + expect(buttons[0].textContent?.trim()).toBe('Save'); + expect(buttons[1].textContent?.trim()).toBe('Discard'); + expect(buttons[2].textContent?.trim()).toBe('Cancel'); + }); + + it('reflects the open input on the underlying ui5-dialog', () => { + const { fixture } = setup(); + + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const dialog = root(fixture).querySelector< + HTMLElement & { open?: boolean } + >('ui5-dialog'); + expect(dialog?.open).toBe(true); + }); + }); + + describe('events', () => { + it('emits save when the Save button is clicked', () => { + const { fixture, component } = setup(); + let emitted = 0; + + component.save.subscribe(() => emitted++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + root(fixture) + .querySelectorAll('ui5-button')[0] + ?.dispatchEvent(new Event('click')); + + expect(emitted).toBe(1); + }); + + it('emits discard when the Discard button is clicked', () => { + const { fixture, component } = setup(); + let emitted = 0; + + component.discard.subscribe(() => emitted++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + root(fixture) + .querySelectorAll('ui5-button')[1] + ?.dispatchEvent(new Event('click')); + + expect(emitted).toBe(1); + }); + + it('emits cancelled when the Cancel button is clicked', () => { + const { fixture, component } = setup(); + let emitted = 0; + + component.cancelled.subscribe(() => emitted++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + root(fixture) + .querySelectorAll('ui5-button')[2] + ?.dispatchEvent(new Event('click')); + + expect(emitted).toBe(1); + }); + + it('emits cancelled when the dialog fires ui5BeforeClose', () => { + const { fixture, component } = setup(); + let emitted = 0; + + component.cancelled.subscribe(() => emitted++); + fixture.componentRef.setInput('open', true); + fixture.detectChanges(); + + const dialog = root(fixture).querySelector('ui5-dialog'); + dialog?.dispatchEvent(new Event('ui5BeforeClose')); + + expect(emitted).toBe(1); + }); + }); +}); diff --git a/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.ts b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.ts new file mode 100644 index 0000000..bc6232a --- /dev/null +++ b/projects/ngx/declarative-ui/dashboard/unsaved-changes-dialog/unsaved-changes-dialog.component.ts @@ -0,0 +1,31 @@ +import { Component, ViewEncapsulation, input, output } from '@angular/core'; +import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; +import { Dialog } from '@fundamental-ngx/ui5-webcomponents/dialog'; +import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; +import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; +import '@ui5/webcomponents-icons/dist/alert.js'; + +/** + * Confirmation popup shown when the user attempts to navigate away from the + * dashboard while there are unsaved edit-mode changes. The host (the + * dashboard, an Angular CanDeactivate guard, a Luigi navigation listener, + * etc.) is responsible for routing its intercepted navigation through this + * dialog and acting on the emitted decision: + * - `save` → persist changes, then proceed with navigation + * - `discard` → revert changes, then proceed with navigation + * - `cancelled` → abort navigation, stay on the page + */ +@Component({ + selector: 'mfp-unsaved-changes-dialog', + imports: [Button, Dialog, Icon, Title], + templateUrl: './unsaved-changes-dialog.component.html', + styleUrl: './unsaved-changes-dialog.component.scss', + encapsulation: ViewEncapsulation.ShadowDom, +}) +export class UnsavedChangesDialog { + open = input(false); + + readonly save = output(); + readonly discard = output(); + readonly cancelled = output(); +} diff --git a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html index 987cc0e..eb9d9b6 100644 --- a/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html +++ b/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html @@ -1,8 +1,12 @@ - + [style.overflow-y]="'auto'" +> + @for (column of viewColumns(); track columnTrackBy(column, $index)) { @if (column.group) { - @let isMultiline = column.group.multiline ?? true; -
- @for ( + @let isMultiline = column.group.multiline ?? true; +
+ @for ( field of column.group.fields; let last = $last; track columnTrackBy(field, $index) @@ -72,7 +79,7 @@ } @else { + >
5 10 + 20 50 100 diff --git a/projects/webcomponents-dashboard/main.ts b/projects/webcomponents-dashboard/main.ts index b4f8387..d593c37 100644 --- a/projects/webcomponents-dashboard/main.ts +++ b/projects/webcomponents-dashboard/main.ts @@ -11,5 +11,29 @@ ignoreCustomElements('mfp'); const DashboardElement = createCustomElement(Dashboard, { 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, + }); + customElements.define('mfp-wc-dashboard', DashboardElement); })();