Skip to content
Merged
39 changes: 22 additions & 17 deletions frontend/src/lib/components/Breadcrumbs/Breadcrumbs.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { breadcrumbs, type Breadcrumb } from '$lib/utils/breadcrumbs';
import { breadcrumbs, syncBreadcrumbsToCurrentUrl } from '$lib/utils/breadcrumbs';
import { URL_MODEL_MAP } from '$lib/utils/crud';
import { safeTranslate } from '$lib/utils/i18n';
import { pageTitle } from '$lib/utils/stores';

async function trimBreadcrumbsToCurrentPath(
breadcrumbs: Breadcrumb[],
currentPath: string
): Promise<Breadcrumb[]> {
const idx = breadcrumbs.findIndex((c) => c.href?.startsWith(currentPath));
// First breadcrumb is home, its href is always '/'
if (idx > 0 && idx < breadcrumbs.length - 1) {
breadcrumbs = breadcrumbs.slice(0, idx + 1);
}
return breadcrumbs;
}

function getPageTitle(): string {
// Check each source in priority order
const title =
Expand All @@ -38,13 +26,30 @@
return URL_MODEL_MAP[lastPathSegment]?.localNamePlural;
}

afterNavigate(async () => {
$breadcrumbs = await trimBreadcrumbsToCurrentPath($breadcrumbs, page.url.pathname);
});
function sync(fallbackLabel: string, isFreshLoad: boolean) {
const currentPath = page.url.pathname;
const currentUrl = currentPath + page.url.search;
const current = $breadcrumbs;
const next = syncBreadcrumbsToCurrentUrl(
current,
currentPath,
currentUrl,
fallbackLabel,
isFreshLoad
);
// No-op if unchanged.
if (
next.length !== current.length ||
next.some((c, i) => c.href !== current[i].href || c.label !== current[i].label)
) {
$breadcrumbs = next;
}
}

afterNavigate((nav) => sync(getPageTitle(), nav.type === 'enter'));

$effect(() => {
$pageTitle = getPageTitle();
if ($breadcrumbs.length < 2) breadcrumbs.push([{ label: $pageTitle, href: page.url.pathname }]);
});
Comment thread
Mohamed-Hacene marked this conversation as resolved.
</script>

Expand Down
18 changes: 12 additions & 6 deletions frontend/src/lib/components/ModelTable/ModelTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,20 @@
if (finalFilterValue) {
finalFilterValue.forEach(({ value }) => page.url.searchParams.append(field, value));
}

const hrefPattern = new RegExp(`^/${URLModel}(\\?.*)?$`);
const fullPath = page.url.pathname + page.url.search;
if (hrefPattern.test(fullPath)) {
breadcrumbs.updateCrumb(hrefPattern, { href: fullPath });
}
}
history.replaceState(history.state, '', page.url.pathname + page.url.search);
// Sync the current crumb's href with the new filter query.
breadcrumbs.update((crumbs) => {
if (crumbs.length < 2) return crumbs;
const last = crumbs[crumbs.length - 1];
const lastPath = last.href?.split('?')[0];
if (lastPath !== page.url.pathname) return crumbs;
const newHref = page.url.pathname + page.url.search;
if (last.href === newHref) return crumbs;
const next = crumbs.slice();
next[next.length - 1] = { ...last, href: newHref };
return next;
});
// untracked so resetFilters can delete the entry without retriggering us
if (isStandaloneTable) {
untrack(() => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/components/SideBar/SideBarFooter.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { breadcrumbs } from '$lib/utils/breadcrumbs';
import { LOCALE_MAP, language, defaultLangLabels } from '$lib/utils/locales';
import { m } from '$paraglide/messages';
import { getLocale, locales, setLocale } from '$paraglide/runtime';
Expand Down Expand Up @@ -136,7 +137,7 @@
class="unstyled cursor-pointer flex items-center gap-2 w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 disabled:text-gray-500 text-gray-800"
data-testid="docs-button"><i class="fa-solid fa-book mr-2"></i>{m.onlineDocs()}</a
>
<form action="/logout" method="POST">
<form action="/logout" method="POST" onsubmit={() => breadcrumbs.clear()}>
<button class="w-full" type="submit" data-testid="logout-button">
<span
class="flex items-center gap-2 w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 disabled:text-gray-500 text-gray-800"
Expand Down
184 changes: 184 additions & 0 deletions frontend/src/lib/utils/breadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach } from 'vitest';

import {
hrefPathname,
isSiblingPath,
syncBreadcrumbsToCurrentUrl,
loadFromSession,
type Breadcrumb
} from './breadcrumbs';

const home: Breadcrumb = { label: 'Home', href: '/' };
const crumb = (label: string, href: string): Breadcrumb => ({ label, href });

describe('hrefPathname', () => {
it('returns undefined for undefined', () => {
expect(hrefPathname(undefined)).toBeUndefined();
});

it('returns full string when no query', () => {
expect(hrefPathname('/foo/bar')).toBe('/foo/bar');
});

it('strips query string', () => {
expect(hrefPathname('/foo?bar=1&baz=2')).toBe('/foo');
});

it('handles root', () => {
expect(hrefPathname('/')).toBe('/');
});
});

describe('isSiblingPath', () => {
it('returns false when first arg is undefined', () => {
expect(isSiblingPath(undefined, '/foo')).toBe(false);
});

it('returns false on different depth', () => {
expect(isSiblingPath('/foo', '/foo/bar')).toBe(false);
expect(isSiblingPath('/foo/bar', '/foo')).toBe(false);
});

it('returns false on different first segment', () => {
expect(isSiblingPath('/foo/123', '/bar/123')).toBe(false);
});

it('returns true on same depth + same first segment', () => {
expect(isSiblingPath('/requirement-assessments/abc', '/requirement-assessments/def')).toBe(
true
);
expect(isSiblingPath('/folders/a', '/folders/b')).toBe(true);
});

it('returns false when either path is empty', () => {
expect(isSiblingPath('/', '/foo')).toBe(false);
expect(isSiblingPath('', '/foo')).toBe(false);
});
});

describe('syncBreadcrumbsToCurrentUrl', () => {
it('trims trail and updates query when current path matches an intermediate crumb', () => {
const trail = [home, crumb('Folders', '/folders'), crumb('Folder A', '/folders/a')];
const next = syncBreadcrumbsToCurrentUrl(trail, '/folders', '/folders?q=x', 'fallback', false);
expect(next).toEqual([home, { label: 'Folders', href: '/folders?q=x' }]);
});

it('updates query on the last crumb when current path equals it', () => {
const trail = [home, crumb('Folders', '/folders')];
const next = syncBreadcrumbsToCurrentUrl(trail, '/folders', '/folders?q=y', 'fallback', false);
expect(next).toEqual([home, { label: 'Folders', href: '/folders?q=y' }]);
});

it('resets trail to [home, current] on fresh load with no match', () => {
const trail = [home, crumb('Stale', '/old')];
const next = syncBreadcrumbsToCurrentUrl(trail, '/new', '/new?x=1', 'New', true);
expect(next).toEqual([home, { label: 'New', href: '/new?x=1' }]);
});

it('replaces last crumb on sibling navigation (same depth + first segment)', () => {
const trail = [home, crumb('Folder list', '/folders'), crumb('A', '/folders/a')];
const next = syncBreadcrumbsToCurrentUrl(trail, '/folders/b', '/folders/b', 'B', false);
expect(next).toEqual([
home,
crumb('Folder list', '/folders'),
{ label: 'B', href: '/folders/b' }
]);
});

it('appends on non-sibling navigation when not a fresh load', () => {
const trail = [home, crumb('Folders', '/folders')];
const next = syncBreadcrumbsToCurrentUrl(
trail,
'/risk-scenarios',
'/risk-scenarios',
'RS',
false
);
expect(next).toEqual([
home,
crumb('Folders', '/folders'),
{ label: 'RS', href: '/risk-scenarios' }
]);
});

it('does not replace when last crumb has no href (no false sibling match)', () => {
const trail = [home, { label: 'Detached' } as Breadcrumb];
const next = syncBreadcrumbsToCurrentUrl(trail, '/foo', '/foo', 'Foo', false);
expect(next).toEqual([home, { label: 'Detached' }, { label: 'Foo', href: '/foo' }]);
});

it('match wins over sibling: trims even when last crumb is a sibling of current', () => {
// trail ends with a deeper crumb that happens to share first segment with current
const trail = [home, crumb('Folders', '/folders'), crumb('A', '/folders/a')];
// current path matches the intermediate crumb -> trim, do not sibling-replace
const next = syncBreadcrumbsToCurrentUrl(trail, '/folders', '/folders?q=1', 'fallback', false);
expect(next).toEqual([home, { label: 'Folders', href: '/folders?q=1' }]);
});
});

describe('loadFromSession', () => {
beforeEach(() => {
sessionStorage.clear();
});

it('returns initial value when storage is empty', () => {
const init = [home];
expect(loadFromSession(init)).toBe(init);
});

it('returns initial value when storage contains invalid JSON', () => {
sessionStorage.setItem('ciso:breadcrumbs', '{not json');
const init = [home];
expect(loadFromSession(init)).toBe(init);
});

it('returns initial value when stored value is not an array', () => {
sessionStorage.setItem('ciso:breadcrumbs', JSON.stringify({ label: 'oops' }));
const init = [home];
expect(loadFromSession(init)).toBe(init);
});

it('hydrates valid crumbs and prepends live home', () => {
sessionStorage.setItem(
'ciso:breadcrumbs',
JSON.stringify([
{ label: 'StaleHome', href: '/' },
{ label: 'Folders', href: '/folders' },
{ label: 'A', href: '/folders/a' }
])
);
const result = loadFromSession([home]);
expect(result).toHaveLength(3);
expect(result[0].label).toBe('Home'); // live home replaces stale
expect(result[1]).toEqual({ label: 'Folders', href: '/folders' });
expect(result[2]).toEqual({ label: 'A', href: '/folders/a' });
});

it('drops entries that fail shape validation', () => {
sessionStorage.setItem(
'ciso:breadcrumbs',
JSON.stringify([
{ label: 'home', href: '/' },
{ label: 'Valid', href: '/foo' },
{ href: '/missing-label' }, // missing label
{ label: 42, href: '/wrong-type' }, // non-string label
{ label: 'BadHref', href: 123 }, // non-string href
null, // null entry
{ label: 'Good', href: '/good' }
])
);
const result = loadFromSession([home]);
expect(result).toHaveLength(3);
expect(result[0].label).toBe('Home');
expect(result[1]).toEqual({ label: 'Valid', href: '/foo' });
expect(result[2]).toEqual({ label: 'Good', href: '/good' });
});

it('caps hydrated tail to BREADCRUMBS_MAX_DEPTH (5)', () => {
const long = [{ label: 'home', href: '/' }];
for (let i = 0; i < 10; i++) long.push({ label: `c${i}`, href: `/c/${i}` });
sessionStorage.setItem('ciso:breadcrumbs', JSON.stringify(long));
const result = loadFromSession([home]);
expect(result).toHaveLength(6); // home + 5 tail
});
});
Loading
Loading