Skip to content

Commit 8742b36

Browse files
authored
feat(frontend): add useTenantLayout hook and widget registry (#1402)
* feat: add useTenantLayout hook and widget registry - useTenantLayout() reads dashboard widget config and table defaults from TenantContext, falling back to empty defaults when no layout is configured - widget-registry.ts exports KNOWN_WIDGETS and isRegisteredWidget() for validating component names from tenant layout config - DashboardPage wired to useTenantLayout so layout config is available - Unit tests covering all fallback and configured-layout paths * fix: address review feedback on useTenantLayout hook - Use Partial<Record<string, TableDefaults>> to reflect partial mapping - Guard getTableDefaults with Object.hasOwn against prototype access - Add missing applyTheme property to test makeContext helper * fix: remove unused useTenantLayout call from DashboardPage The hook has no side effects so calling it without capturing the result was dead code. Dashboard wiring is a follow-up task. * chore: trigger re-review after addressing all CodeRabbit feedback --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 184b313 commit 8742b36

4 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { renderHook } from '@testing-library/react'
3+
import { useTenantLayout } from '../use-tenant-layout'
4+
import type { TenantContextValue } from '@/contexts/tenant-context'
5+
import { DEFAULT_UI_CONFIG } from '@/lib/tenant-ui-config'
6+
7+
vi.mock('@/contexts/tenant-context', () => ({
8+
useTenantContext: vi.fn(),
9+
}))
10+
11+
import { useTenantContext } from '@/contexts/tenant-context'
12+
13+
function makeContext(overrides: Partial<TenantContextValue> = {}): TenantContextValue {
14+
return {
15+
currentTenant: null,
16+
tenantSlug: null,
17+
isPlatformAdmin: false,
18+
switchTenant: vi.fn(),
19+
clearTenant: vi.fn(),
20+
applyTheme: vi.fn(),
21+
...overrides,
22+
}
23+
}
24+
25+
describe('useTenantLayout', () => {
26+
it('returns empty widgets and tableDefaults when no tenantConfig is set', () => {
27+
vi.mocked(useTenantContext).mockReturnValue(makeContext())
28+
29+
const { result } = renderHook(() => useTenantLayout())
30+
31+
expect(result.current.widgets).toEqual([])
32+
expect(result.current.tableDefaults).toEqual({})
33+
})
34+
35+
it('returns empty widgets and tableDefaults with DEFAULT_UI_CONFIG (no layout defined)', () => {
36+
vi.mocked(useTenantContext).mockReturnValue(
37+
makeContext({ tenantConfig: DEFAULT_UI_CONFIG }),
38+
)
39+
40+
const { result } = renderHook(() => useTenantLayout())
41+
42+
expect(result.current.widgets).toEqual([])
43+
expect(result.current.tableDefaults).toEqual({})
44+
})
45+
46+
it('returns widgets from tenant layout config', () => {
47+
vi.mocked(useTenantContext).mockReturnValue(
48+
makeContext({
49+
tenantConfig: {
50+
layout: {
51+
dashboard: {
52+
widgets: [
53+
{ feature: 'payments', component: 'StatCards', position: 0 },
54+
{ feature: 'ledger', component: 'ActivityFeed', position: 1 },
55+
],
56+
},
57+
tableDefaults: {},
58+
},
59+
},
60+
}),
61+
)
62+
63+
const { result } = renderHook(() => useTenantLayout())
64+
65+
expect(result.current.widgets).toHaveLength(2)
66+
expect(result.current.widgets[0]).toEqual({
67+
feature: 'payments',
68+
component: 'StatCards',
69+
position: 0,
70+
})
71+
expect(result.current.widgets[1]).toEqual({
72+
feature: 'ledger',
73+
component: 'ActivityFeed',
74+
position: 1,
75+
})
76+
})
77+
78+
it('returns tableDefaults from tenant layout config', () => {
79+
vi.mocked(useTenantContext).mockReturnValue(
80+
makeContext({
81+
tenantConfig: {
82+
layout: {
83+
dashboard: { widgets: [] },
84+
tableDefaults: {
85+
payments: { visibleColumns: ['id', 'amount', 'status'], defaultSort: 'createdAt' },
86+
accounts: { visibleColumns: ['id', 'name'] },
87+
},
88+
},
89+
},
90+
}),
91+
)
92+
93+
const { result } = renderHook(() => useTenantLayout())
94+
95+
expect(result.current.tableDefaults).toEqual({
96+
payments: { visibleColumns: ['id', 'amount', 'status'], defaultSort: 'createdAt' },
97+
accounts: { visibleColumns: ['id', 'name'] },
98+
})
99+
})
100+
101+
it('getTableDefaults returns config for a known table key', () => {
102+
vi.mocked(useTenantContext).mockReturnValue(
103+
makeContext({
104+
tenantConfig: {
105+
layout: {
106+
dashboard: { widgets: [] },
107+
tableDefaults: {
108+
payments: { visibleColumns: ['id', 'amount'], defaultSort: 'id' },
109+
},
110+
},
111+
},
112+
}),
113+
)
114+
115+
const { result } = renderHook(() => useTenantLayout())
116+
117+
expect(result.current.getTableDefaults('payments')).toEqual({
118+
visibleColumns: ['id', 'amount'],
119+
defaultSort: 'id',
120+
})
121+
})
122+
123+
it('getTableDefaults returns undefined for an unknown table key', () => {
124+
vi.mocked(useTenantContext).mockReturnValue(
125+
makeContext({ tenantConfig: DEFAULT_UI_CONFIG }),
126+
)
127+
128+
const { result } = renderHook(() => useTenantLayout())
129+
130+
expect(result.current.getTableDefaults('nonexistent')).toBeUndefined()
131+
})
132+
133+
it('falls back gracefully when tenantConfig has no layout key', () => {
134+
vi.mocked(useTenantContext).mockReturnValue(
135+
makeContext({ tenantConfig: {} }),
136+
)
137+
138+
const { result } = renderHook(() => useTenantLayout())
139+
140+
expect(result.current.widgets).toEqual([])
141+
expect(result.current.tableDefaults).toEqual({})
142+
expect(result.current.getTableDefaults('anything')).toBeUndefined()
143+
})
144+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useMemo } from 'react'
2+
import { useTenantContext } from '@/contexts/tenant-context'
3+
import {
4+
DEFAULT_UI_CONFIG,
5+
type DashboardWidget,
6+
type TableDefaults,
7+
} from '@/lib/tenant-ui-config'
8+
9+
const DEFAULT_WIDGETS: DashboardWidget[] = []
10+
11+
const DEFAULT_TABLE_DEFAULTS: Readonly<Partial<Record<string, TableDefaults>>> = {}
12+
13+
export interface TenantLayoutResult {
14+
widgets: readonly DashboardWidget[]
15+
tableDefaults: Readonly<Partial<Record<string, TableDefaults>>>
16+
getTableDefaults: (tableKey: string) => TableDefaults | undefined
17+
}
18+
19+
export function useTenantLayout(): TenantLayoutResult {
20+
const { tenantConfig } = useTenantContext()
21+
22+
return useMemo(() => {
23+
const config = tenantConfig ?? DEFAULT_UI_CONFIG
24+
const layout = config.layout
25+
26+
const widgets: readonly DashboardWidget[] =
27+
layout?.dashboard?.widgets ?? DEFAULT_WIDGETS
28+
29+
const tableDefaults: Readonly<Partial<Record<string, TableDefaults>>> =
30+
layout?.tableDefaults ?? DEFAULT_TABLE_DEFAULTS
31+
32+
return {
33+
widgets,
34+
tableDefaults,
35+
getTableDefaults: (tableKey: string) =>
36+
Object.hasOwn(tableDefaults, tableKey) ? tableDefaults[tableKey] : undefined,
37+
}
38+
}, [tenantConfig])
39+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { KNOWN_WIDGETS, isRegisteredWidget } from '../widget-registry'
3+
4+
describe('widget-registry', () => {
5+
it('KNOWN_WIDGETS contains expected entries', () => {
6+
expect(KNOWN_WIDGETS).toContain('StatCards')
7+
expect(KNOWN_WIDGETS).toContain('ActivityFeed')
8+
expect(KNOWN_WIDGETS).toContain('QuickActions')
9+
})
10+
11+
it('isRegisteredWidget returns true for known widgets', () => {
12+
for (const name of KNOWN_WIDGETS) {
13+
expect(isRegisteredWidget(name)).toBe(true)
14+
}
15+
})
16+
17+
it('isRegisteredWidget returns false for unknown component names', () => {
18+
expect(isRegisteredWidget('UnknownWidget')).toBe(false)
19+
expect(isRegisteredWidget('')).toBe(false)
20+
expect(isRegisteredWidget('statcards')).toBe(false) // case-sensitive
21+
})
22+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Widget registry maps component names (from tenant layout config) to React component
2+
// identifiers. Each entry represents a renderable dashboard widget.
3+
//
4+
// The registry is intentionally kept as a plain object (no lazy imports) so that
5+
// widget-registry.ts can be used in tests without a full React render environment.
6+
// Actual rendering is handled by consumers (e.g. DashboardPage) that resolve the
7+
// component name to its concrete implementation.
8+
9+
export const KNOWN_WIDGETS = [
10+
'StatCards',
11+
'ActivityFeed',
12+
'QuickActions',
13+
] as const
14+
15+
export type KnownWidgetName = (typeof KNOWN_WIDGETS)[number]
16+
17+
const WIDGET_SET = new Set<string>(KNOWN_WIDGETS)
18+
19+
export function isRegisteredWidget(componentName: string): boolean {
20+
return WIDGET_SET.has(componentName)
21+
}

0 commit comments

Comments
 (0)