diff --git a/src/components/CollapsableGroup.vue b/src/components/CollapsableGroup.vue
index 5f1a9785..9812795e 100644
--- a/src/components/CollapsableGroup.vue
+++ b/src/components/CollapsableGroup.vue
@@ -1,13 +1,29 @@
diff --git a/src/components/groups/GroupMembersPanel.vue b/src/components/groups/GroupMembersPanel.vue
index bc7b59b1..96cdac12 100644
--- a/src/components/groups/GroupMembersPanel.vue
+++ b/src/components/groups/GroupMembersPanel.vue
@@ -129,10 +129,6 @@
{{ adding ? 'Adding…' : 'Add' }}
-
-
- Done
-
diff --git a/src/components/navigation/SideNavCategory.vue b/src/components/navigation/SideNavCategory.vue
index 84317e42..ab2d3063 100644
--- a/src/components/navigation/SideNavCategory.vue
+++ b/src/components/navigation/SideNavCategory.vue
@@ -1,18 +1,46 @@
diff --git a/src/components/navigation/SideNavLink.vue b/src/components/navigation/SideNavLink.vue
index 1aa165d4..758b0e65 100644
--- a/src/components/navigation/SideNavLink.vue
+++ b/src/components/navigation/SideNavLink.vue
@@ -4,12 +4,20 @@ import type { RouteLocationRaw } from 'vue-router';
defineProps<{
to: RouteLocationRaw;
+ // Sticky active-section indicator (set by LeftSideNav), independent of RouterLink's own
+ // exact/descendant route match — see LeftSideNav.vue's activeLinkKey.
+ active?: boolean;
}>();
diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts
index 19964a89..cb93faa7 100644
--- a/src/router/__tests__/index.spec.ts
+++ b/src/router/__tests__/index.spec.ts
@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
import router from '@/router';
describe('router', () => {
+ it('redirects the root route to the canonical evidence route', () => {
+ // So the initial landing page resolves as 'evidence:index' — the name LeftSideNav's
+ // active-section tracking matches on — and "Evidence" is highlighted immediately,
+ // rather than under a separate 'home' route name nothing in the nav matches.
+ const home = router.getRoutes().find((route) => route.name === 'home');
+ expect(home?.redirect).toEqual({ name: 'evidence:index' });
+ });
+
it('routes SSP creation to the create view', () => {
const route = router
.getRoutes()
diff --git a/src/router/index.ts b/src/router/index.ts
index 0f683215..b92d4837 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -16,10 +16,12 @@ const authenticatedRoutes = [
{
path: '/',
name: 'home',
- // route level code-splitting
- // this generates a separate chunk (About.[hash].js) for this route
- // which is lazy-loaded when the route is visited.
- component: () => import('../views/evidence/IndexView.vue'),
+ // Redirects to the canonical evidence route (rather than duplicating its component
+ // under a second route name) so the initial landing page resolves as 'evidence:index'
+ // — the same name LeftSideNav's active-section tracking matches on — and the "Evidence"
+ // nav item is correctly highlighted from the very first render, not just after the user
+ // clicks it.
+ redirect: { name: 'evidence:index' },
meta: {
requiresAuth: true,
},
diff --git a/src/stores/__tests__/leftNavCategories.spec.ts b/src/stores/__tests__/leftNavCategories.spec.ts
new file mode 100644
index 00000000..fad30d16
--- /dev/null
+++ b/src/stores/__tests__/leftNavCategories.spec.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { nextTick } from 'vue';
+import { createPinia, setActivePinia } from 'pinia';
+import { useLeftNavCategoriesStore } from '../leftNavCategories';
+
+describe('useLeftNavCategoriesStore', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ setActivePinia(createPinia());
+ });
+
+ it('reports categories as closed by default', () => {
+ const store = useLeftNavCategoriesStore();
+ expect(store.isOpen('Control Definitions')).toBe(false);
+ });
+
+ it('remembers an opened category independently of others', () => {
+ const store = useLeftNavCategoriesStore();
+ store.setOpen('Control Definitions', true);
+
+ expect(store.isOpen('Control Definitions')).toBe(true);
+ expect(store.isOpen('Workflows')).toBe(false);
+
+ store.setOpen('Control Definitions', false);
+ expect(store.isOpen('Control Definitions')).toBe(false);
+ });
+
+ it('persists open categories to localStorage across a fresh store instance', async () => {
+ const first = useLeftNavCategoriesStore();
+ first.setOpen('Admin', true);
+ // useLocalStorage writes to storage via a watcher, not synchronously on assignment.
+ await nextTick();
+
+ // Simulates a hard refresh: a brand new Pinia instance, but the same localStorage.
+ setActivePinia(createPinia());
+ const second = useLeftNavCategoriesStore();
+
+ expect(second.isOpen('Admin')).toBe(true);
+ });
+});
diff --git a/src/stores/collapsibleGroups.ts b/src/stores/collapsibleGroups.ts
new file mode 100644
index 00000000..5d6983b3
--- /dev/null
+++ b/src/stores/collapsibleGroups.ts
@@ -0,0 +1,32 @@
+import { defineStore } from 'pinia';
+import { useLocalStorage } from '@vueuse/core';
+
+// Generic persisted open/closed state for CollapsableGroup instances, keyed by an
+// arbitrary caller-supplied string. Opt-in via CollapsableGroup's `persistKey` prop — used
+// where a page wants its expand/collapse tree (e.g. a catalog's groups and controls) to
+// survive a hard refresh, rather than always resetting collapsed on mount.
+export const useCollapsibleGroupsStore = defineStore(
+ 'collapsibleGroups',
+ () => {
+ const openKeys = useLocalStorage('collapsibleGroupsOpen', []);
+
+ function isOpen(key: string): boolean {
+ return openKeys.value.includes(key);
+ }
+
+ function setOpen(key: string, open: boolean) {
+ if (open === isOpen(key)) {
+ return;
+ }
+ openKeys.value = open
+ ? [...openKeys.value, key]
+ : openKeys.value.filter((openKey) => openKey !== key);
+ }
+
+ return {
+ openKeys,
+ isOpen,
+ setOpen,
+ };
+ },
+);
diff --git a/src/stores/leftNavCategories.ts b/src/stores/leftNavCategories.ts
new file mode 100644
index 00000000..923362b8
--- /dev/null
+++ b/src/stores/leftNavCategories.ts
@@ -0,0 +1,31 @@
+import { defineStore } from 'pinia';
+import { useLocalStorage } from '@vueuse/core';
+
+// Which left-hand-nav categories (keyed by their title, e.g. "Control Definitions") the
+// user has expanded — persisted so a manually-opened submenu stays open across a hard
+// refresh or a new session, even if nothing under it was ever selected.
+export const useLeftNavCategoriesStore = defineStore(
+ 'leftNavCategories',
+ () => {
+ const openTitles = useLocalStorage('leftNavOpenCategories', []);
+
+ function isOpen(title: string): boolean {
+ return openTitles.value.includes(title);
+ }
+
+ function setOpen(title: string, open: boolean) {
+ if (open === isOpen(title)) {
+ return;
+ }
+ openTitles.value = open
+ ? [...openTitles.value, title]
+ : openTitles.value.filter((openTitle) => openTitle !== title);
+ }
+
+ return {
+ openTitles,
+ isOpen,
+ setOpen,
+ };
+ },
+);
diff --git a/src/views/LeftSideNav.vue b/src/views/LeftSideNav.vue
index f17ea600..eddd2b48 100644
--- a/src/views/LeftSideNav.vue
+++ b/src/views/LeftSideNav.vue
@@ -8,12 +8,15 @@ import darkLogo from '@/assets/logo-dark.svg';
import lightMiniLogo from '@/assets/logo-light-mini.svg';
import darkMiniLogo from '@/assets/logo-dark-mini.svg';
import { useSidebarStore } from '@/stores/sidebar';
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
import { usePermissions } from '@/composables/usePermissions';
import { RESOURCES, ACTIONS } from '@/constants/permissions';
const sidebarStore = useSidebarStore();
const { can } = usePermissions();
+const route = useRoute();
+const router = useRouter();
interface NavigationItem {
title: string;
@@ -252,6 +255,74 @@ const visibleLinks = computed>(() =>
),
);
+// Identifies a top-level nav entry for the sticky-active tracking below: a category by its
+// (unique) title, a standalone link by its route name.
+function linkKey(item: NavigationItem): string | undefined {
+ return item.children ? item.title : item.name;
+}
+
+function routeIsUnder(name?: string): boolean {
+ if (!name) {
+ return false;
+ }
+ if (route.matched.some((matched) => matched.name === name)) {
+ return true;
+ }
+ // Many sections (e.g. catalogs) are flat sibling routes — /catalogs, /catalogs/:id,
+ // /catalogs/new — rather than nested child routes, so a detail/create page reached
+ // directly (deep link, hard refresh) never shows up in route.matched under the list
+ // route's name. Falling back to a path-prefix check lets a fresh load on such a page
+ // still resolve to the right nav item without depending on any in-session state.
+ try {
+ const basePath = router.resolve({ name }).path;
+ return route.path === basePath || route.path.startsWith(`${basePath}/`);
+ } catch {
+ return false;
+ }
+}
+
+interface ActiveSection {
+ // A category's title, or a standalone top-level link's route name.
+ topLevel?: string;
+ // Set only when `topLevel` is a category: the specific child route name active within
+ // it, so "Governance > Catalogs" stays distinguishable from "Governance > Profiles".
+ child?: string;
+}
+
+// Which nav entry, at both levels, the CURRENT route belongs to (a category whose
+// children include the route anywhere in its matched chain, or a standalone link whose
+// own route matches) — or undefined when the route matches nothing in the nav at all.
+function matchingSection(): ActiveSection | undefined {
+ for (const link of links.value) {
+ if (link.children) {
+ const child = link.children.find((c) => routeIsUnder(c.name));
+ if (child) {
+ return { topLevel: link.title, child: child.name };
+ }
+ } else if (routeIsUnder(link.name)) {
+ return { topLevel: link.name };
+ }
+ }
+ return undefined;
+}
+
+// The section (and, within it, the specific item) the user is "in", kept sticky at both
+// levels: it only moves when navigation lands on a route that genuinely belongs to a
+// different nav entry. A button inside a page pushing to some unlisted detail route (e.g.
+// "New Catalog", never registered as a nav child) would otherwise match nothing and blank
+// the highlight out from under the user — at either level — even though they haven't left
+// that item.
+const activeSection = ref(matchingSection() ?? {});
+watch(
+ () => route.fullPath,
+ () => {
+ const matched = matchingSection();
+ if (matched !== undefined) {
+ activeSection.value = matched;
+ }
+ },
+);
+
const footLinks = ref>([
{
name: 'logout',
@@ -296,7 +367,12 @@ const footLinks = ref>([
-
+
{{
sidebarStore.open ? link.title : abbreviated(link)
@@ -305,6 +381,7 @@ const footLinks = ref>([
',
+ },
+ SideNavLink: {
+ template: '',
+ },
+ SideNavLogo: {
+ template: '
',
+ },
+ },
+ },
+ });
+
+ const governanceCategory = wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === 'Control Definitions');
+ const workflowsCategory = wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === 'Workflows');
+
+ expect(governanceCategory?.props('active')).toBe(true);
+ expect(governanceCategory?.props('open')).toBe(true);
+
+ // A category that doesn't contain the active route is neither highlighted nor forced open.
+ expect(workflowsCategory?.props('active')).toBe(false);
+ expect(workflowsCategory?.props('open')).toBe(false);
+ });
+
+ it('keeps a manually opened category open across a hard refresh, even with nothing under it selected', async () => {
+ mockRoute.matched = []; // nothing active anywhere
+ const sidebarStore = useSidebarStore();
+ sidebarStore.open = true;
+
+ const mountFresh = () =>
+ mount(LeftSideNav, {
+ global: {
+ directives: {
+ tooltip: {
+ mounted: () => undefined,
+ },
+ },
+ stubs: {
+ SideNav: {
+ template: '
',
+ },
+ SideNavLink: {
+ template: '',
+ },
+ SideNavLogo: {
+ template: '
',
+ },
+ },
+ },
+ });
+
+ const findWorkflowsChildren = (wrapper: ReturnType) =>
+ wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === 'Workflows')!
+ .find('div.mb-2');
+
+ const wrapper = mountFresh();
+ expect(findWorkflowsChildren(wrapper).classes()).toContain('hidden');
+
+ const workflowsHeader = wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === 'Workflows')!
+ .find('div');
+ await workflowsHeader.trigger('click');
+
+ expect(findWorkflowsChildren(wrapper).classes()).not.toContain('hidden');
+
+ // Simulate a hard refresh: a brand-new Pinia instance (so the store itself is rebuilt
+ // from scratch, not just re-read from memory) backed by the same localStorage, exactly
+ // as a real browser reload would leave it.
+ setActivePinia(createPinia());
+ const wrapperAfterRefresh = mountFresh();
+ expect(findWorkflowsChildren(wrapperAfterRefresh).classes()).not.toContain(
+ 'hidden',
+ );
+ });
});
diff --git a/src/views/__tests__/LeftSideNavActiveSection.spec.ts b/src/views/__tests__/LeftSideNavActiveSection.spec.ts
new file mode 100644
index 00000000..2cae5f05
--- /dev/null
+++ b/src/views/__tests__/LeftSideNavActiveSection.spec.ts
@@ -0,0 +1,175 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createRouter, createWebHistory } from 'vue-router';
+import { createPinia, setActivePinia } from 'pinia';
+import { useSidebarStore } from '@/stores/sidebar';
+import LeftSideNav from '../LeftSideNav.vue';
+import SideNavCategory from '@/components/navigation/SideNavCategory.vue';
+import SideNavLink from '@/components/navigation/SideNavLink.vue';
+
+// Every route name referenced by LeftSideNav.vue's nav config, plus two routes that are
+// never registered as nav items anywhere but share a URL prefix with one that is
+// ('catalog-create'/'catalog-view', siblings of 'catalog-list' under /catalogs) — the
+// "New Catalog" button and a catalog detail deep link. A real router is used (rather than
+// mocking vue-router) so route.matched/route.path changes on navigation are genuinely
+// reactive, exercising LeftSideNav's active-section tracking end to end.
+const routeNames = [
+ 'admin-agents',
+ 'admin-diagnostics',
+ 'admin-groups',
+ 'admin-import',
+ 'admin-parties',
+ 'admin-risk-templates',
+ 'admin-risks',
+ 'admin-roles',
+ 'admin-subject-templates',
+ 'assessment-plans',
+ 'assessment-results',
+ 'catalog-list',
+ 'component-definitions',
+ 'control-links-list',
+ 'controls:index',
+ 'dashboards',
+ 'evidence:index',
+ 'inventory:index',
+ 'lineage',
+ 'logout',
+ 'plan-of-action-and-milestones',
+ 'profile-list',
+ 'risks:index',
+ 'system-security-plans',
+ 'system:overview',
+ 'users-list',
+ 'workflow-instances:index',
+ 'workflow:index',
+];
+
+function createTestRouter() {
+ return createRouter({
+ history: createWebHistory(),
+ routes: [
+ ...routeNames.map((name) => ({
+ // 'catalog-list' mirrors the real router's /catalogs path, since the whole point
+ // of the tests below is exercising the path-prefix relationship with its siblings.
+ path:
+ name === 'catalog-list' ? '/catalogs' : `/${name.replace(':', '-')}`,
+ name,
+ component: { template: '' },
+ })),
+ // Flat siblings of 'catalog-list', not themselves nav items — matches the real
+ // router's shape (/catalogs, /catalogs/:id, /catalogs/new).
+ {
+ path: '/catalogs/new',
+ name: 'catalog-create',
+ component: { template: '' },
+ },
+ {
+ path: '/catalogs/:id',
+ name: 'catalog-view',
+ component: { template: '' },
+ },
+ ],
+ });
+}
+
+function mountNav(router: ReturnType) {
+ const sidebarStore = useSidebarStore();
+ sidebarStore.open = true;
+
+ return mount(LeftSideNav, {
+ global: {
+ plugins: [router],
+ directives: {
+ tooltip: {
+ mounted: () => undefined,
+ },
+ },
+ stubs: {
+ SideNav: {
+ template: '
',
+ },
+ SideNavLogo: {
+ template: '
',
+ },
+ },
+ },
+ });
+}
+
+describe('LeftSideNav active-section persistence', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ // Category open/closed state now persists to real localStorage (leftNavCategories
+ // store), which outlives a fresh Pinia instance — clear it so tests stay isolated.
+ localStorage.clear();
+ });
+
+ it('resolves the active section from the URL alone on a fresh load (hard refresh / deep link)', async () => {
+ // 'catalog-list' (the nav item) has path /catalogs; 'catalog-view' (a detail page,
+ // never itself a nav item) has path /catalogs/:id — simulating loading
+ // http://localhost/catalogs/ directly, with no prior in-app navigation to
+ // inherit state from.
+ const router = createTestRouter();
+ await router.push({ name: 'catalog-view', params: { id: 'some-uuid' } });
+ await router.isReady();
+
+ const wrapper = mountNav(router);
+
+ const controlDefinitionsCategory = wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === 'Control Definitions');
+ const catalogsLink = wrapper
+ .findAllComponents(SideNavLink)
+ .find(
+ (link) =>
+ (link.props('to') as { name?: string } | undefined)?.name ===
+ 'catalog-list',
+ );
+
+ expect(controlDefinitionsCategory?.props('active')).toBe(true);
+ expect(controlDefinitionsCategory?.props('open')).toBe(true);
+ expect(catalogsLink?.props('active')).toBe(true);
+ });
+
+ it('keeps the previous section highlighted through an unlisted route, and only switches once a genuinely different section is reached', async () => {
+ const router = createTestRouter();
+ await router.push({ name: 'catalog-list' }); // a child of Control Definitions
+ await router.isReady();
+
+ const wrapper = mountNav(router);
+
+ const findCategory = (title: string) =>
+ wrapper
+ .findAllComponents(SideNavCategory)
+ .find((category) => category.props('title') === title);
+ const findChildLink = (routeName: string) =>
+ wrapper
+ .findAllComponents(SideNavLink)
+ .find(
+ (link) =>
+ (link.props('to') as { name?: string } | undefined)?.name ===
+ routeName,
+ );
+
+ expect(findCategory('Control Definitions')?.props('active')).toBe(true);
+ expect(findChildLink('catalog-list')?.props('active')).toBe(true);
+
+ // An in-page button (e.g. "New Catalog") pushes to a route that was never
+ // registered as a nav child anywhere, one level down from the list page.
+ await router.push({ name: 'catalog-create' });
+ await wrapper.vm.$nextTick();
+
+ expect(findCategory('Control Definitions')?.props('active')).toBe(true);
+ expect(findCategory('Control Definitions')?.props('open')).toBe(true);
+ expect(findChildLink('catalog-list')?.props('active')).toBe(true);
+
+ // Only a route that genuinely belongs to a different nav entry moves the highlight —
+ // at either level.
+ await router.push({ name: 'workflow:index' });
+ await wrapper.vm.$nextTick();
+
+ expect(findCategory('Control Definitions')?.props('active')).toBe(false);
+ expect(findChildLink('catalog-list')?.props('active')).toBe(false);
+ expect(findCategory('Workflows')?.props('active')).toBe(true);
+ });
+});
diff --git a/src/views/catalog/CatalogControl.vue b/src/views/catalog/CatalogControl.vue
index 64e49bca..065c148b 100644
--- a/src/views/catalog/CatalogControl.vue
+++ b/src/views/catalog/CatalogControl.vue
@@ -1,5 +1,7 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+