Skip to content

Enhancement - Warn users when navigating away from cohort builder with unsaved changes#2764

Open
khairul-syazwan wants to merge 19 commits into
developfrom
khairul-syazwan/analyze-2636
Open

Enhancement - Warn users when navigating away from cohort builder with unsaved changes#2764
khairul-syazwan wants to merge 19 commits into
developfrom
khairul-syazwan/analyze-2636

Conversation

@khairul-syazwan

@khairul-syazwan khairul-syazwan commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Resolves #2636

Screenshot 2026-06-24 at 9 28 04 AM Screenshot 2026-06-24 at 9 27 56 AM Screen Recording 2026-06-24 at 9 26 49 AM

What changed

This PR adds a portal-wide unsaved-changes guard that protects the user from leaving a plugin while they have uncommitted work. The guard is implemented in three layers:

Layer Coverage Implementation
1. Native page exit Reload, tab/window close, typing an external URL beforeunload listener per micro-frontend
2. Cross-app registry Portal asks "is anybody dirty?" window.__d2eUnsavedChangesRegistry
3. Portal in-app guard Link/menu navigation between plugins NavigationGuardRouter intercepts React Router push/replace

Portal shell

  • New NavigationGuardRouter wraps the app in index.tsx and replaces the previous BrowserRouter.
  • It uses @remix-run/router directly to create and wrap the history object, so we can intercept history.push / history.replace synchronously.
  • If any mounted micro-frontend reports dirty state, navigation is paused and the shared UnsavedChangesDialog is shown.
  • On Leave, the registry calls clearAll() so the dirty app resets its baseline and does not re-block the navigation that follows.
  • New useGuardedChange hook guards non-router state changes such as dataset and release switches in the portal header.

Patient Analytics (vue-mri-ui-lib)

  • New unsavedChangesRegistry module creates the cross-app registry singleton on window.__d2eUnsavedChangesRegistry.
  • New useUnsavedChanges composable registers/unregisters PA and exposes a guard() helper for in-app flows (dataset switch, deep-link load, Atlas load).
  • New UnsavedChangesDialog.vue for PA-internal guarded actions.
  • Bookmark store now tracks a dirty baseline (SET_ACTIVE_BOOKMARK_BASELINE) and clears the active bookmark on unmount to prevent stale dirty state when the user navigates back.
  • useDeepLink and Bookmarks.vue updated to use the new guard.

Quality

  • Unit tests for NavigationGuardRouter, UnsavedChangesDialog, useGuardedChange, useUnsavedChanges, and the registry.
  • Integration guide: plugins/ui/docs/cross-app-unsaved-changes.md.

Architecture

flowchart TD
    subgraph "Portal shell"
        A[Link / menu click] --> B[NavigationGuardRouter]
        B --> C{Has unsaved changes?}
        C -->|No| D[Call original history.push/replace]
        C -->|Yes| E[Show UnsavedChangesDialog]
        E -->|Stay| F[Cancel pending navigation]
        E -->|Leave| G[clearAll then replay original push/replace]
    end

    subgraph "Cross-app registry"
        R[window.__d2eUnsavedChangesRegistry]
        R --> PA[PA: useUnsavedChanges]
        R --> MF[Future micro-frontends]
    end

    B -.->|queries| R
    G -.->|resets dirty apps| R
Loading

In-app navigation sequence

sequenceDiagram
    actor User
    participant UI as Portal nav link
    participant NG as NavigationGuardRouter
    participant REG as __d2eUnsavedChangesRegistry
    participant PA as PA / vue-mri-ui-lib
    participant D as UnsavedChangesDialog

    User->>UI: Click link to another plugin
    UI->>NG: history.push(target)
    NG->>REG: hasAnyUnsavedChanges()
    REG->>PA: hasUnsavedChanges()
    PA-->>REG: true
    REG-->>NG: true
    NG->>D: open = true
    D-->>User: Stay / Leave

    alt User chooses Stay
        D->>NG: onCancel
        NG->>NG: drop pending navigation
    else User chooses Leave
        D->>NG: onLeave
        NG->>REG: clearAll()
        REG->>PA: clearUnsavedChanges()
        PA->>PA: re-baseline bookmark
        NG->>NG: replay original history.push(target)
    end
Loading

Why @remix-run/router is used for the portal router

The portal previously used React Router's declarative BrowserRouter. Declarative routers do not expose a synchronous interception point for all push/replace calls. To pause navigation, show a dialog, and optionally cancel or resume, we need access to the underlying history object.

@remix-run/router is the low-level routing package that React Router itself is built on. By creating the history with createBrowserHistory({ v5Compat: true }), wrapping its push and replace methods, and then passing that wrapped history into React Router's unstable_HistoryRouter, we get:

  • A single interception point for every link/menu-driven navigation in the portal shell.
  • Full compatibility with the rest of React Router (Routes, Link, useNavigate, etc.).
  • No migration to a data router (createBrowserRouter) required.

Why not just upgrade to the latest React Router?

Modern React Router v6.20+ promotes the data router pattern (createBrowserRouter + RouterProvider). That would give us useBlocker for blocking navigation, but it would also require a much larger change:

  1. Rewrite the portal shell's routing model. The portal uses the declarative <Routes><Route .../></Routes> tree throughout the app. Moving to a data router means lifting all route definitions into a single createBrowserRouter config, converting data-loading patterns to loader/action APIs, and re-testing the entire shell.
  2. Back-button blocking is still buggy upstream. React Router's own useBlocker has a reported issue where pressing Back changes the location before the blocker can stop it (react-router #11589). Upgrading would not solve the Back/Forward limitation in this PR.
  3. Risk vs. value. The portal only needs a synchronous way to intercept push/replace. Using @remix-run/router directly gives us exactly that with a small, scoped change while keeping the rest of the declarative router code unchanged.
flowchart LR
    subgraph "Low-level history wrapping"
        H0["@remix-run/router createBrowserHistory"]
        H0 --> H1["Wrap push/replace"]
        H1 --> H2["Inject into unstable_HistoryRouter"]
    end

    subgraph "Declarative React Router still works"
        H2 --> L["Link"]
        H2 --> N["useNavigate"]
        H2 --> R["Routes / Route"]
    end

    L -.->|calls| H1
    N -.->|calls| H1
Loading

Known limitation: browser Back/Forward button

The guard does not intercept the browser Back/Forward buttons. This is a documented limitation of the current stack, not a wiring bug. Full details are in UNSAVED_CHANGES_BACK_BUTTON_LIMITATION.md.

Why it cannot be reliably blocked

When the user presses Back/Forward, the browser fires popstate after it has already moved the history pointer and changed the URL. popstate is not cancelable with preventDefault. By the time our code runs, the navigation has effectively happened.

In the portal, two independent systems react to that popstate:

  1. single-spa re-evaluates activeWhen and mounts/unmounts apps.
  2. React Router updates its location and re-renders the shell.

Intercepting at either layer has proven unreliable:

Approach Why it doesn't work here
React Router useBlocker Requires a data router (createBrowserRouter + RouterProvider). The portal uses declarative unstable_HistoryRouter. Even with migration, back-button blocking is buggy (react-router #11589).
single-spa cancelNavigation() Works for same-app navigation, but is broken for cross-app navigation: the URL changes, the current app unmounts, and the dialog appears too late (single-spa-layout #151, single-spa #951).
sequenceDiagram
    actor User
    participant Browser
    participant SPA as single-spa
    participant RR as React Router

    User->>Browser: Press Back button
    Browser->>Browser: Update URL & move history pointer
    Browser->>SPA: popstate event
    Browser->>RR: popstate event
    SPA->>SPA: Re-evaluate activeWhen, mount/unmount apps
    RR->>RR: Update location, re-render shell
    Note over SPA,RR: Both systems react independently.<br/>No synchronous way to cancel the navigation.<br/>preventDefault() has no effect on popstate.
Loading

What is still protected

Scenario Protection
In-app link / menu navigation Portal NavigationGuardRouter dialog ✅
Dataset / release switch useGuardedChange dialog ✅
Deep-link / Atlas load inside PA useUnsavedChanges.guard() dialog ✅
Reload / close tab / close window / external URL Native beforeunload prompt ✅
Browser Back / Forward ❌ Not covered by the in-app dialog

Decision

We intentionally do not intercept Back/Forward. The half-working single-spa handler was net-negative UX (the page navigated back and then the dialog appeared), and migrating the portal to a React Router data router is high-effort and still would not reliably block the Back button.

Destructive exits — reload, tab close, and external navigation — are covered by the native beforeunload prompt. If Back/Forward blocking becomes a hard requirement in the future, the established option is a history sentinel trap, which would be implemented as a small, self-contained portal-level module.


How other single-spa apps can participate

Any micro-frontend in the portal can opt into the guard by registering with window.__d2eUnsavedChangesRegistry. The registry is framework-agnostic: the first app that loads creates the singleton, and every later app reuses it.

Minimal integration

const APP_NAME = 'my-app';

const isDirty = (): boolean => {
  // return true while the app has uncommitted work
  return store.hasUnsavedChanges;
};

const handleBeforeUnload = (e: BeforeUnloadEvent): void => {
  if (!isDirty()) return;
  e.preventDefault();
  e.returnValue = '';
};

// on mount
window.__d2eUnsavedChangesRegistry?.register(APP_NAME, {
  hasUnsavedChanges: isDirty,
  clearUnsavedChanges: () => {
    store.discardUnsavedChanges();
  },
});
window.addEventListener('beforeunload', handleBeforeUnload);

// on unmount
window.__d2eUnsavedChangesRegistry?.unregister(APP_NAME);
window.removeEventListener('beforeunload', handleBeforeUnload);

How the dirty-state watcher is shared

flowchart TD
    subgraph "Single-spa runtime"
        S[single-spa orchestrator]
        S --> PA[PA mounts]
        S --> MF[Other app mounts]
    end

    subgraph "Shared registry on window"
        R[__d2eUnsavedChangesRegistry]
        R --> PA_API[PA registers hasUnsavedChanges + clearUnsavedChanges]
        R --> MF_API[Other app registers hasUnsavedChanges + clearUnsavedChanges]
    end

    subgraph "Portal guard"
        G[NavigationGuardRouter]
        G -->|hasAnyUnsavedChanges| R
    end

    PA -->|install| PA_API
    MF -->|install| MF_API
Loading

Contract requirements

  • hasUnsavedChanges() must be synchronous and cheap — it is called on every guarded navigation.
  • clearUnsavedChanges() should make the app report not dirty so the subsequent navigation is not re-blocked.
  • Always unregister on unmount. single-spa preserves module state, so a stale registration would make the portal think an unmounted app is still dirty.
  • Add a beforeunload listener for full page-exit protection.

Reference implementations

  • plugins/ui/apps/vue-mri-ui-lib/src/shared/unsavedChangesRegistry.ts — registry singleton.
  • plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts — mount/unmount/guard wiring.
  • plugins/ui/docs/cross-app-unsaved-changes.md — full integration guide.

Test coverage

  • plugins/ui/apps/portal/src/components/NavigationGuardRouter/__tests__/NavigationGuardRouter.test.tsx
  • plugins/ui/apps/portal/src/components/UnsavedChangesDialog/__tests__/UnsavedChangesDialog.test.tsx
  • plugins/ui/apps/portal/src/hooks/__tests__/useGuardedChange.test.tsx
  • plugins/ui/apps/vue-mri-ui-lib/src/composables/__tests__/useUnsavedChanges.test.ts
  • plugins/ui/apps/vue-mri-ui-lib/src/components/__tests__/UnsavedChangesDialog.test.ts
  • plugins/ui/apps/vue-mri-ui-lib/src/shared/__tests__/unsavedChangesRegistry.test.ts
  • plugins/ui/apps/vue-mri-ui-lib/src/store/modules/__tests__/bookmark.test.ts

Docs

  • plugins/ui/docs/cross-app-unsaved-changes.md — integration guide for future micro-frontends.
  • UNSAVED_CHANGES_BACK_BUTTON_LIMITATION.md — detailed technical limitation note.

Merge Checklist

Please cross check this list if additions / modifications needs to be done on top of your core changes and tick them off. Reviewer can as well glance through and help the developer if something is missed out.

  • Automated Tests (Jasmine integration tests, Unit tests, and/or Performance tests)
  • Updated Manual tests / Demo Config
  • Documentation (Application guide, Admin guide, Markdown, Readme and/or Wiki)
  • Verified that local development environment is working with latest changes (integrated with latest develop branch)
  • following best practices in code review doc

- Add useUnsavedChanges composable exposing isDirty, guard(action),
  install/uninstall, confirmLeave/cancelLeave. Listens for
  single-spa:before-routing-event and beforeunload.
- Refactor App.vue to Composition API (script setup) and mount the
  UnsavedChangesDialog alongside install/uninstall of listeners.
- Bookmarks.vue: replace local messageBox save/discard with
  unsavedChanges.guard() at loadBookmarkCheck and openAddNewCohort.
- portalPropsListener: add guardChange option that fires only on
  dataset/release deltas; wire it through lifecycles.ts so the
  cohort builder can intercept dataset switches.
- vitest: inline vuetify and polyfill visualViewport/matchMedia so
  Vuetify components can mount in happy-dom for tests.
…owing

Remove block sizing from action buttons and switch card width to max-width so the dialog stays within the 540px Figma spec without clipping.
… navigation correctly

- Use event.detail.cancelNavigation(true) instead of preventDefault() for single-spa routing events.

- Make install/uninstall idempotent to avoid duplicate listeners.

- Prevent concurrent guards while the dialog is already open.

- Track the expected navigation URL so confirming leave does not reopen the dialog.

- Suppress dirty state while a bookmark is being restored.

- Expand unit tests for cancelNavigation, idempotency, and concurrent guard behavior.
…ctive bookmark

- Add isRestoringBookmark flag with SET_IS_RESTORING_BOOKMARK mutation.

- Set the flag around loadbookmarkToState and loadBookmarkDataToState.

- Normalize SET_ACTIVE_BOOKMARK so isNew is always a boolean.

- Update bookmark store tests for normalized active bookmark.
…nges check

- Wrap loadAtlasBookmark and openNewAtlasBookmark with unsavedChanges.guard.

- Accept an optional guard callback in useDeepLink and wire it from App.vue.

- Reset active bookmark when the dataset/release changes so dirty state does not persist.

- Update datasetWatcher and datasetPropagation tests for the new commit order.
- Add activeBookmarkBaseline state and SET_ACTIVE_BOOKMARK_BASELINE mutation.

- Compare current bookmark data against baseline in getCurrentBookmarkHasChanges.

- Reset baseline whenever active bookmark changes.

- Capture baseline after loading an existing bookmark or creating a new one.

- Make resetChart return a promise so addNewCohort can await the reset.

- Remove activeBookmark.isNew from the unsaved-changes dirty check.

- Update bookmark store tests for baseline behavior.
…ing on navigation

- Add @mdi/font dependency and import its CSS in the vuetify plugin.

- Attach UnsavedChangesDialog to #app so it is removed with the microfrontend.

- Reset dialog state in App.vue onBeforeUnmount.

- Call cancelNavigation() without arguments per single-spa docs.

- Update tests for the close icon and navigation cancellation.
…alog colors

- Only use activeBookmarkBaseline for bookmarks without saved JSON.

- Ignore single-spa routing events while the warning dialog is already open.

- Override Bootstrap's .text-primary inside PA to use the theme primary color.

- Define --color-primary in the d2e theme.

- Update tests for saved-bookmark dirty state and routing-event guard.
…kmark dirty normalization

- Add window.__d2eUnsavedChangesRegistry and register MRI app from useUnsavedChanges
- Add DirtyStateAwareRouter + MUI UnsavedChangesDialog to portal
- Normalize barChartType.showDistributionOverlay in bookmark dirty check
- Add unit tests for registry, router, and bookmark comparison
…t, dialog polish

- normalize auto-defaulted colorAxis on BOTH sides of the dirty comparison so it is timing-invariant; add chart isColorAxisAutoDefaulted flag + setDefaultColorAxisIndex
- clear active bookmark on unmount (Vuex module state is shared across single-spa remounts)
- reset to default view (full-width cohorts, no chart, default filters) when a dataset/release switch completes
- keep the splitpanes pane static (remove width slide transition)
- restore bookmark on ?bmkId remount (loadBookmark was ignoring its args)
- register clearUnsavedChanges with the cross-app registry (adds clearAll)
- style UnsavedChangesDialog to the Figma spec (explicit #80, 18px title)
- tests for the colorAxis normalization, chart flag, registry, and composable
- rename DirtyStateAwareRouter -> NavigationGuardRouter
- remove the unreliable single-spa back-button (popstate) handler; rely on beforeunload + in-app link guard (back-button is a documented limitation)
- add clearAll() to the registry contract and call it on Leave
- guard dataset/release dropdown switches with a new useGuardedChange hook so the selection reverts to the current value on Stay
- style UnsavedChangesDialog to the Figma spec (explicit #80, 18px title)
- tests for the router, dialog, and useGuardedChange
Documents the window.__d2eUnsavedChangesRegistry contract so other single-spa apps can register hasUnsavedChanges/clearUnsavedChanges and integrate with the portal guard + beforeunload.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a cross-micro-frontend unsaved-changes guard spanning the portal shell and the Patient Analytics (vue-mri-ui-lib) app, aiming to prevent silent loss of cohort builder work by warning users before navigations that would discard uncommitted changes.

Changes:

  • Adds a shared window.__d2eUnsavedChangesRegistry and PA wiring (useUnsavedChanges, beforeunload, baseline handling) to report/clear dirty state across apps.
  • Replaces the portal BrowserRouter with a history-wrapping NavigationGuardRouter to synchronously intercept push/replace and show a shared dialog.
  • Adds guarded non-router changes in the portal (dataset/release switching) plus supporting UI/tests/docs and PA store adjustments to avoid false-positive dirty states.

Reviewed changes

Copilot reviewed 42 out of 43 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
plugins/ui/docs/cross-app-unsaved-changes.md New integration guide for registering apps with the cross-app unsaved-changes registry.
plugins/ui/bun.lock Lockfile updates for new UI dependencies (e.g., MDI font, sass).
plugins/ui/apps/vue-mri-ui-lib/vitest.setup.ts Test-environment polyfills for Vuetify (visualViewport, matchMedia).
plugins/ui/apps/vue-mri-ui-lib/vite.config.ts Vitest config tweaks (inline Vuetify deps for test server).
plugins/ui/apps/vue-mri-ui-lib/src/styles/themes/_main.scss Defines primary color CSS variable used by dialogs/theme-aware components.
plugins/ui/apps/vue-mri-ui-lib/src/styles/style.scss Overrides Bootstrap .text-primary to respect app theme primary color.
plugins/ui/apps/vue-mri-ui-lib/src/store/mutation-types.ts Adds mutation constants for bookmark restore/baseline and color-axis default tracking.
plugins/ui/apps/vue-mri-ui-lib/src/store/modules/chart.ts Tracks auto-defaulted color axis and fixes reset sequencing.
plugins/ui/apps/vue-mri-ui-lib/src/store/modules/bookmark.ts Adds restore/baseline logic to stabilize dirty-state detection and prevent false positives.
plugins/ui/apps/vue-mri-ui-lib/src/store/modules/tests/chart.colorAxis.test.ts Unit tests for color-axis auto-default tracking.
plugins/ui/apps/vue-mri-ui-lib/src/store/modules/tests/bookmark.test.ts Expanded unit tests for baseline-based dirty detection and normalization rules.
plugins/ui/apps/vue-mri-ui-lib/src/shared/unsavedChangesRegistry.ts New cross-app registry singleton exposed on window.__d2eUnsavedChangesRegistry.
plugins/ui/apps/vue-mri-ui-lib/src/shared/tests/unsavedChangesRegistry.test.ts Unit tests for registry behavior (dirty checks, unregister, clearAll).
plugins/ui/apps/vue-mri-ui-lib/src/plugins/vuetify.ts Adds MDI icon font import required by the new dialog (close icon).
plugins/ui/apps/vue-mri-ui-lib/src/lifecycles.ts Wires portal dataset/release changes through PA unsaved-changes guard.
plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts Updates unsaved-changes strings and adds “Stay/Leave without saving” labels.
plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts New composable for PA dirty detection, registry integration, and guarded actions.
plugins/ui/apps/vue-mri-ui-lib/src/composables/useDeepLink.ts Adds optional guard hook when deep-link loading would overwrite dirty state.
plugins/ui/apps/vue-mri-ui-lib/src/composables/tests/useUnsavedChanges.test.ts Unit tests for the PA unsaved-changes composable behavior.
plugins/ui/apps/vue-mri-ui-lib/src/components/UnsavedChangesDialog.vue New PA dialog component for guarded in-app actions.
plugins/ui/apps/vue-mri-ui-lib/src/components/ShinyViewer/SaveCohortModal.vue Captures baseline after save to clear dirty state post-save.
plugins/ui/apps/vue-mri-ui-lib/src/components/PatientAnalytics.vue Resets view on dataset switch completion; disables splitpanes width transition.
plugins/ui/apps/vue-mri-ui-lib/src/components/FiltersFooter.vue Captures baseline after save/reload flow to avoid lingering dirty state.
plugins/ui/apps/vue-mri-ui-lib/src/components/ChartController.vue Uses auto-default setter when app chooses a default color axis.
plugins/ui/apps/vue-mri-ui-lib/src/components/Bookmarks.vue Replaces legacy save/discard prompt with shared guard; captures baselines for Atlas/new flows.
plugins/ui/apps/vue-mri-ui-lib/src/components/tests/UnsavedChangesDialog.test.ts Unit tests for the new PA unsaved-changes dialog.
plugins/ui/apps/vue-mri-ui-lib/src/components/tests/App.startup.test.ts Updates startup test selectors; stubs UnsavedChangesDialog.
plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/portalPropsListener.ts Adds guardChange hook for dataset/release changes coming from the portal.
plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/datasetWatcher.ts Clears active bookmark when dataset reload begins (prevents stale dirty state).
plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/portalPropsListener.test.ts Tests for guarding dataset/release changes via guardChange.
plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/datasetWatcher.test.ts Updates expectations for bookmark-clearing on dataset reload.
plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/datasetPropagation.test.ts Updates expectations for bookmark-clearing in dataset propagation path.
plugins/ui/apps/vue-mri-ui-lib/src/App.vue Installs/uninstalls unsaved-changes wiring and renders the shared PA dialog.
plugins/ui/apps/vue-mri-ui-lib/package.json Adds @mdi/font dependency and reorders dependency list.
plugins/ui/apps/portal/src/index.tsx Wraps the portal app with NavigationGuardRouter instead of BrowserRouter.
plugins/ui/apps/portal/src/hooks/useGuardedChange.ts New hook to guard dataset/release switches against cross-app dirty state.
plugins/ui/apps/portal/src/hooks/tests/useGuardedChange.test.tsx Tests for guarded change behavior and dialog flow.
plugins/ui/apps/portal/src/components/UnsavedChangesDialog/UnsavedChangesDialog.tsx New portal-level shared unsaved-changes dialog (MUI).
plugins/ui/apps/portal/src/components/UnsavedChangesDialog/tests/UnsavedChangesDialog.test.tsx Tests for portal dialog rendering and callbacks.
plugins/ui/apps/portal/src/components/NavigationGuardRouter/NavigationGuardRouter.tsx New router wrapper intercepting push/replace and coordinating clearAll() before leaving.
plugins/ui/apps/portal/src/components/NavigationGuardRouter/tests/NavigationGuardRouter.test.tsx Tests for blocking/resuming navigation via the portal router guard.
plugins/ui/apps/portal/src/components/Header/SelectRelease/SelectRelease.tsx Guards release switching against unsaved changes and shows shared dialog.
plugins/ui/apps/portal/src/components/Header/SelectDataset/SelectDataset.tsx Guards dataset switching against unsaved changes and shows shared dialog.

Comment thread plugins/ui/apps/vue-mri-ui-lib/src/composables/useDeepLink.ts
Comment thread plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts
Comment thread plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts
Comment thread plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts
…omise and deep-link error handling

- Bookmarks.vue reset(): return the resetChart() promise so addNewCohort's
  `await this.reset()` actually waits for setIFRState + setupChartDefaults to
  settle before snapshotting the baseline — fixes false dirty state when
  create-new-cohort → load saved bookmark → create-new-cohort → load saved bookmark
- addNewCohort: await $nextTick() after reset before capturing baseline so
  reactive chart defaults (colorAxis auto-default) have flushed
- confirmRenameBookmark: preserve and restore activeBookmarkBaseline around the
  metadata-only SET_ACTIVE_BOOKMARK commit — prevents rename from dropping the
  baseline and falling back to the fragile legacy raw-JSON dirty comparison
- QueryFilterModern saveAtlasCohort: recapture baseline after SET_ACTIVE_BOOKMARK
  on both create and update paths — persisted state is the new clean reference
- useDeepLink: wrap load() body in its own try/catch so a failed restore
  surfaces the error toast when the guard invokes load() detached (the guard
  does not await it, so the outer try/catch would never see the rejection)
- useUnsavedChanges: use types.SET_ACTIVE_BOOKMARK_BASELINE constant instead
  of the raw string literal in clearUnsavedChanges
…ways dirty

A cohort loaded from a deep link or Atlas import has never been saved as a
PA bookmark. It should always report dirty so the user is prompted before
navigating away, until they explicitly save it.

- loadBookmarkDataToState: remove the post-restore baseline capture so the
  deep-link bookmark has no baseline
- getCurrentBookmarkHasChanges: change the no-baseline + no-.bookmark branch
  to return true instead of false — no saved JSON and no baseline means
  unsaved external work, not a blank new cohort (addNewCohort captures its
  own baseline immediately after reset, keeping blank cohorts clean)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement - Warn users when navigating away from cohort builder with unsaved changes

5 participants