@open-mercato/ui 0.4.11-develop.2631.481e9df5b0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +28 -4
- package/agentic/standalone-guide.md +97 -0
- package/build.mjs +10 -6
- package/dist/backend/AppShell.js +15 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +22 -1
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/CustomDataSection.js +1 -5
- package/dist/backend/detail/CustomDataSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -5
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +2 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +93 -3
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/markdown/MarkdownContent.js +47 -4
- package/dist/backend/markdown/MarkdownContent.js.map +2 -2
- package/dist/portal/PortalShell.js +41 -11
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/portal/hooks/usePortalDashboardWidgets.js +40 -1
- package/dist/portal/hooks/usePortalDashboardWidgets.js.map +2 -2
- package/dist/portal/utils/nav.js +84 -0
- package/dist/portal/utils/nav.js.map +7 -0
- package/package.json +13 -9
- package/src/backend/AppShell.tsx +22 -2
- package/src/backend/DataTable.tsx +28 -5
- package/src/backend/__tests__/AppShell.test.tsx +67 -0
- package/src/backend/__tests__/FormHeader.test.tsx +0 -1
- package/src/backend/detail/CustomDataSection.tsx +1 -10
- package/src/backend/detail/InlineEditors.tsx +3 -15
- package/src/backend/detail/NotesSection.tsx +5 -14
- package/src/backend/icons/lucideRegistry.generated.tsx +93 -3
- package/src/backend/injection/__tests__/resolveInjectedIcon.test.tsx +7 -0
- package/src/backend/markdown/MarkdownContent.tsx +76 -6
- package/src/backend/section-page/types.ts +1 -0
- package/src/portal/PortalShell.tsx +43 -11
- package/src/portal/hooks/__tests__/usePortalDashboardWidgets.test.tsx +117 -0
- package/src/portal/hooks/usePortalDashboardWidgets.ts +55 -1
- package/src/portal/utils/__tests__/nav.test.ts +199 -0
- package/src/portal/utils/nav.ts +150 -0
|
@@ -4,7 +4,65 @@ import * as React from 'react'
|
|
|
4
4
|
import type { PluggableList } from 'unified'
|
|
5
5
|
import { useMarkdownRemarkPlugins } from './useMarkdownRemarkPlugins'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
type ReactMarkdownProps = {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
remarkPlugins?: PluggableList
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
|
|
13
|
+
|
|
14
|
+
const TestMarkdownComponent: React.ComponentType<ReactMarkdownProps> = ({ children }) => <>{children}</>
|
|
15
|
+
|
|
16
|
+
let loadedReactMarkdownComponent: React.ComponentType<ReactMarkdownProps> | null = isTestEnv
|
|
17
|
+
? TestMarkdownComponent
|
|
18
|
+
: null
|
|
19
|
+
let reactMarkdownComponentPromise: Promise<React.ComponentType<ReactMarkdownProps>> | null = null
|
|
20
|
+
|
|
21
|
+
function loadReactMarkdownComponent(): Promise<React.ComponentType<ReactMarkdownProps>> {
|
|
22
|
+
if (loadedReactMarkdownComponent) {
|
|
23
|
+
return Promise.resolve(loadedReactMarkdownComponent)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!reactMarkdownComponentPromise) {
|
|
27
|
+
reactMarkdownComponentPromise = import('react-markdown').then((mod) => {
|
|
28
|
+
const component = mod.default as React.ComponentType<ReactMarkdownProps>
|
|
29
|
+
loadedReactMarkdownComponent = component
|
|
30
|
+
return component
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return reactMarkdownComponentPromise
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ReactMarkdownComponent(props: ReactMarkdownProps) {
|
|
38
|
+
const [Component, setComponent] = React.useState<React.ComponentType<ReactMarkdownProps> | null>(
|
|
39
|
+
() => loadedReactMarkdownComponent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
if (Component) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let active = true
|
|
48
|
+
|
|
49
|
+
void loadReactMarkdownComponent().then((resolved) => {
|
|
50
|
+
if (active) {
|
|
51
|
+
setComponent(() => resolved)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
active = false
|
|
57
|
+
}
|
|
58
|
+
}, [Component])
|
|
59
|
+
|
|
60
|
+
if (!Component) {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return <Component {...props} />
|
|
65
|
+
}
|
|
8
66
|
|
|
9
67
|
export type MarkdownContentProps = {
|
|
10
68
|
body: string
|
|
@@ -13,8 +71,22 @@ export type MarkdownContentProps = {
|
|
|
13
71
|
remarkPlugins?: PluggableList
|
|
14
72
|
}
|
|
15
73
|
|
|
74
|
+
export type MarkdownPreviewProps = {
|
|
75
|
+
children: string
|
|
76
|
+
className?: string
|
|
77
|
+
remarkPlugins?: PluggableList
|
|
78
|
+
}
|
|
79
|
+
|
|
16
80
|
const EMPTY_PLUGINS: PluggableList = []
|
|
17
81
|
|
|
82
|
+
export function MarkdownPreview({ children, className, remarkPlugins }: MarkdownPreviewProps) {
|
|
83
|
+
return (
|
|
84
|
+
<div className={className}>
|
|
85
|
+
<ReactMarkdownComponent remarkPlugins={remarkPlugins}>{children}</ReactMarkdownComponent>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
18
90
|
export function MarkdownContent({
|
|
19
91
|
body,
|
|
20
92
|
format = 'text',
|
|
@@ -31,10 +103,8 @@ export function MarkdownContent({
|
|
|
31
103
|
}
|
|
32
104
|
|
|
33
105
|
return (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</ReactMarkdown>
|
|
38
|
-
</React.Suspense>
|
|
106
|
+
<MarkdownPreview className={className} remarkPlugins={plugins}>
|
|
107
|
+
{body}
|
|
108
|
+
</MarkdownPreview>
|
|
39
109
|
)
|
|
40
110
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client"
|
|
2
|
-
import { type ReactNode, useState, useCallback, useMemo, useContext } from 'react'
|
|
2
|
+
import { type ReactNode, useEffect, useState, useCallback, useMemo, useContext } from 'react'
|
|
3
3
|
import Image from 'next/image'
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { usePathname } from 'next/navigation'
|
|
@@ -12,6 +12,8 @@ import { mergeMenuItems } from '../backend/injection/mergeMenuItems'
|
|
|
12
12
|
import type { MergedMenuItem } from '../backend/injection/mergeMenuItems'
|
|
13
13
|
import { PortalNotificationBell } from './components/PortalNotificationBell'
|
|
14
14
|
import { usePortalContext } from './PortalContext'
|
|
15
|
+
import { apiCall } from '../backend/utils/apiCall'
|
|
16
|
+
import type { PortalNavGroup } from './utils/nav'
|
|
15
17
|
|
|
16
18
|
// Component replacement handle IDs (FROZEN once shipped)
|
|
17
19
|
export const PORTAL_SHELL_HANDLE = 'page:portal:layout'
|
|
@@ -174,29 +176,59 @@ export function PortalShell({
|
|
|
174
176
|
const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'
|
|
175
177
|
const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'
|
|
176
178
|
const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'
|
|
177
|
-
const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : '/portal/dashboard'
|
|
178
|
-
const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : '/portal/profile'
|
|
179
179
|
// Always use the resolved organization name from the database.
|
|
180
180
|
// Fall back to the generic portal title — never display the raw slug.
|
|
181
181
|
const headerTitle = orgName || t('portal.title', 'Customer Portal')
|
|
182
182
|
|
|
183
183
|
const closeMobile = useCallback(() => setMobileOpen(false), [])
|
|
184
184
|
|
|
185
|
+
const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!authenticated) {
|
|
188
|
+
setAutoNavGroups([])
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false
|
|
192
|
+
const load = async () => {
|
|
193
|
+
try {
|
|
194
|
+
const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(
|
|
195
|
+
'/api/customer_accounts/portal/nav',
|
|
196
|
+
)
|
|
197
|
+
if (cancelled || !ok || !result?.ok) return
|
|
198
|
+
setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])
|
|
199
|
+
} catch {
|
|
200
|
+
if (!cancelled) setAutoNavGroups([])
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
void load()
|
|
204
|
+
return () => {
|
|
205
|
+
cancelled = true
|
|
206
|
+
}
|
|
207
|
+
}, [authenticated])
|
|
208
|
+
|
|
185
209
|
const mergedNavItems = useMemo(() => {
|
|
186
210
|
if (!authenticated) return []
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []
|
|
212
|
+
const builtIn = discovered.map((item) => ({
|
|
213
|
+
id: item.id,
|
|
214
|
+
labelKey: item.labelKey,
|
|
215
|
+
label: item.label,
|
|
216
|
+
href: item.href,
|
|
217
|
+
}))
|
|
190
218
|
return mergeMenuItems(builtIn, injectedMainItems)
|
|
191
|
-
}, [authenticated,
|
|
219
|
+
}, [authenticated, autoNavGroups, injectedMainItems])
|
|
192
220
|
|
|
193
221
|
const mergedAccountItems = useMemo(() => {
|
|
194
222
|
if (!authenticated) return []
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
223
|
+
const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []
|
|
224
|
+
const builtIn = discovered.map((item) => ({
|
|
225
|
+
id: item.id,
|
|
226
|
+
labelKey: item.labelKey,
|
|
227
|
+
label: item.label,
|
|
228
|
+
href: item.href,
|
|
229
|
+
}))
|
|
198
230
|
return mergeMenuItems(builtIn, injectedAccountItems)
|
|
199
|
-
}, [authenticated,
|
|
231
|
+
}, [authenticated, autoNavGroups, injectedAccountItems])
|
|
200
232
|
|
|
201
233
|
/* ---- PUBLIC LAYOUT ---- */
|
|
202
234
|
if (!authenticated) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const loadInjectionWidgetsForSpotMock = jest.fn()
|
|
6
|
+
const apiCallMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
|
|
9
|
+
loadInjectionWidgetsForSpot: (...args: unknown[]) => loadInjectionWidgetsForSpotMock(...args),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('../../../backend/utils/apiCall', () => ({
|
|
13
|
+
apiCall: (...args: unknown[]) => apiCallMock(...args),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { usePortalDashboardWidgets } from '../usePortalDashboardWidgets'
|
|
17
|
+
|
|
18
|
+
function widget(id: string, features?: string[]) {
|
|
19
|
+
return {
|
|
20
|
+
moduleId: 'test',
|
|
21
|
+
spotId: 'portal:dashboard:sections',
|
|
22
|
+
widgetId: id,
|
|
23
|
+
Widget: () => null,
|
|
24
|
+
metadata: { id, features },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mockFeatureCheckGranted(granted: string[]) {
|
|
29
|
+
apiCallMock.mockImplementation(async (url: string) => {
|
|
30
|
+
if (url === '/api/customer_accounts/portal/feature-check') {
|
|
31
|
+
return { ok: true, result: { ok: true, granted } }
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`unexpected apiCall: ${url}`)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('usePortalDashboardWidgets — feature gating (Phase 1 regression)', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns widgets without required features regardless of grants', async () => {
|
|
43
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('always-visible')])
|
|
44
|
+
// No features required → hook should skip the feature-check entirely
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
47
|
+
|
|
48
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
49
|
+
expect(result.current.widgets).toHaveLength(1)
|
|
50
|
+
expect(result.current.widgets[0].widgetId).toBe('always-visible')
|
|
51
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('filters out widgets whose required feature the user lacks', async () => {
|
|
55
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
56
|
+
widget('visible', ['portal.orders.view']),
|
|
57
|
+
widget('hidden', ['portal.billing.manage']),
|
|
58
|
+
])
|
|
59
|
+
mockFeatureCheckGranted(['portal.orders.view'])
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
62
|
+
|
|
63
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
64
|
+
const ids = result.current.widgets.map((w) => w.widgetId)
|
|
65
|
+
expect(ids).toEqual(['visible'])
|
|
66
|
+
expect(apiCallMock).toHaveBeenCalledWith(
|
|
67
|
+
'/api/customer_accounts/portal/feature-check',
|
|
68
|
+
expect.objectContaining({ method: 'POST' }),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resolves wildcard grants through the shared matcher', async () => {
|
|
73
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
74
|
+
widget('orders-view', ['portal.orders.view']),
|
|
75
|
+
widget('orders-create', ['portal.orders.create']),
|
|
76
|
+
widget('billing', ['portal.billing.manage']),
|
|
77
|
+
])
|
|
78
|
+
// Grant is a wildcard — server returns the concrete grants it matched.
|
|
79
|
+
mockFeatureCheckGranted(['portal.orders.view', 'portal.orders.create'])
|
|
80
|
+
|
|
81
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
84
|
+
const ids = result.current.widgets.map((w) => w.widgetId).sort()
|
|
85
|
+
expect(ids).toEqual(['orders-create', 'orders-view'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('excludes all gated widgets when feature-check fails', async () => {
|
|
89
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
90
|
+
widget('ungated'),
|
|
91
|
+
widget('gated', ['portal.orders.view']),
|
|
92
|
+
])
|
|
93
|
+
apiCallMock.mockRejectedValueOnce(new Error('network down'))
|
|
94
|
+
|
|
95
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
98
|
+
const ids = result.current.widgets.map((w) => w.widgetId)
|
|
99
|
+
// Ungated widget stays; gated widget is filtered because granted set is empty.
|
|
100
|
+
expect(ids).toEqual(['ungated'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('excludes widgets without a Widget component', async () => {
|
|
104
|
+
const noWidget = {
|
|
105
|
+
moduleId: 'test',
|
|
106
|
+
spotId: 'portal:dashboard:sections',
|
|
107
|
+
widgetId: 'data-only',
|
|
108
|
+
metadata: { id: 'data-only' },
|
|
109
|
+
} as any
|
|
110
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('real'), noWidget])
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
115
|
+
expect(result.current.widgets.map((w) => w.widgetId)).toEqual(['real'])
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
5
|
import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
6
|
+
import { hasAllFeatures } from '@open-mercato/shared/security/features'
|
|
7
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
8
|
+
|
|
9
|
+
type PortalFeatureCheckResponse = {
|
|
10
|
+
ok: boolean
|
|
11
|
+
granted?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collectRequiredFeatures(widgets: LoadedInjectionWidget[]): string[] {
|
|
15
|
+
const set = new Set<string>()
|
|
16
|
+
for (const widget of widgets) {
|
|
17
|
+
for (const feature of widget.metadata.features ?? []) {
|
|
18
|
+
if (!feature || feature.trim().length === 0) continue
|
|
19
|
+
set.add(feature)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Array.from(set)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readPortalGrantedFeatures(features: string[]): Promise<Set<string>> {
|
|
26
|
+
if (features.length === 0) return new Set()
|
|
27
|
+
try {
|
|
28
|
+
const { ok, result: data } = await apiCall<PortalFeatureCheckResponse>('/api/customer_accounts/portal/feature-check', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ features }),
|
|
32
|
+
})
|
|
33
|
+
if (!ok || !data?.ok) return new Set()
|
|
34
|
+
return new Set(data.granted ?? [])
|
|
35
|
+
} catch {
|
|
36
|
+
return new Set()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
6
39
|
|
|
7
40
|
/**
|
|
8
41
|
* Loads UI injection widgets (with Widget component) for a portal spot.
|
|
@@ -10,6 +43,11 @@ import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-m
|
|
|
10
43
|
* Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),
|
|
11
44
|
* this hook loads widgets that export a `Widget` React component — suitable for
|
|
12
45
|
* portal dashboard sections and other UI injection spots.
|
|
46
|
+
*
|
|
47
|
+
* Feature gating: widgets declaring `metadata.features` are filtered against the
|
|
48
|
+
* authenticated customer's grants resolved via
|
|
49
|
+
* `/api/customer_accounts/portal/feature-check`. Wildcard grants (`portal.*`) resolve
|
|
50
|
+
* through the shared matcher.
|
|
13
51
|
*/
|
|
14
52
|
export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
15
53
|
widgets: LoadedInjectionWidget[]
|
|
@@ -19,6 +57,7 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
19
57
|
const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])
|
|
20
58
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
21
59
|
const [error, setError] = React.useState<string | null>(null)
|
|
60
|
+
const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())
|
|
22
61
|
|
|
23
62
|
React.useEffect(() => {
|
|
24
63
|
let mounted = true
|
|
@@ -31,6 +70,10 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
31
70
|
// Only keep widgets that have a Widget component
|
|
32
71
|
const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')
|
|
33
72
|
setWidgets(uiWidgets)
|
|
73
|
+
const required = collectRequiredFeatures(uiWidgets)
|
|
74
|
+
const granted = await readPortalGrantedFeatures(required)
|
|
75
|
+
if (!mounted) return
|
|
76
|
+
setGrantedFeatures(granted)
|
|
34
77
|
} catch (loadError) {
|
|
35
78
|
if (!mounted) return
|
|
36
79
|
console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)
|
|
@@ -46,5 +89,16 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
46
89
|
}
|
|
47
90
|
}, [spotId])
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures])
|
|
93
|
+
|
|
94
|
+
const visibleWidgets = React.useMemo(
|
|
95
|
+
() =>
|
|
96
|
+
widgets.filter((widget) => {
|
|
97
|
+
const required = widget.metadata.features ?? []
|
|
98
|
+
return required.length === 0 || hasAllFeatures(grantedFeatureList, required)
|
|
99
|
+
}),
|
|
100
|
+
[widgets, grantedFeatureList],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return { widgets: visibleWidgets, isLoading, error }
|
|
50
104
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import { buildPortalNav, mergePortalSidebarGroupsWithInjected } from '../nav'
|
|
3
|
+
|
|
4
|
+
function makeRoute(partial: Partial<FrontendRouteManifestEntry>): FrontendRouteManifestEntry {
|
|
5
|
+
return {
|
|
6
|
+
moduleId: 'test',
|
|
7
|
+
pattern: '/[orgSlug]/portal/test',
|
|
8
|
+
load: async () => null as any,
|
|
9
|
+
...partial,
|
|
10
|
+
} as FrontendRouteManifestEntry
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('buildPortalNav', () => {
|
|
14
|
+
it('auto-lists portal pages that declare nav metadata', () => {
|
|
15
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
16
|
+
makeRoute({
|
|
17
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
18
|
+
nav: { label: 'Dashboard', labelKey: 'portal.nav.dashboard', group: 'main', order: 10 },
|
|
19
|
+
}),
|
|
20
|
+
makeRoute({
|
|
21
|
+
pattern: '/[orgSlug]/portal/profile',
|
|
22
|
+
nav: { label: 'Profile', labelKey: 'portal.nav.profile', group: 'account', order: 10 },
|
|
23
|
+
}),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
27
|
+
|
|
28
|
+
expect(groups).toEqual([
|
|
29
|
+
{
|
|
30
|
+
id: 'main',
|
|
31
|
+
items: [
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
label: 'Dashboard',
|
|
34
|
+
labelKey: 'portal.nav.dashboard',
|
|
35
|
+
href: '/my-org/portal/dashboard',
|
|
36
|
+
order: 10,
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'account',
|
|
42
|
+
items: [
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
label: 'Profile',
|
|
45
|
+
labelKey: 'portal.nav.profile',
|
|
46
|
+
href: '/my-org/portal/profile',
|
|
47
|
+
order: 10,
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('skips pages without nav metadata', () => {
|
|
55
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
56
|
+
makeRoute({ pattern: '/[orgSlug]/portal/login' }),
|
|
57
|
+
makeRoute({
|
|
58
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
59
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
60
|
+
}),
|
|
61
|
+
]
|
|
62
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
63
|
+
expect(groups).toHaveLength(1)
|
|
64
|
+
expect(groups[0].id).toBe('main')
|
|
65
|
+
expect(groups[0].items.map((i) => i.label)).toEqual(['Dashboard'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips pages the user lacks required features for', () => {
|
|
69
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
70
|
+
makeRoute({
|
|
71
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
72
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
73
|
+
nav: { label: 'Orders', group: 'main' },
|
|
74
|
+
}),
|
|
75
|
+
makeRoute({
|
|
76
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
77
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
78
|
+
}),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
82
|
+
expect(groups).toEqual([
|
|
83
|
+
{ id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
|
|
84
|
+
])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('matches wildcard grants like portal.*', () => {
|
|
88
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
89
|
+
makeRoute({
|
|
90
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
91
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
92
|
+
nav: { label: 'Orders', group: 'main' },
|
|
93
|
+
}),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: ['portal.*'] })
|
|
97
|
+
expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('bypasses feature checks when isPortalAdmin is true', () => {
|
|
101
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
102
|
+
makeRoute({
|
|
103
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
104
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
105
|
+
nav: { label: 'Orders', group: 'main' },
|
|
106
|
+
}),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
|
|
110
|
+
expect(groups[0].items[0].label).toBe('Orders')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('ignores navHidden pages even when nav is declared', () => {
|
|
114
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
115
|
+
makeRoute({
|
|
116
|
+
pattern: '/[orgSlug]/portal/secret',
|
|
117
|
+
navHidden: true,
|
|
118
|
+
nav: { label: 'Secret', group: 'main' },
|
|
119
|
+
}),
|
|
120
|
+
]
|
|
121
|
+
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('skips non-portal routes and dynamic patterns with unresolved params', () => {
|
|
125
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
126
|
+
makeRoute({
|
|
127
|
+
pattern: '/[orgSlug]/portal/orders/[id]',
|
|
128
|
+
nav: { label: 'Order Detail', group: 'main' },
|
|
129
|
+
}),
|
|
130
|
+
makeRoute({
|
|
131
|
+
pattern: '/[orgSlug]/checkout',
|
|
132
|
+
nav: { label: 'Checkout', group: 'main' },
|
|
133
|
+
}),
|
|
134
|
+
]
|
|
135
|
+
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sorts items by order then label', () => {
|
|
139
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
140
|
+
makeRoute({
|
|
141
|
+
pattern: '/[orgSlug]/portal/b',
|
|
142
|
+
nav: { label: 'B', group: 'main', order: 20 },
|
|
143
|
+
}),
|
|
144
|
+
makeRoute({
|
|
145
|
+
pattern: '/[orgSlug]/portal/a',
|
|
146
|
+
nav: { label: 'A', group: 'main', order: 10 },
|
|
147
|
+
}),
|
|
148
|
+
makeRoute({
|
|
149
|
+
pattern: '/[orgSlug]/portal/aa',
|
|
150
|
+
nav: { label: 'Aa', group: 'main', order: 10 },
|
|
151
|
+
}),
|
|
152
|
+
]
|
|
153
|
+
const main = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })[0]
|
|
154
|
+
expect(main.items.map((i) => i.label)).toEqual(['A', 'Aa', 'B'])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('mergePortalSidebarGroupsWithInjected', () => {
|
|
159
|
+
it('dedupes injected items by id', () => {
|
|
160
|
+
const result = mergePortalSidebarGroupsWithInjected(
|
|
161
|
+
[
|
|
162
|
+
{
|
|
163
|
+
id: 'main',
|
|
164
|
+
items: [
|
|
165
|
+
{ id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard', href: '/x/portal/dashboard', order: 10 },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
{
|
|
170
|
+
main: [
|
|
171
|
+
{ id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard (injected)', href: '/x/portal/dashboard' } as any,
|
|
172
|
+
{ id: 'orders-external', label: 'External', href: 'https://external' } as any,
|
|
173
|
+
],
|
|
174
|
+
account: [],
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
expect(result.main).toHaveLength(2)
|
|
178
|
+
expect(result.main[0]).toEqual(expect.objectContaining({ label: 'Dashboard' }))
|
|
179
|
+
expect(result.main[1]).toEqual(expect.objectContaining({ id: 'orders-external' }))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('dedupes injected items by href', () => {
|
|
183
|
+
const result = mergePortalSidebarGroupsWithInjected(
|
|
184
|
+
[
|
|
185
|
+
{
|
|
186
|
+
id: 'main',
|
|
187
|
+
items: [
|
|
188
|
+
{ id: 'portal-nav:/[orgSlug]/portal/profile', label: 'Profile', href: '/x/portal/profile', order: 10 },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
{
|
|
193
|
+
main: [{ id: 'different-id', label: 'Profile', href: '/x/portal/profile' } as any],
|
|
194
|
+
account: [],
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
expect(result.main).toHaveLength(1)
|
|
198
|
+
})
|
|
199
|
+
})
|