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:
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
@@ -218,4 +219,74 @@ describe('NamespacesListView', () => {
|
|||||||
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
||||||
expect(errorScore).toHaveAttribute('data-status', 'error');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
SimpleTable,
|
SimpleTable,
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
} 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 {
|
import {
|
||||||
computeScore,
|
computeScore,
|
||||||
countResultsForItems,
|
countResultsForItems,
|
||||||
@@ -213,8 +214,45 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NamespacesListView() {
|
export default function NamespacesListView() {
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
const { data, loading, error } = usePolarisDataContext();
|
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) {
|
if (loading) {
|
||||||
return <Loader title="Loading Polaris audit data..." />;
|
return <Loader title="Loading Polaris audit data..." />;
|
||||||
@@ -274,7 +312,7 @@ export default function NamespacesListView() {
|
|||||||
label: 'Namespace',
|
label: 'Namespace',
|
||||||
getter: (row: NamespaceRow) => (
|
getter: (row: NamespaceRow) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedNamespace(row.namespace)}
|
onClick={() => openNamespace(row.namespace)}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -322,7 +360,7 @@ export default function NamespacesListView() {
|
|||||||
{selectedNamespace && (
|
{selectedNamespace && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedNamespace(null)}
|
onClick={closeNamespace}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -334,10 +372,7 @@ export default function NamespacesListView() {
|
|||||||
}}
|
}}
|
||||||
aria-label="Close panel backdrop"
|
aria-label="Close panel backdrop"
|
||||||
/>
|
/>
|
||||||
<NamespaceDetailPanel
|
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
|
||||||
namespace={selectedNamespace}
|
|
||||||
onClose={() => setSelectedNamespace(null)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user