diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index bccb76dab..7fe9d067a 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -57,6 +57,10 @@ of the following.
## Vue / Quasar
+UI rules beyond this list (color roles, typography, spacing, motion, component
+choice) live in `DESIGN.md`, which is authoritative. Consult it before building
+or changing UI; the rules below are the subset worth flagging in review.
+
- Use Quasar components (`q-btn`, `q-dialog`, `q-banner`, etc.). Selects need
`dense` + `options-dense`.
- **Dialogs (`q-dialog`)**: must have an accessible name (`aria-labelledby`
@@ -69,7 +73,13 @@ of the following.
`role="alert"`; warning/info default to `role="status"`. Use
`live="assertive"` only for direct user-action responses, not persistent
state indicators. Razor pages use `q-banner` with accessible classes
- (`bg-warning text-dark`, `role="status"` or `role="alert"`).
+ (`bg-warning text-dark`, `role="status"` or `role="alert"`) for these
+ in-flow banners.
+- **Status messages, toast vs banner**: a banner is a persistent, in-flow
+ message tied to page state. Transient confirmation that an action completed
+ is a toast instead, in Razor and Vue alike: `showStatusNotification()`, or
+ `queueStatusNotification()` when the action redirects. Do not swap these for
+ `q-banner` or Quasar `Notify`, neither works here; see `DESIGN.md`.
- **Button colors**: `primary` (Aggie Blue), `positive` (create), `negative`
(delete), `info text-color="dark"` (tertiary), `warning text-color="dark"`
(caution), `secondary`.
diff --git a/DESIGN.md b/DESIGN.md
index 0394d9e9f..a6210f794 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -341,7 +341,9 @@ Use **`StatusBadge`**, which wraps `q-badge` and does two things automatically:
### Banners
-Use **`StatusBanner`** in Vue SPAs with `type="success|error|warning|info"`. Each type carries its own Material icon (`check_circle`, `error`, `warning`, `info`), a 12% tint background (15% for warning), a 0.25rem left border, and matching text color. Only `type="error"` is assertive (`role="alert"`) by default; everything else is polite (`role="status"`). Override with `live`: `live="assertive"` for a warning or info banner shown in direct response to a user action, `live="off"` for a decorative banner with no dynamic content. Do not reach for `type="warning"` to force an assertive announcement on a persistent state indicator. Banners are `rounded` with `inline-actions`, sit on `q-mb-md`, and accept an optional dismiss button. Razor pages use `q-banner` with accessible classes. Error surfaces outside `StatusBanner` use the shared `.error-surface` treatment so `GenericError` and expired-session dialogs match.
+Use **`StatusBanner`** in Vue SPAs with `type="success|error|warning|info"`. Each type carries its own Material icon (`check_circle`, `error`, `warning`, `info`), a 12% tint background (15% for warning), a 0.25rem left border, and matching text color. Only `type="error"` is assertive (`role="alert"`) by default; everything else is polite (`role="status"`). Override with `live`: `live="assertive"` for a warning or info banner shown in direct response to a user action, `live="off"` for a decorative banner with no dynamic content. Do not reach for `type="warning"` to force an assertive announcement on a persistent state indicator. Banners are `rounded` with `inline-actions`, sit on `q-mb-md`, and accept an optional dismiss button. Razor pages use `q-banner` with accessible classes for these in-flow banners. Error surfaces outside `StatusBanner` use the shared `.error-surface` treatment so `GenericError` and expired-session dialogs match.
+
+**Banner or toast?** A banner is a persistent message that sits in the page flow and reflects page state: validation, warnings, empty and error states. Transient confirmation that an action just completed is the status toast (`.viper-status-notification`) instead, in Razor and Vue alike, via `showStatusNotification()`, or `queueStatusNotification()` when the action redirects and the message has to survive the navigation. Quasar's `Notify` is unusable in the Razor pages: the app mounts on `
`, so Notify's teleport container falls outside Vue's reactive scope and messages are dropped silently.
### Cards and Containers
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 48af0f47a..d5c1b7075 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -83,6 +83,8 @@ export default [
getItemFromStorage: "readonly",
putItemInStorage: "readonly",
showStatusNotification: "readonly",
+ queueStatusNotification: "readonly",
+ showQueuedStatusNotification: "readonly",
Quasar: "readonly",
},
},
diff --git a/test/Classes/LeftNavHighlightTests.cs b/test/Classes/LeftNavHighlightTests.cs
new file mode 100644
index 000000000..d07b4cea4
--- /dev/null
+++ b/test/Classes/LeftNavHighlightTests.cs
@@ -0,0 +1,158 @@
+using Viper.Classes;
+
+namespace Viper.test.Classes
+{
+ public class LeftNavHighlightTests
+ {
+ // Stands in for IUrlHelper.Content. The prefix mimics the "/2" PathBase used on
+ // TEST and PROD, so resolution is exercised the way it behaves off the app root.
+ private const string PathBase = "/2";
+ private static string ResolveAppPath(string url) => PathBase + url.TrimStart('~');
+
+ private static (int ActiveIndex, int SecondaryActiveIndex) FindActive(List items, string requestPath)
+ => LeftNavHighlight.FindActive(items, requestPath, ResolveAppPath);
+
+ private static NavMenuItem Link(string url) => new() { MenuItemText = url, MenuItemURL = url };
+
+ [Fact]
+ public void FindActive_RelativeLinkForCurrentPage_IsPrimary()
+ {
+ var items = new List { Link("Rolelist"), Link("RoleTemplateList") };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/RoleTemplateList");
+
+ Assert.Equal(1, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_ChildPage_HighlightsParentItem()
+ {
+ // RoleTemplateRoles has no nav entry, so the Role Templates item stays lit.
+ var items = new List
+ {
+ Link("Rolelist"),
+ new() { MenuItemText = "Role Templates", MenuItemURL = "RoleTemplateList", ChildPageURLs = { "RoleTemplateRoles", "RoleTemplateApply" } }
+ };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/RoleTemplateRoles");
+
+ Assert.Equal(1, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_ChildPageWithQueryString_HighlightsParentItem()
+ {
+ // The child page is always reached with ?roleTemplateId=, which is not part of the path.
+ var items = new List
+ {
+ new() { MenuItemText = "Role Templates", MenuItemURL = "RoleTemplateList", ChildPageURLs = { "RoleTemplateApply?roleTemplateId=1" } }
+ };
+
+ var (active, _) = FindActive(items, "/2/raps/Viper/RoleTemplateApply");
+
+ Assert.Equal(0, active);
+ }
+
+ [Fact]
+ public void FindActive_UnrelatedPage_HighlightsNothing()
+ {
+ var items = new List
+ {
+ Link("Rolelist"),
+ new() { MenuItemText = "Role Templates", MenuItemURL = "RoleTemplateList", ChildPageURLs = { "RoleTemplateRoles" } }
+ };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/AuditTrail");
+
+ Assert.Equal(-1, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_OnlyInstanceLinkMatches_IsPromotedToPrimary()
+ {
+ var items = new List { Link("~/raps/Viper/RoleList"), Link("~/raps/VMACS.VMTH/RoleList") };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/RoleList");
+
+ Assert.Equal(0, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_PageAndInstanceLinkMatch_PageLinkIsPrimary()
+ {
+ var items = new List { Link("~/raps/Viper/Rolelist"), Link("Rolelist") };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/Rolelist");
+
+ Assert.Equal(1, active);
+ Assert.Equal(0, secondary);
+ }
+
+ [Fact]
+ public void FindActive_MatchIsCaseInsensitiveAndIgnoresTrailingSlash()
+ {
+ var items = new List { Link("rolelist/") };
+
+ var (active, _) = FindActive(items, "/2/raps/Viper/RoleList");
+
+ Assert.Equal(0, active);
+ }
+
+ [Fact]
+ public void FindActive_ExternalAndEmptyUrls_NeverMatch()
+ {
+ var items = new List
+ {
+ new() { MenuItemText = "Header", MenuItemURL = "" },
+ Link("https://ucdavis.edu/2/raps/Viper/RoleList"),
+ Link("mailto:someone@ucdavis.edu")
+ };
+
+ var (active, secondary) = FindActive(items, "/2/raps/Viper/RoleList");
+
+ Assert.Equal(-1, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_UnresolvableInstanceLink_NeverMatches()
+ {
+ // IUrlHelper.Content is nullable, so a URL it cannot resolve must not be
+ // compared against the request path as if it had resolved to nothing.
+ var items = new List { Link("~/raps/Viper/RoleList") };
+
+ var (active, secondary) = LeftNavHighlight.FindActive(items, "/2/raps/Viper/RoleList", _ => null);
+
+ Assert.Equal(-1, active);
+ Assert.Equal(-1, secondary);
+ }
+
+ [Fact]
+ public void FindActive_RequestPathWithTrailingSlash_StillMatches()
+ {
+ var items = new List { Link("RoleList") };
+
+ var (active, _) = FindActive(items, "/2/raps/Viper/RoleList/");
+
+ Assert.Equal(0, active);
+ }
+
+ [Fact]
+ public void FindActive_RootRelativeUrl_MatchesWithoutBasePath()
+ {
+ // Regression guard, and not as trivial as it looks: Uri parses a leading-slash
+ // path as an absolute file:// URI on Unix but not on Windows, so ordering the
+ // absolute-URL check before the root-relative one passes on a dev machine and
+ // fails on the Linux CI runner.
+ var items = new List { Link("/2/raps/Viper/RoleList") };
+
+ var (active, _) = FindActive(items, "/2/raps/Viper/RoleList");
+
+ Assert.Equal(0, active);
+ }
+ }
+}
diff --git a/web/Areas/RAPS/Controllers/RAPSController.cs b/web/Areas/RAPS/Controllers/RAPSController.cs
index bd1d7d515..aaaf3c877 100644
--- a/web/Areas/RAPS/Controllers/RAPSController.cs
+++ b/web/Areas/RAPS/Controllers/RAPSController.cs
@@ -128,7 +128,13 @@ public async Task Nav(int? roleId, int? permissionId, string? memberId,
}
if (_securityService.IsAllowedTo("ViewRoles", instance))
{
- nav.Add(new NavMenuItem { MenuItemText = "Role Templates", MenuItemURL = "RoleTemplateList" });
+ nav.Add(new NavMenuItem
+ {
+ MenuItemText = "Role Templates",
+ MenuItemURL = "RoleTemplateList",
+ // These pages are reached from the template listing and have no nav entry of their own
+ ChildPageURLs = { "RoleTemplateRoles", "RoleTemplateApply" }
+ });
}
if (selectedRole != null && RAPSSecurityService.RoleBelongsToInstance(instance, selectedRole))
{
diff --git a/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml b/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml
index 2b1818408..dbbd907d0 100644
--- a/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml
+++ b/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml
@@ -37,28 +37,29 @@
methods: {
loadRoles: async function() {
this.rolesLoaded = false
- var childRoles = await viperFetch(this, "Roles/ControlledBy/" + + this.urlParams.get("roleId"))
- var allRoles = await viperFetch(this, "Roles?Application=0")
+ this.loadingRoles = true
+ const [childRoles, allRoles] = await Promise.all([
+ viperFetch(this, "Roles/ControlledBy/" + this.urlParams.get("roleId")),
+ viperFetch(this, "Roles?Application=0")
+ ])
+ this.loadingRoles = false
// A failed or still-pending read must not look like "nothing is
// selected": submitting that would PUT an empty list and wipe the
// existing delegations. Stay disabled until both reads succeed.
if (childRoles === undefined || allRoles === undefined) {
return
}
+ const childRoleIds = new Set(childRoles.map(cr => cr.roleId))
this.selectedRoles = childRoles
- this.roles = childRoles.concat(
- allRoles.filter(r => childRoles.find(cr => cr.roleId === r.roleId) === undefined))
+ this.roles = childRoles.concat(allRoles.filter(r => !childRoleIds.has(r.roleId)))
this.rolesLoaded = true
},
submitChanges: async function() {
if (!this.rolesLoaded) {
return
}
- var roleIds = this.selectedRoles.reduce( (result, role) => {
- result.push(role.roleId)
- return result
- }, [])
- viperFetch(this,
+ const roleIds = this.selectedRoles.map(role => role.roleId)
+ const result = await viperFetch(this,
"Roles/ControlledBy/" + this.urlParams.get("roleId"),
{
method: "PUT",
@@ -67,12 +68,14 @@
},
[this.loadRoles]
)
+ if (result !== undefined) {
+ showStatusNotification("Delegated roles updated")
+ }
}
},
async mounted() {
await this.loadRoles()
this.role = (await viperFetch(this, "Roles/" + this.urlParams.get("roleId"))) ?? {}
-
}
})
diff --git a/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml b/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml
index 89f742c15..10002d5b8 100644
--- a/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml
+++ b/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml
@@ -12,7 +12,7 @@
:pagination="{rowsPerPage:0}"
:loading="loadingRoles">
-
+
@@ -32,6 +32,8 @@
roles: [],
selectedRoles: [],
loadingRoles: false,
+ // Starts false so the page is disabled until the initial read succeeds.
+ rolesLoaded: false,
filter: "",
columns: [
{ name: "role", label: "Role", field: "friendlyName", align: "left" },
@@ -41,27 +43,42 @@
},
methods: {
loadRoles: async function () {
- var templateRoles = await viperFetch(this, "RoleTemplates/" + + this.urlParams.get("roleTemplateId") + "/Roles")
- var allRoles = (await viperFetch(this, "Roles?Application=0"))
- .filter(r => templateRoles.find(tr => tr.roleId === r.roleId) === undefined)
- this.selectedRoles = templateRoles;
- this.roles = templateRoles.concat(allRoles)
+ this.rolesLoaded = false
+ this.loadingRoles = true
+ const [templateRoles, allRoles] = await Promise.all([
+ viperFetch(this, "RoleTemplates/" + this.urlParams.get("roleTemplateId") + "/Roles"),
+ viperFetch(this, "Roles?Application=0")
+ ])
+ this.loadingRoles = false
+ // A failed or still-pending read must not look like "nothing is
+ // selected": submitting that would PUT an empty list and wipe the
+ // template's roles. Stay disabled until both reads succeed.
+ if (templateRoles === undefined || allRoles === undefined) {
+ return
+ }
+ const templateRoleIds = new Set(templateRoles.map(tr => tr.roleId))
+ this.selectedRoles = templateRoles
+ this.roles = templateRoles.concat(allRoles.filter(r => !templateRoleIds.has(r.roleId)))
this.filter = ""
+ this.rolesLoaded = true
},
submitChanges: async function () {
- var roleIds = this.selectedRoles.reduce((result, role) => {
- result.push(role.roleId)
- return result
- }, [])
- viperFetch(this,
+ if (!this.rolesLoaded) {
+ return
+ }
+ const roleIds = this.selectedRoles.map(role => role.roleId)
+ const result = await viperFetch(this,
"RoleTemplates/" + this.urlParams.get("roleTemplateId") + "/Roles",
{
method: "PUT",
body: JSON.stringify(roleIds),
headers: { "Content-Type": "application/json" }
- },
- [this.loadRoles]
+ }
)
+ if (result !== undefined) {
+ queueStatusNotification("Template roles updated")
+ location.href = "RoleTemplateList"
+ }
}
},
async mounted() {
diff --git a/web/Areas/RAPS/Views/Roles/Templates.cshtml b/web/Areas/RAPS/Views/Roles/Templates.cshtml
index 4ef26a23f..1b467e6a4 100644
--- a/web/Areas/RAPS/Views/Roles/Templates.cshtml
+++ b/web/Areas/RAPS/Views/Roles/Templates.cshtml
@@ -147,6 +147,7 @@
}
},
async mounted() {
+ showQueuedStatusNotification()
this.templates.load()
}
})
diff --git a/web/Classes/LeftNavHighlight.cs b/web/Classes/LeftNavHighlight.cs
new file mode 100644
index 000000000..a1bdf4efe
--- /dev/null
+++ b/web/Classes/LeftNavHighlight.cs
@@ -0,0 +1,107 @@
+namespace Viper.Classes
+{
+ ///
+ /// Works out which left nav item corresponds to the page being rendered.
+ /// Page links (relative URLs) get the primary highlight; instance links (tilde-prefixed
+ /// URLs that point to the same page in a different RAPS instance) get a secondary
+ /// highlight. When only an instance link matches, it is promoted to the primary highlight.
+ ///
+ public static class LeftNavHighlight
+ {
+ ///
+ /// Find the menu items to highlight for the current request.
+ ///
+ /// Menu items, in render order.
+ /// The request path, e.g. "/raps/Viper/Rolelist".
+ /// Resolves a "~/" URL to an app path, i.e. IUrlHelper.Content.
+ /// Indexes into , or -1 when nothing matches.
+ public static (int ActiveIndex, int SecondaryActiveIndex) FindActive(
+ IReadOnlyList items,
+ string requestPath,
+ Func resolveAppPath)
+ {
+ string currentPath = requestPath.TrimEnd('/');
+ // Relative menu URLs resolve against the current page's directory,
+ // e.g. "/raps/Viper/" from "/raps/Viper/Rolelist".
+ string basePath = currentPath[..(currentPath.LastIndexOf('/') + 1)];
+ int activePageIndex = -1;
+ int activeInstanceIndex = -1;
+ for (int i = 0; i < items.Count; i++)
+ {
+ NavMenuItem item = items[i];
+ if (string.IsNullOrEmpty(item.MenuItemURL))
+ {
+ continue;
+ }
+
+ // A child page has no nav entry of its own, so it highlights its parent item.
+ bool matched = IsCurrentPage(item.MenuItemURL, currentPath, basePath, resolveAppPath)
+ || item.ChildPageURLs.Exists(childUrl => IsCurrentPage(childUrl, currentPath, basePath, resolveAppPath));
+ if (!matched)
+ {
+ continue;
+ }
+
+ bool isTilde = item.MenuItemURL.StartsWith('~');
+ if (isTilde && activeInstanceIndex < 0)
+ {
+ activeInstanceIndex = i;
+ }
+ else if (!isTilde && activePageIndex < 0)
+ {
+ activePageIndex = i;
+ }
+ }
+
+ return activePageIndex >= 0
+ ? (activePageIndex, activeInstanceIndex)
+ : (activeInstanceIndex, -1);
+ }
+
+ private static bool IsCurrentPage(string url, string currentPath, string basePath, Func resolveAppPath)
+ {
+ // Headers carry "" as their URL, and NavMenuItem coerces the CMS's nullable
+ // Url column to "" on the way in, so empty is the only non-link value here.
+ if (url.Length == 0)
+ {
+ return false;
+ }
+
+ string rawUrl = url.Split('?')[0].TrimEnd('/');
+ if (rawUrl.Length == 0)
+ {
+ return false;
+ }
+
+ // Resolve the on-site forms first. The absolute-URL test below must not run
+ // before these: on Unix, Uri parses a leading-slash path as an absolute
+ // file:// URI, so testing it first classifies every root-relative menu URL
+ // as off-site and highlights nothing.
+ string resolvedPath;
+ if (rawUrl.StartsWith('~'))
+ {
+ string? appPath = resolveAppPath(rawUrl);
+ if (string.IsNullOrEmpty(appPath))
+ {
+ return false;
+ }
+ resolvedPath = appPath.TrimEnd('/');
+ }
+ else if (rawUrl.StartsWith('/'))
+ {
+ resolvedPath = rawUrl;
+ }
+ else if (Uri.TryCreate(rawUrl, UriKind.Absolute, out _))
+ {
+ // Carries a scheme (http://, https://, mailto:), so it points off this site.
+ return false;
+ }
+ else
+ {
+ resolvedPath = basePath + rawUrl;
+ }
+
+ return string.Equals(currentPath, resolvedPath, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/web/Classes/NavMenuItem.cs b/web/Classes/NavMenuItem.cs
index 805784191..68e00ec05 100644
--- a/web/Classes/NavMenuItem.cs
+++ b/web/Classes/NavMenuItem.cs
@@ -9,6 +9,12 @@ public class NavMenuItem
public bool IsHeader { get; set; }
public int DisplayOrder { get; set; }
public int IndentLevel { get; set; } = 0;
+ ///
+ /// URLs of pages that belong to this menu item but have no nav entry of their own,
+ /// e.g. the Role Templates item covers the pages for editing and applying a template.
+ /// Visiting one of these highlights this item. Resolved like .
+ ///
+ public List ChildPageURLs { get; set; } = new();
public NavMenuItem()
{
diff --git a/web/Views/Shared/Components/LeftNav/Default.cshtml b/web/Views/Shared/Components/LeftNav/Default.cshtml
index 527096250..e0961200a 100644
--- a/web/Views/Shared/Components/LeftNav/Default.cshtml
+++ b/web/Views/Shared/Components/LeftNav/Default.cshtml
@@ -3,47 +3,11 @@
@model Viper.Models.AAUD.AaudUser
@{
NavMenu leftNav = (NavMenu)(ViewData["ViperLeftNav"] ?? new NavMenu());
- var currentPath = ViewContext.HttpContext.Request.Path.ToString().TrimEnd('/');
- // Base path for resolving relative URLs (e.g. "/raps/Viper/" from "/raps/Viper/Rolelist")
- var basePath = currentPath.Substring(0, currentPath.LastIndexOf('/') + 1);
-
- // Pre-compute which items to highlight. Page links (relative URLs) get the primary
- // highlight; instance links (tilde-prefixed URLs that point to the same page in a
- // different RAPS instance) get a secondary highlight. When only an instance link
- // matches, it is promoted to the primary highlight.
var items = leftNav.MenuItems ?? new List();
- int activePageIndex = -1;
- int activeInstanceIndex = -1;
- for (int i = 0; i < items.Count; i++)
- {
- var item = items[i];
- if (string.IsNullOrEmpty(item.MenuItemURL)) { continue; }
- var rawUrl = item.MenuItemURL.Split('?')[0].TrimEnd('/');
- if (rawUrl.StartsWith("http")) { continue; }
-
- bool isTilde = rawUrl.StartsWith('~');
- string resolvedPath;
- if (isTilde)
- {
- resolvedPath = Url.Content(rawUrl).TrimEnd('/');
- }
- else if (rawUrl.StartsWith('/'))
- {
- resolvedPath = rawUrl;
- }
- else
- {
- resolvedPath = basePath + rawUrl;
- }
-
- if (!string.Equals(currentPath, resolvedPath, StringComparison.OrdinalIgnoreCase)) { continue; }
- if (isTilde && activeInstanceIndex < 0) { activeInstanceIndex = i; }
- else if (!isTilde && activePageIndex < 0) { activePageIndex = i; }
- }
- // If no page link matched, promote the instance link to the primary highlight.
- int activeIndex = activePageIndex >= 0 ? activePageIndex : activeInstanceIndex;
- int secondaryActiveIndex = activePageIndex >= 0 ? activeInstanceIndex : -1;
- int renderIndex = 0;
+ // Include PathBase: under the /2 sub-app Request.Path omits it while Url.Content("~/...")
+ // includes it, so without this the instance links never match on TEST/PROD.
+ var requestPath = $"{ViewContext.HttpContext.Request.PathBase}{ViewContext.HttpContext.Request.Path}";
+ var (activeIndex, secondaryActiveIndex) = LeftNavHighlight.FindActive(items, requestPath, Url.Content);
}
@@ -60,13 +24,14 @@
@leftNav.MenuHeaderText
}
- @foreach(var menuItem in items)
+ @for(int i = 0; i < items.Count; i++)
{
+ var menuItem = items[i];
@if(menuItem.MenuItemURL != "")
{
string activeClass;
- if (renderIndex == activeIndex) { activeClass = " leftNavActive"; }
- else if (renderIndex == secondaryActiveIndex) { activeClass = " leftNavActiveSecondary"; }
+ if (i == activeIndex) { activeClass = " leftNavActive"; }
+ else if (i == secondaryActiveIndex) { activeClass = " leftNavActiveSecondary"; }
else { activeClass = ""; }
@@ -82,7 +47,6 @@
}
- renderIndex++;
}
diff --git a/web/wwwroot/js/qtable.js b/web/wwwroot/js/qtable.js
index 4c4c8bf28..301c2b9db 100644
--- a/web/wwwroot/js/qtable.js
+++ b/web/wwwroot/js/qtable.js
@@ -21,6 +21,43 @@ function showStatusNotification(message) {
}, STATUS_NOTIFICATION_DISPLAY_MS)
}
+const STATUS_NOTIFICATION_STORAGE_KEY = "viperQueuedStatusNotification"
+
+/*
+ * Queue a status message to be shown on the next page load, for saves that redirect
+ * (e.g. an edit page that sends the user back to its listing page on success).
+ */
+function queueStatusNotification(message) {
+ // Best effort. sessionStorage throws when site data is blocked or the quota is
+ // full, and losing the message must not stop the caller from redirecting.
+ try {
+ putItemInStorage(STATUS_NOTIFICATION_STORAGE_KEY, message)
+ } catch {
+ // Nothing to do; the save still succeeded and the redirect still matters.
+ }
+}
+
+/*
+ * Show and clear the message queued by queueStatusNotification, if there is one.
+ * Call from the destination page so a queued message is shown once and only once.
+ */
+function showQueuedStatusNotification() {
+ let message = null
+ // Callers run this on mount, ahead of loading their data, so an unreadable
+ // store must not throw and take the rest of the page down with it.
+ try {
+ message = getItemFromStorage(STATUS_NOTIFICATION_STORAGE_KEY)
+ if (message) {
+ removeItemFromStorage(STATUS_NOTIFICATION_STORAGE_KEY)
+ }
+ } catch {
+ // Storage unavailable, so there is nothing queued to show.
+ }
+ if (message) {
+ showStatusNotification(message)
+ }
+}
+
/*
* QuasarTable - code to support a quasar table with an edit dialog and add/update/delete functions, with optional server side paging/filtering and export to csv
*/
diff --git a/web/wwwroot/js/site.js b/web/wwwroot/js/site.js
index 8e467c6b0..e533cc8a3 100644
--- a/web/wwwroot/js/site.js
+++ b/web/wwwroot/js/site.js
@@ -172,3 +172,7 @@ function getItemFromStorage(key) {
function putItemInStorage(key, val) {
globalThis.sessionStorage.setItem(key, JSON.stringify(val))
}
+
+function removeItemFromStorage(key) {
+ globalThis.sessionStorage.removeItem(key)
+}