Skip to content

Commit d6caf2a

Browse files
authored
feat: Restructure sidebar with collapsible Economy group (#1654)
* feat: Restructure sidebar with collapsible Economy group Reorganize the flat Configuration group into a hierarchical layout: - Promote Economy to its own collapsible group with 5 items (Overview, Reference Data, Starlark Config, Market Data, Forecasting) - Reduce Configuration to 3 remaining items (Gateway Mappings, MCP Config, Cookbook) - Add useCollapsedGroups hook with localStorage persistence - Auto-expand Economy group when current path matches a child route - Support group-level feature gating (economy feature gates the group) - Add 15 new tests covering collapse toggle, localStorage persistence, auto-expand, feature gate inheritance, and individual item gates * fix: Resolve lint errors in sidebar component - Remove unused `within` import from test file - Make useCollapsedGroups private (not exported) to satisfy react-refresh/only-export-components - Replace setState-in-effect with derived state pattern for auto-expand, avoiding react-hooks/set-state-in-effect error - Remove unused useRef import * fix: Address CodeRabbit review feedback - Guard localStorage.setItem with try-catch for quota/private browsing - Use within() in placement tests to verify items are under correct group containers, not just present in sidebar --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 3bc64e9 commit d6caf2a

2 files changed

Lines changed: 293 additions & 31 deletions

File tree

frontend/src/components/layout/sidebar.test.tsx

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { render, screen } from '@testing-library/react'
2+
import { render, screen, within } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
34
import { MemoryRouter } from 'react-router-dom'
45
import { Sidebar } from '@/components/layout/sidebar'
56
import type { TenantContextValue } from '@/contexts/tenant-context'
@@ -51,6 +52,7 @@ describe('Sidebar', () => {
5152
beforeEach(() => {
5253
vi.mocked(useTenantContext).mockReturnValue(makeContext())
5354
vi.mocked(useTenantFeatures).mockReturnValue(makeFeatures())
55+
localStorage.clear()
5456
})
5557

5658
describe('tenant lens', () => {
@@ -208,4 +210,174 @@ describe('Sidebar', () => {
208210
expect(screen.queryByRole('link', { name: 'Reconciliation' })).not.toBeInTheDocument()
209211
})
210212
})
213+
214+
describe('nav group structure', () => {
215+
it('renders four groups: Operations, Economy, Configuration, Admin', () => {
216+
renderSidebar({ lens: 'tenant' })
217+
218+
expect(screen.getByText('Operations')).toBeInTheDocument()
219+
expect(screen.getByText('Economy')).toBeInTheDocument()
220+
expect(screen.getByText('Configuration')).toBeInTheDocument()
221+
expect(screen.getByText('Admin')).toBeInTheDocument()
222+
})
223+
224+
it('places Economy items under Economy group', () => {
225+
renderSidebar({ lens: 'tenant' })
226+
227+
const economyGroup = screen.getByRole('button', { name: /economy/i }).closest('li')!
228+
expect(within(economyGroup).getByRole('link', { name: 'Overview' })).toBeInTheDocument()
229+
expect(within(economyGroup).getByRole('link', { name: 'Reference Data' })).toBeInTheDocument()
230+
expect(within(economyGroup).getByRole('link', { name: 'Starlark Config' })).toBeInTheDocument()
231+
expect(within(economyGroup).getByRole('link', { name: 'Market Data' })).toBeInTheDocument()
232+
expect(within(economyGroup).getByRole('link', { name: 'Forecasting' })).toBeInTheDocument()
233+
})
234+
235+
it('places remaining config items under Configuration group', () => {
236+
renderSidebar({ lens: 'tenant' })
237+
238+
const configGroup = screen.getByText('Configuration').closest('li')!
239+
expect(within(configGroup).getByRole('link', { name: 'Gateway Mappings' })).toBeInTheDocument()
240+
expect(within(configGroup).getByRole('link', { name: 'MCP Config' })).toBeInTheDocument()
241+
expect(within(configGroup).getByRole('link', { name: 'Cookbook' })).toBeInTheDocument()
242+
})
243+
})
244+
245+
describe('collapsible Economy group', () => {
246+
it('renders Economy header as a button with aria-expanded', () => {
247+
renderSidebar({ lens: 'tenant' })
248+
249+
const economyButton = screen.getByRole('button', { name: /economy/i })
250+
expect(economyButton).toBeInTheDocument()
251+
expect(economyButton).toHaveAttribute('aria-expanded', 'true')
252+
})
253+
254+
it('collapses Economy items when toggle button is clicked', async () => {
255+
const user = userEvent.setup()
256+
renderSidebar({ lens: 'tenant' })
257+
258+
const economyButton = screen.getByRole('button', { name: /economy/i })
259+
await user.click(economyButton)
260+
261+
expect(economyButton).toHaveAttribute('aria-expanded', 'false')
262+
expect(screen.queryByRole('link', { name: 'Overview' })).not.toBeInTheDocument()
263+
expect(screen.queryByRole('link', { name: 'Reference Data' })).not.toBeInTheDocument()
264+
})
265+
266+
it('expands Economy items when toggle is clicked again', async () => {
267+
const user = userEvent.setup()
268+
renderSidebar({ lens: 'tenant' })
269+
270+
const economyButton = screen.getByRole('button', { name: /economy/i })
271+
await user.click(economyButton) // collapse
272+
await user.click(economyButton) // expand
273+
274+
expect(economyButton).toHaveAttribute('aria-expanded', 'true')
275+
expect(screen.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
276+
})
277+
278+
it('persists collapsed state to localStorage', async () => {
279+
const user = userEvent.setup()
280+
renderSidebar({ lens: 'tenant' })
281+
282+
const economyButton = screen.getByRole('button', { name: /economy/i })
283+
await user.click(economyButton)
284+
285+
const stored = JSON.parse(localStorage.getItem('meridian:sidebar-collapsed') ?? '[]')
286+
expect(stored).toContain('Economy')
287+
})
288+
289+
it('restores collapsed state from localStorage', () => {
290+
localStorage.setItem('meridian:sidebar-collapsed', JSON.stringify(['Economy']))
291+
renderSidebar({ lens: 'tenant' })
292+
293+
const economyButton = screen.getByRole('button', { name: /economy/i })
294+
expect(economyButton).toHaveAttribute('aria-expanded', 'false')
295+
expect(screen.queryByRole('link', { name: 'Overview' })).not.toBeInTheDocument()
296+
})
297+
298+
it('auto-expands Economy when currentPath matches an Economy child route', () => {
299+
localStorage.setItem('meridian:sidebar-collapsed', JSON.stringify(['Economy']))
300+
renderSidebar({ lens: 'tenant', currentPath: '/economy' })
301+
302+
const economyButton = screen.getByRole('button', { name: /economy/i })
303+
expect(economyButton).toHaveAttribute('aria-expanded', 'true')
304+
expect(screen.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
305+
})
306+
307+
it('auto-expands Economy when currentPath is /reference-data', () => {
308+
localStorage.setItem('meridian:sidebar-collapsed', JSON.stringify(['Economy']))
309+
renderSidebar({ lens: 'tenant', currentPath: '/reference-data' })
310+
311+
expect(screen.getByRole('button', { name: /economy/i })).toHaveAttribute('aria-expanded', 'true')
312+
})
313+
314+
it('auto-expands Economy when currentPath is /starlark-config', () => {
315+
localStorage.setItem('meridian:sidebar-collapsed', JSON.stringify(['Economy']))
316+
renderSidebar({ lens: 'tenant', currentPath: '/starlark-config' })
317+
318+
expect(screen.getByRole('button', { name: /economy/i })).toHaveAttribute('aria-expanded', 'true')
319+
})
320+
321+
it('does not auto-expand Economy for unrelated paths', () => {
322+
localStorage.setItem('meridian:sidebar-collapsed', JSON.stringify(['Economy']))
323+
renderSidebar({ lens: 'tenant', currentPath: '/accounts' })
324+
325+
expect(screen.getByRole('button', { name: /economy/i })).toHaveAttribute('aria-expanded', 'false')
326+
})
327+
328+
it('non-collapsible groups render header as static text, not button', () => {
329+
renderSidebar({ lens: 'tenant' })
330+
331+
// Operations is not collapsible — no button for it
332+
expect(screen.queryByRole('button', { name: /operations/i })).not.toBeInTheDocument()
333+
expect(screen.getByText('Operations')).toBeInTheDocument()
334+
})
335+
})
336+
337+
describe('Economy group feature gate', () => {
338+
it('hides entire Economy group when economy feature is disabled', () => {
339+
vi.mocked(useTenantFeatures).mockReturnValue(
340+
makeFeatures(['dashboard', 'accounts']),
341+
)
342+
vi.mocked(useTenantContext).mockReturnValue(makeContext({ isPlatformAdmin: false }))
343+
344+
renderSidebar({ lens: 'tenant' })
345+
346+
expect(screen.queryByRole('button', { name: /economy/i })).not.toBeInTheDocument()
347+
expect(screen.queryByRole('link', { name: 'Overview' })).not.toBeInTheDocument()
348+
expect(screen.queryByRole('link', { name: 'Reference Data' })).not.toBeInTheDocument()
349+
})
350+
351+
it('shows Economy group for platform admin even when economy feature disabled', () => {
352+
vi.mocked(useTenantFeatures).mockReturnValue(
353+
makeFeatures(['dashboard']),
354+
)
355+
vi.mocked(useTenantContext).mockReturnValue(makeContext({ isPlatformAdmin: true }))
356+
357+
renderSidebar({ lens: 'platform' })
358+
359+
expect(screen.getByRole('button', { name: /economy/i })).toBeInTheDocument()
360+
expect(screen.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
361+
})
362+
363+
it('hides individual Economy items whose specific feature is disabled', () => {
364+
vi.mocked(useTenantFeatures).mockReturnValue(
365+
makeFeatures(['dashboard', 'economy', 'reference-data']),
366+
)
367+
vi.mocked(useTenantContext).mockReturnValue(makeContext({ isPlatformAdmin: false }))
368+
369+
renderSidebar({ lens: 'tenant' })
370+
371+
// Economy group visible (economy feature enabled)
372+
expect(screen.getByRole('button', { name: /economy/i })).toBeInTheDocument()
373+
// Overview visible (economy feature)
374+
expect(screen.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
375+
// Reference Data visible (reference-data feature)
376+
expect(screen.getByRole('link', { name: 'Reference Data' })).toBeInTheDocument()
377+
// Starlark Config hidden (sagas feature not enabled)
378+
expect(screen.queryByRole('link', { name: 'Starlark Config' })).not.toBeInTheDocument()
379+
// Market Data hidden
380+
expect(screen.queryByRole('link', { name: 'Market Data' })).not.toBeInTheDocument()
381+
})
382+
})
211383
})

0 commit comments

Comments
 (0)