Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -69,7 +73,17 @@ 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 and the message has to
survive the navigation. Do not convert these to `q-banner`. Quasar's `Notify`
is unusable here because the app mounts on `<body>`, so Notify's teleport
container falls outside Vue's reactive scope and messages are dropped
silently; the toast is a plain DOM element carrying `role="status"` and
`aria-live="polite"`.
- **Button colors**: `primary` (Aggie Blue), `positive` (create), `negative`
(delete), `info text-color="dark"` (tertiary), `warning text-color="dark"`
(caution), `secondary`.
Expand Down
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`, so Notify's teleport container falls outside Vue's reactive scope and messages are dropped silently.

### Cards and Containers

Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export default [
getItemFromStorage: "readonly",
putItemInStorage: "readonly",
showStatusNotification: "readonly",
queueStatusNotification: "readonly",
showQueuedStatusNotification: "readonly",
Quasar: "readonly",
},
},
Expand Down
173 changes: 173 additions & 0 deletions test/Classes/LeftNavHighlightTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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<NavMenuItem> 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<NavMenuItem> { 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()
{
// VPR-158: RoleTemplateRoles has no nav entry, so the Role Templates item stays lit.
var items = new List<NavMenuItem>
{
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<NavMenuItem>
{
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<NavMenuItem>
{
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<NavMenuItem> { 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<NavMenuItem> { 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<NavMenuItem> { Link("rolelist/") };

var (active, _) = FindActive(items, "/2/raps/Viper/RoleList");

Assert.Equal(0, active);
}

[Fact]
public void FindActive_ExternalAndEmptyUrls_NeverMatch()
{
var items = new List<NavMenuItem>
{
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<NavMenuItem> { 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_NullChildPageUrl_IsSkippedNotThrown()
{
// CMS nav URLs come from a nullable column. A null must be skipped so the
// remaining child URLs are still considered and the nav still renders.
var items = new List<NavMenuItem>
{
new() { MenuItemText = "Role Templates", MenuItemURL = "RoleTemplateList", ChildPageURLs = { null!, "RoleTemplateRoles" } }
};

var (active, _) = FindActive(items, "/2/raps/Viper/RoleTemplateRoles");

Assert.Equal(0, active);
}

[Fact]
public void FindActive_RequestPathWithTrailingSlash_StillMatches()
{
var items = new List<NavMenuItem> { 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<NavMenuItem> { Link("/2/raps/Viper/RoleList") };

var (active, _) = FindActive(items, "/2/raps/Viper/RoleList");

Assert.Equal(0, active);
}
}
}
8 changes: 7 additions & 1 deletion web/Areas/RAPS/Controllers/RAPSController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ public async Task<NavMenu> 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))
{
Expand Down
23 changes: 13 additions & 10 deletions web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"))) ?? {}

}
})
</script>
Expand Down
43 changes: 30 additions & 13 deletions web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
:pagination="{rowsPerPage:0}"
:loading="loadingRoles">
<template v-slot:top-left>
<q-btn dense no-caps color="primary" padding="xs md" @@click="submitChanges()" label="Submit changes"></q-btn>
<q-btn dense no-caps color="primary" padding="xs md" :disable="!rolesLoaded" @@click="submitChanges()" label="Submit changes"></q-btn>
</template>
<template v-slot:top-right>
<q-input class="q-ml-xs q-mr-xs" v-model="filter" dense outlined debounce="300" placeholder="Filter Results">
Expand All @@ -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" },
Expand All @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions web/Areas/RAPS/Views/Roles/Templates.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
}
},
async mounted() {
showQueuedStatusNotification()
this.templates.load()
}
})
Expand Down
Loading
Loading