UnsavedChangesGuard

Description

UnsavedChangesGuard is a static helper that stops an editor's unsaved changes from being lost without the user choosing that. While anything is tracked it warns on tab close and reload through beforeunload, and when called from Router.OnBeforeNavigate it blocks in-app navigation and shows a "Save and leave / Leave without saving / Stay here" dialog. Its companion TabSaveIndicator marks Pivot tabs that hold unsaved changes and registers a save handler for each, so the guard can save a tab without knowing what kind of editor it holds.

Use UnsavedChangesGuard.Track(...) for a single editor (a form, a settings panel, a modal) and add TabSaveIndicator when editors are hosted as tabs.

Usage

Register the editor with Track(key, name, isDirty, save) while it is on screen and call Forget(key) when it is removed. CanNavigateAway(target, from) returns true when nothing would be lost; otherwise it cancels the navigation, asks the user, and re-issues the navigation itself if the user chooses to leave.

Router keeps a single OnBeforeNavigate handler, so call the guard from inside the handler the app already registers and pass its current state as from, so that navigating to the route already showing is not treated as leaving it:

Router.OnBeforeNavigate((newState, currentState, isBack) =>
    UnsavedChangesGuard.CanNavigateAway(newState, currentState));

For editors hosted as Pivot tabs, call UnsavedChangesGuard.TrackOpenTabs() when the hosting view mounts and ForgetOpenTabs() when it is removed. Each tab uses TabSaveIndicator.Title(id, text) as its title, calls MarkDirty(id) / MarkClean(id) from its own change tracking, and registers its save with OnSave(id, saveAsync).

API reference

class

UnsavedChangesGuard

public static class UnsavedChangesGuard

Stops an editor carrying unsaved changes from being thrown away without the user saying so. Two ways of losing one are covered: reloading / closing the browser tab — a beforeunload listener asks the browser to show its native "leave site?" prompt; navigating to another route — CanNavigateAway (wire it into OnBeforeNavigate) cancels the navigation, asks whether to save, and only then lets it through. There are two ways to feed it. A modal or a standalone editor registers itself with Track and drops out with Forget. A view hosting editors as pivot tabs instead calls TrackOpenTabs, which reads the dirty tabs straight off the DOM via TabSaveIndicator — tabs come and go faster than registrations can be kept honest, and a torn-down tab can't leave a stale entry behind that way. Closing a modal directly (its own close button, a light-dismiss click) is NOT covered here — that path has to ask its own confirmation before the modal hides, since by the time it is gone there is nothing left to intercept.

Namespace
Tesserae

Methods

NameDescription
TrackRegisters a single editor for as long as it is on screen. key identifies it (re-opening the same editor replaces its entry), name is what the user is told has unsaved changes, and save returns whether the save actually went through. Always pair this with Forget on the editor's close / removal path.
ForgetDrops a Tracked editor — call it when that editor closes.
TrackOpenTabsStarts watching the open editor tabs — call it when the hosting view is mounted.
ForgetOpenTabsStops watching the open editor tabs — call it when the hosting view is removed.
CanNavigateAwayA OnBeforeNavigate answer. Returns true when nothing would be lost; otherwise cancels this navigation and asks the user what to do with the unsaved editors, re-issuing the navigation once they have decided. from is the route being left (the handler's second argument) — pass it so that going nowhere isn't mistaken for leaving.
CollectUnsavedEvery editor that currently holds changes the user has not saved.
RefreshUnloadListenerThe beforeunload listener only needs to exist while something is being watched, so adding and removing it follows the two registration paths.
SaveAllAsyncSaves every unsaved editor, stopping at the first one that refuses. Editors say what went wrong themselves, but not all of them do — so name the one we stopped on, otherwise "Save and leave" would look like it did nothing.
SameRouteWhether two full URLs address the same route. Only the hash carries the route, and the browser may hand back a differently-spelled absolute URL for the same one — it percent-encodes whatever we navigated to, so a route we confirmed as "#/view/My page" comes back as "#/view/My%20page". Compare the decoded hashes, or the guard would ask again about the navigation the user just confirmed and never let it through.
DecodeRoutePercent-decodes a route, leaving it alone if it isn't valid encoding.
Method
UnsavedChangesGuard.Track
public static void Track(string key, Func<string> name, Func<bool> isDirty, Func<Task<bool>> save)

Registers a single editor for as long as it is on screen. key identifies it (re-opening the same editor replaces its entry), name is what the user is told has unsaved changes, and save returns whether the save actually went through. Always pair this with Forget on the editor's close / removal path.

Method
UnsavedChangesGuard.Forget
public static void Forget(string key)

Drops a Tracked editor — call it when that editor closes.

Method
UnsavedChangesGuard.TrackOpenTabs
public static void TrackOpenTabs()

Starts watching the open editor tabs — call it when the hosting view is mounted.

Method
UnsavedChangesGuard.ForgetOpenTabs
public static void ForgetOpenTabs()

Stops watching the open editor tabs — call it when the hosting view is removed.

Method
UnsavedChangesGuard.CanNavigateAway
public static bool CanNavigateAway(Router.State target, Router.State from = null)

A OnBeforeNavigate answer. Returns true when nothing would be lost; otherwise cancels this navigation and asks the user what to do with the unsaved editors, re-issuing the navigation once they have decided. from is the route being left (the handler's second argument) — pass it so that going nowhere isn't mistaken for leaving.

Method
UnsavedChangesGuard.CollectUnsaved
private static List<UnsavedEditor> CollectUnsaved()

Every editor that currently holds changes the user has not saved.

Method
UnsavedChangesGuard.RefreshUnloadListener
private static void RefreshUnloadListener()

The beforeunload listener only needs to exist while something is being watched, so adding and removing it follows the two registration paths.

Method
UnsavedChangesGuard.SaveAllAsync
private static async Task<bool> SaveAllAsync(List<UnsavedEditor> unsaved)

Saves every unsaved editor, stopping at the first one that refuses. Editors say what went wrong themselves, but not all of them do — so name the one we stopped on, otherwise "Save and leave" would look like it did nothing.

Method
UnsavedChangesGuard.SameRoute
private static bool SameRoute(string a, string b)

Whether two full URLs address the same route. Only the hash carries the route, and the browser may hand back a differently-spelled absolute URL for the same one — it percent-encodes whatever we navigated to, so a route we confirmed as "#/view/My page" comes back as "#/view/My%20page". Compare the decoded hashes, or the guard would ask again about the navigation the user just confirmed and never let it through.

Method
UnsavedChangesGuard.DecodeRoute
private static string DecodeRoute(string route)

Percent-decodes a route, leaving it alone if it isn't valid encoding.

class

TabSaveIndicator

public static class TabSaveIndicator

Toggles a CSS class on a Pivot tab title element so it can show an "unsaved changes" marker: a dot standing in for the close cross on a closeable tab, or beside the label on a tab without one. Both are drawn from tss.pivot.css.

Remarks

An editor hosted as a pivot tab calls MarkDirty / MarkClean from inside its own validation / save handling. The host gives the tab title element an id (any string that is stable for as long as the tab is open) and the editor references that same id, so the host and the editor don't need to pass tab/component references back and forth. An editor also registers its save handler here via OnSave, which is what lets UnsavedChangesGuard offer to save the tab instead of only offering to discard it.

Namespace
Tesserae

Methods

NameDescription
TabIdBuilds a stable tab indicator id from an item type and uid, e.g. tab-endpoint-{uid}.
TitleA pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip.
IsDirtyWhether the tab currently shows the unsaved-changes indicator.
OnSaveRegisters the editor's save handler for tabIndicatorId. It must return whether the save actually succeeded — an editor that refuses to save (incomplete form, compile error, …) returns false and is expected to tell the user why.
ForgetDrops the registered save handler — call this when the tab closes.
CanSaveWhether an editor registered a save handler for this tab.
SaveAsyncRuns the tab's registered save handler. Returns false when the tab has no handler or the editor reported the save didn't go through.
DirtyTabIdsThe ids of every tab currently showing the unsaved-changes indicator. Reading them back off the DOM (rather than a registry we'd have to keep in sync) means a tab that was torn down can't leave a stale entry behind.
TitleOfThe tab's visible title, used when telling the user which editors are unsaved.
Method
TabSaveIndicator.TabId
public static string TabId(string itemType, object uid)

Builds a stable tab indicator id from an item type and uid, e.g. tab-endpoint-{uid}.

Method
TabSaveIndicator.Title
Overload
Title(string, string)A pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip.
Title(string, string, UIcons)A pivot tab title with an icon that carries tabIndicatorId. See Title.
Title(string, string)
public static Func<IComponent> Title(string tabIndicatorId, string text)

A pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip.

Parameters

tabIndicatorId string
text string
Title(string, string, UIcons)
public static Func<IComponent> Title(string tabIndicatorId, string text, UIcons icon)

A pivot tab title with an icon that carries tabIndicatorId. See Title.

Parameters

tabIndicatorId string
text string
icon UIcons
Method
TabSaveIndicator.IsDirty
public static bool IsDirty(string tabIndicatorId)

Whether the tab currently shows the unsaved-changes indicator.

Method
TabSaveIndicator.OnSave
public static void OnSave(string tabIndicatorId, Func<Task<bool>> saveAsync)

Registers the editor's save handler for tabIndicatorId. It must return whether the save actually succeeded — an editor that refuses to save (incomplete form, compile error, …) returns false and is expected to tell the user why.

Method
TabSaveIndicator.Forget
public static void Forget(string tabIndicatorId)

Drops the registered save handler — call this when the tab closes.

Method
TabSaveIndicator.CanSave
public static bool CanSave(string tabIndicatorId)

Whether an editor registered a save handler for this tab.

Method
TabSaveIndicator.SaveAsync
public static Task<bool> SaveAsync(string tabIndicatorId)

Runs the tab's registered save handler. Returns false when the tab has no handler or the editor reported the save didn't go through.

Method
TabSaveIndicator.DirtyTabIds
public static string[] DirtyTabIds()

The ids of every tab currently showing the unsaved-changes indicator. Reading them back off the DOM (rather than a registry we'd have to keep in sync) means a tab that was torn down can't leave a stale entry behind.

Method
TabSaveIndicator.TitleOf
public static string TitleOf(string tabIndicatorId)

The tab's visible title, used when telling the user which editors are unsaved.

See also