Skip to content
Merged
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
22 changes: 19 additions & 3 deletions src/components/CollapsableGroup.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useCollapsibleGroupsStore } from '@/stores/collapsibleGroups';

const props = defineProps({
open: Boolean,
// Opt-in: when set, expand/collapse state is persisted (keyed by this string) instead
// of being purely local to this component instance, so it survives a hard refresh.
persistKey: String,
});

const isOpen = ref<boolean>(props.open);
const collapsibleGroupsStore = useCollapsibleGroupsStore();
const localOpen = ref<boolean>(props.open);

const isOpen = computed<boolean>(() =>
props.persistKey
? collapsibleGroupsStore.isOpen(props.persistKey)
: localOpen.value,
);

function toggleOpen() {
isOpen.value = !isOpen.value;
if (props.persistKey) {
collapsibleGroupsStore.setOpen(props.persistKey, !isOpen.value);
} else {
localOpen.value = !localOpen.value;
}
}
</script>

Expand Down
4 changes: 0 additions & 4 deletions src/components/groups/GroupMembersPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,6 @@
{{ adding ? 'Adding…' : 'Add' }}
</PrimaryButton>
</div>

<div class="mt-6 flex justify-end">
<SecondaryButton @click="showAddMember = false">Done</SecondaryButton>
</div>
</div>
</Dialog>
</div>
Expand Down
36 changes: 32 additions & 4 deletions src/components/navigation/SideNavCategory.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import ChevronRightIcon from '@primevue/icons/chevronright';
import ChevronDownIcon from '@primevue/icons/chevrondown';
import { useToggle } from '@/composables/useToggle';
import { useLeftNavCategoriesStore } from '@/stores/leftNavCategories';

defineProps<{
const props = defineProps<{
title: string;
open?: boolean;
// Highlights the header to show the currently active route lives under this category
// (BCH — LHN active-section indicator).
active?: boolean;
}>();

const { value: isOpen, toggle } = useToggle();
// Open/closed state lives in this store (persisted to localStorage, keyed by title) rather
// than local component state, so a submenu the user expands — even with nothing under it
// selected — stays open across a hard refresh or a new session.
const categoriesStore = useLeftNavCategoriesStore();
const isOpen = computed(() => categoriesStore.isOpen(props.title));

// Force the category open when it becomes the one containing the active route, so the
// highlighted child link is actually visible. Never auto-collapses it back — a user who
// manually closes it keeps that choice while still browsing inside it.
watch(
() => props.open,
(open) => {
if (open) categoriesStore.setOpen(props.title, true);
},
{ immediate: true },
);

function toggle() {
categoriesStore.setOpen(props.title, !isOpen.value);
}
</script>
<template>
<div
class="text-zinc-500 dark:text-slate-100 py-2 font-base pl-6 flex items-center gap-x-4"
class="text-zinc-500 dark:text-slate-100 py-2 font-base pl-4 flex items-center gap-x-4 border-l-8"
:class="
active
? 'bg-ccf-100 dark:bg-slate-800 border-l-slate-300 dark:border-l-slate-500 text-zinc-900 dark:text-white font-medium'
: 'border-l-transparent'
"
@click="toggle"
>
<slot name="title">
Expand Down
10 changes: 9 additions & 1 deletion src/components/navigation/SideNavLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>();
</script>
<template>
<RouterLink
:to="to"
class="font-light block py-2 hover:bg-gray-50 dark:hover:bg-slate-800 pl-4 border-l-transparent border-l-8 text-zinc-700 dark:text-slate-200"
class="font-light block py-2 hover:bg-gray-50 dark:hover:bg-slate-800 pl-4 border-l-8 text-zinc-700 dark:text-slate-200"
:class="
active
? 'bg-linear-to-r from-slate-200 to-slate-100 border-l-slate-300 dark:from-slate-700 dark:to-slate-800 dark:border-slate-500 dark:text-slate-200'
: 'border-l-transparent'
"
>
<slot />
</RouterLink>
Expand Down
8 changes: 8 additions & 0 deletions src/router/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 6 additions & 4 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
40 changes: 40 additions & 0 deletions src/stores/__tests__/leftNavCategories.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions src/stores/collapsibleGroups.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>('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,
};
},
);
31 changes: 31 additions & 0 deletions src/stores/leftNavCategories.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>('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,
};
},
);
82 changes: 80 additions & 2 deletions src/views/LeftSideNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -252,6 +255,74 @@ const visibleLinks = computed<Array<NavigationItem>>(() =>
),
);

// 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<ActiveSection>(matchingSection() ?? {});
watch(
() => route.fullPath,
() => {
const matched = matchingSection();
if (matched !== undefined) {
activeSection.value = matched;
}
},
);

Comment thread
gusfcarvalho marked this conversation as resolved.
const footLinks = ref<Array<NavigationItem>>([
{
name: 'logout',
Expand Down Expand Up @@ -296,7 +367,12 @@ const footLinks = ref<Array<NavigationItem>>([
<div>
<!-- Main Navigation Items -->
<template v-for="link in visibleLinks" :key="link.name">
<SideNavCategory :title="link.title" v-if="link.children">
<SideNavCategory
:title="link.title"
:open="linkKey(link) === activeSection.topLevel"
:active="linkKey(link) === activeSection.topLevel"
v-if="link.children"
>
<template #title>
<span>{{
sidebarStore.open ? link.title : abbreviated(link)
Expand All @@ -305,6 +381,7 @@ const footLinks = ref<Array<NavigationItem>>([
<template v-for="child in link.children" :key="child.name">
<SideNavLink
:to="{ name: child.name }"
:active="child.name === activeSection.child"
v-tooltip.right="{
value: `${link.title} | ${child.title}`,
disabled: sidebarStore.open,
Expand All @@ -317,6 +394,7 @@ const footLinks = ref<Array<NavigationItem>>([
<SideNavLink
v-else
:to="{ name: link.name }"
:active="link.name === activeSection.topLevel"
v-tooltip.hover.right="{
value: link.title,
disabled: sidebarStore.open,
Expand Down
Loading
Loading