Skip to content

Commit 107b087

Browse files
authored
feat: add useTenantFeatures hook and feature visibility system (#1394)
* feat: add useTenantFeatures hook and feature visibility system - Create use-tenant-features.ts hook returning isFeatureEnabled, enabledFeatures, defaultFeature - Reads tenantConfig from TenantContext, falls back to DEFAULT_UI_CONFIG - Re-export ALL_FEATURES from tenant-ui-config for consumers - Add tenantConfig field to TenantContextValue interface in tenant-context.tsx - Populate tenantConfig with DEFAULT_UI_CONFIG in TenantProvider value - Add unit tests covering defaults, filtering, isFeatureEnabled, and fallback paths * refactor: memoize useTenantFeatures and validate defaultFeature against enabled list - Wrap computation in useMemo to avoid re-creating Set and callback on every render - Fall back to first enabled feature when configured defaultFeature is not in enabled list - Add test covering defaultFeature fallback when configured default is disabled * fix: return empty string for defaultFeature when enabled list is empty Avoids inconsistency where defaultFeature would resolve to 'dashboard' but isFeatureEnabled('dashboard') returns false. When no features are enabled, defaultFeature is now an empty string. * fix: treat empty enabled list as default (all features) in useTenantFeatures An empty features.enabled array is treated the same as an absent config — fall back to all features. This avoids the inconsistency where defaultFeature could resolve to a feature not present in the enabled set. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent d66d4aa commit 107b087

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

frontend/src/contexts/tenant-context.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
22
import { useQueryClient } from '@tanstack/react-query'
33
import { useAuth } from '@/contexts/auth-context'
4+
import { DEFAULT_UI_CONFIG, type TenantUIConfig } from '@/lib/tenant-ui-config'
45

56
export interface Tenant {
67
id: string
@@ -14,6 +15,7 @@ export interface TenantContextValue {
1415
isPlatformAdmin: boolean
1516
switchTenant: (tenant: Tenant) => void
1617
clearTenant: () => void
18+
tenantConfig?: TenantUIConfig
1719
}
1820

1921
const TenantContext = createContext<TenantContextValue | null>(null)
@@ -59,6 +61,7 @@ export function TenantProvider({ children }: { children: ReactNode }) {
5961
isPlatformAdmin,
6062
switchTenant,
6163
clearTenant,
64+
tenantConfig: DEFAULT_UI_CONFIG,
6265
}
6366

6467
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { renderHook } from '@testing-library/react'
3+
import { useTenantFeatures, ALL_FEATURES } from '../use-tenant-features'
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+
...overrides,
21+
}
22+
}
23+
24+
describe('useTenantFeatures', () => {
25+
it('returns all features enabled when no tenantConfig is set', () => {
26+
vi.mocked(useTenantContext).mockReturnValue(makeContext())
27+
28+
const { result } = renderHook(() => useTenantFeatures())
29+
30+
expect(result.current.enabledFeatures).toEqual([...ALL_FEATURES])
31+
expect(result.current.defaultFeature).toBe('dashboard')
32+
})
33+
34+
it('returns all features enabled when using DEFAULT_UI_CONFIG', () => {
35+
vi.mocked(useTenantContext).mockReturnValue(
36+
makeContext({ tenantConfig: DEFAULT_UI_CONFIG }),
37+
)
38+
39+
const { result } = renderHook(() => useTenantFeatures())
40+
41+
expect(result.current.enabledFeatures).toEqual([...ALL_FEATURES])
42+
ALL_FEATURES.forEach((f) => {
43+
expect(result.current.isFeatureEnabled(f)).toBe(true)
44+
})
45+
})
46+
47+
it('correctly filters disabled features when config limits enabled list', () => {
48+
vi.mocked(useTenantContext).mockReturnValue(
49+
makeContext({
50+
tenantConfig: {
51+
features: {
52+
enabled: ['dashboard', 'accounts', 'payments'],
53+
defaultFeature: 'dashboard',
54+
},
55+
},
56+
}),
57+
)
58+
59+
const { result } = renderHook(() => useTenantFeatures())
60+
61+
expect(result.current.enabledFeatures).toEqual(['dashboard', 'accounts', 'payments'])
62+
expect(result.current.isFeatureEnabled('dashboard')).toBe(true)
63+
expect(result.current.isFeatureEnabled('accounts')).toBe(true)
64+
expect(result.current.isFeatureEnabled('payments')).toBe(true)
65+
expect(result.current.isFeatureEnabled('ledger')).toBe(false)
66+
expect(result.current.isFeatureEnabled('tenants')).toBe(false)
67+
})
68+
69+
it('isFeatureEnabled returns false for unknown feature names', () => {
70+
vi.mocked(useTenantContext).mockReturnValue(makeContext({ tenantConfig: DEFAULT_UI_CONFIG }))
71+
72+
const { result } = renderHook(() => useTenantFeatures())
73+
74+
expect(result.current.isFeatureEnabled('nonexistent-feature')).toBe(false)
75+
})
76+
77+
it('uses custom defaultFeature from config', () => {
78+
vi.mocked(useTenantContext).mockReturnValue(
79+
makeContext({
80+
tenantConfig: {
81+
features: {
82+
enabled: ['accounts', 'payments'],
83+
defaultFeature: 'accounts',
84+
},
85+
},
86+
}),
87+
)
88+
89+
const { result } = renderHook(() => useTenantFeatures())
90+
91+
expect(result.current.defaultFeature).toBe('accounts')
92+
})
93+
94+
it('falls back to dashboard as defaultFeature when config has no defaultFeature', () => {
95+
vi.mocked(useTenantContext).mockReturnValue(
96+
makeContext({
97+
tenantConfig: {
98+
features: {
99+
enabled: ['dashboard', 'accounts'],
100+
},
101+
},
102+
}),
103+
)
104+
105+
const { result } = renderHook(() => useTenantFeatures())
106+
107+
expect(result.current.defaultFeature).toBe('dashboard')
108+
})
109+
110+
it('falls back gracefully when tenantConfig has no features key', () => {
111+
vi.mocked(useTenantContext).mockReturnValue(
112+
makeContext({
113+
tenantConfig: {},
114+
}),
115+
)
116+
117+
const { result } = renderHook(() => useTenantFeatures())
118+
119+
expect(result.current.enabledFeatures).toEqual([...ALL_FEATURES])
120+
expect(result.current.defaultFeature).toBe('dashboard')
121+
expect(result.current.isFeatureEnabled('dashboard')).toBe(true)
122+
})
123+
124+
it('falls back to first enabled feature when configured default is not in enabled list', () => {
125+
vi.mocked(useTenantContext).mockReturnValue(
126+
makeContext({
127+
tenantConfig: {
128+
features: {
129+
enabled: ['accounts', 'payments'],
130+
defaultFeature: 'dashboard', // dashboard is NOT in the enabled list
131+
},
132+
},
133+
}),
134+
)
135+
136+
const { result } = renderHook(() => useTenantFeatures())
137+
138+
// Should fall back to first enabled feature, not the disabled default
139+
expect(result.current.defaultFeature).toBe('accounts')
140+
})
141+
142+
it('falls back to all features when enabled list is empty', () => {
143+
vi.mocked(useTenantContext).mockReturnValue(
144+
makeContext({
145+
tenantConfig: {
146+
features: {
147+
enabled: [],
148+
},
149+
},
150+
}),
151+
)
152+
153+
const { result } = renderHook(() => useTenantFeatures())
154+
155+
// Empty enabled list is treated as "use defaults" — all features enabled
156+
expect(result.current.enabledFeatures).toEqual([...ALL_FEATURES])
157+
expect(result.current.isFeatureEnabled('dashboard')).toBe(true)
158+
expect(result.current.defaultFeature).toBe('dashboard')
159+
})
160+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useMemo } from 'react'
2+
import { useTenantContext } from '@/contexts/tenant-context'
3+
import {
4+
ALL_FEATURES,
5+
DEFAULT_UI_CONFIG,
6+
type FeatureId,
7+
} from '@/lib/tenant-ui-config'
8+
9+
export { ALL_FEATURES }
10+
11+
export interface TenantFeaturesResult {
12+
isFeatureEnabled: (feature: string) => boolean
13+
enabledFeatures: readonly FeatureId[]
14+
defaultFeature: string
15+
}
16+
17+
export function useTenantFeatures(): TenantFeaturesResult {
18+
const { tenantConfig } = useTenantContext()
19+
20+
return useMemo(() => {
21+
const config = tenantConfig ?? DEFAULT_UI_CONFIG
22+
23+
// Treat an empty enabled list the same as an absent one — fall back to all features
24+
const configuredEnabled = config.features?.enabled ?? [...ALL_FEATURES]
25+
const enabledFeatures = configuredEnabled.length > 0 ? configuredEnabled : [...ALL_FEATURES]
26+
const enabledSet = new Set<string>(enabledFeatures)
27+
28+
// Fall back to the first enabled feature if the configured default is not in the enabled list
29+
const configuredDefault =
30+
config.features?.defaultFeature ?? DEFAULT_UI_CONFIG.features!.defaultFeature!
31+
const defaultFeature = enabledSet.has(configuredDefault)
32+
? configuredDefault
33+
: enabledFeatures[0]
34+
35+
return {
36+
isFeatureEnabled: (feature: string) => enabledSet.has(feature),
37+
enabledFeatures,
38+
defaultFeature,
39+
}
40+
}, [tenantConfig])
41+
}

0 commit comments

Comments
 (0)