-
Notifications
You must be signed in to change notification settings - Fork 720
fix: improve breadcrumbs #4142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
fix: improve breadcrumbs #4142
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
4e4e262
feat: improve breadcrumbs
Mohamed-Hacene 66cc9e9
chore: comments
Mohamed-Hacene f11efca
chore: remove dead code
Mohamed-Hacene 6f874f0
fix: remove redundant effect
Mohamed-Hacene dd53be3
Merge branch 'main' into feat/improve-breadcrumbs
ab-smith 585457a
fix: bring back usual behaviors
Mohamed-Hacene 9c4deef
chore: comments
Mohamed-Hacene 74d4c45
feat: session store breadcrumbs
Mohamed-Hacene 72ae494
fix: side effects
Mohamed-Hacene 5600c35
refactor: extract breadcrumb sync helpers to module
nas-tabchiche 8970484
fix: clear breadcrumbs on logout and validate hydrated crumbs
nas-tabchiche b6feeb3
test: cover breadcrumb sync and hydration
nas-tabchiche File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.