feat: add URL hash navigation and keyboard support to drawer

Enhance the namespace detail drawer with URL-aware navigation and
keyboard accessibility features.

Changes:
- URL hash support: /polaris/namespaces#alpha opens alpha drawer
- Deep linking: URLs can be bookmarked and shared
- Browser back/forward: Navigate drawer history with browser buttons
- Keyboard navigation: Escape key closes the drawer
- URL synchronization: Hash updates when drawer opens/closes

Technical implementation:
- Use React Router v5 useHistory/useLocation hooks
- Initialize drawer state from location.hash on mount
- Sync drawer state when hash changes (back/forward navigation)
- Update hash when drawer opens/closes via history.push()
- Add global keydown listener for Escape key

Tests:
- Added test for clicking namespace button opens drawer
- Added test for initializing drawer from URL hash
- All 50 tests passing

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-09 08:00:39 -05:00
parent 4544284df0
commit 1b082a24db
2 changed files with 114 additions and 8 deletions
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
@@ -218,4 +219,74 @@ describe('NamespacesListView', () => {
const errorScore = scoreLabels.find(el => el.textContent === '0%');
expect(errorScore).toHaveAttribute('data-status', 'error');
});
it('opens drawer when namespace button is clicked and URL hash is updated', async () => {
const user = userEvent.setup();
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'alpha',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
renderWithRouter(<NamespacesListView />);
// Click the namespace button
const alphaButton = screen.getByText('alpha');
await user.click(alphaButton);
// Drawer should open (check for the panel title)
expect(screen.getByText(/Polaris — alpha/)).toBeInTheDocument();
});
it('initializes drawer from URL hash', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'test-ns',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
// Render with initial hash in URL
render(
<MemoryRouter initialEntries={['/polaris/namespaces#test-ns']}>
<NamespacesListView />
</MemoryRouter>
);
// Drawer should be open with the namespace from hash
expect(screen.getByText(/Polaris — test-ns/)).toBeInTheDocument();
});
});
+43 -8
View File
@@ -6,7 +6,8 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
computeScore,
countResultsForItems,
@@ -213,8 +214,45 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
}
export default function NamespacesListView() {
const location = useLocation();
const history = useHistory();
const { data, loading, error } = usePolarisDataContext();
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
// Initialize from URL hash
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(
location.hash.slice(1) || null
);
// Sync drawer state when URL hash changes (browser back/forward)
useEffect(() => {
const hashNs = location.hash.slice(1);
setSelectedNamespace(hashNs || null);
}, [location.hash]);
const openNamespace = (ns: string) => {
setSelectedNamespace(ns);
history.push(`${location.pathname}#${ns}`);
};
const closeNamespace = () => {
setSelectedNamespace(null);
history.push(location.pathname);
};
// Handle keyboard navigation (Escape key closes drawer)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNamespace) {
closeNamespace();
}
};
if (selectedNamespace) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNamespace]);
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
@@ -274,7 +312,7 @@ export default function NamespacesListView() {
label: 'Namespace',
getter: (row: NamespaceRow) => (
<button
onClick={() => setSelectedNamespace(row.namespace)}
onClick={() => openNamespace(row.namespace)}
style={{
border: 'none',
background: 'transparent',
@@ -322,7 +360,7 @@ export default function NamespacesListView() {
{selectedNamespace && (
<>
<div
onClick={() => setSelectedNamespace(null)}
onClick={closeNamespace}
style={{
position: 'fixed',
top: 0,
@@ -334,10 +372,7 @@ export default function NamespacesListView() {
}}
aria-label="Close panel backdrop"
/>
<NamespaceDetailPanel
namespace={selectedNamespace}
onClose={() => setSelectedNamespace(null)}
/>
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
</>
)}
</>