@open-mercato/ui 0.6.3-develop.3838.1.7f2657a47c → 0.6.3-develop.3857.1.da89d7530c
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/dist/backend/AppShell.js +2 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +1 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +104 -26
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/upgrades/UpgradeActionBanner.js +17 -7
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +2 -2
- package/dist/backend/utils/api.js +17 -14
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/theme/QueryProvider.js +5 -5
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +3 -2
- package/src/backend/CrudForm.tsx +1 -0
- package/src/backend/__tests__/AppShell.test.tsx +27 -0
- package/src/backend/__tests__/UpgradeActionBanner.test.tsx +146 -0
- package/src/backend/__tests__/nav-utils.test.ts +73 -0
- package/src/backend/inputs/ComboboxInput.tsx +136 -35
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +241 -10
- package/src/backend/upgrades/UpgradeActionBanner.tsx +21 -9
- package/src/backend/utils/__tests__/api.test.ts +27 -2
- package/src/backend/utils/api.ts +34 -19
- package/src/theme/QueryProvider.tsx +7 -6
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
-
import { redirectToSessionRefresh,
|
|
5
|
+
import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from "../backend/utils/api.js";
|
|
6
6
|
function ensureGlobalFetchInterception() {
|
|
7
7
|
if (typeof window === "undefined") return;
|
|
8
8
|
const w = window;
|
|
@@ -15,17 +15,17 @@ const client = new QueryClient({
|
|
|
15
15
|
queryCache: new QueryCache({
|
|
16
16
|
onError: (error) => {
|
|
17
17
|
if (error instanceof UnauthorizedError) redirectToSessionRefresh();
|
|
18
|
-
else if (error instanceof ForbiddenError)
|
|
18
|
+
else if (error instanceof ForbiddenError) notifyForbiddenAccess();
|
|
19
19
|
else if (error?.status === 401) redirectToSessionRefresh();
|
|
20
|
-
else if (error?.status === 403)
|
|
20
|
+
else if (error?.status === 403) notifyForbiddenAccess();
|
|
21
21
|
}
|
|
22
22
|
}),
|
|
23
23
|
mutationCache: new MutationCache({
|
|
24
24
|
onError: (error) => {
|
|
25
25
|
if (error instanceof UnauthorizedError) redirectToSessionRefresh();
|
|
26
|
-
else if (error instanceof ForbiddenError)
|
|
26
|
+
else if (error instanceof ForbiddenError) notifyForbiddenAccess();
|
|
27
27
|
else if (error?.status === 401) redirectToSessionRefresh();
|
|
28
|
-
else if (error?.status === 403)
|
|
28
|
+
else if (error?.status === 403) notifyForbiddenAccess();
|
|
29
29
|
}
|
|
30
30
|
})
|
|
31
31
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/theme/QueryProvider.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { redirectToSessionRefresh,
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'\n\n// Ensure global fetch calls also flow through apiFetch so 401 session-refresh\n// and 403 access-denied flash banners fire consistently.\nfunction ensureGlobalFetchInterception() {\n if (typeof window === 'undefined') return\n const w = window as any\n if (w.__omFetchPatched) return\n w.__omFetchPatched = true\n w.__omOriginalFetch = window.fetch\n window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => apiFetch(input, init)) as any\n}\n\nconst client = new QueryClient({\n queryCache: new QueryCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n // As a fallback, try to detect common cases\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n mutationCache: new MutationCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n})\n\ntype QueryProviderProps = { children: React.ReactNode; defaultForbiddenRoles?: string[] }\n\nexport function QueryProvider({ children, defaultForbiddenRoles }: QueryProviderProps) {\n React.useEffect(() => {\n ensureGlobalFetchInterception()\n if (defaultForbiddenRoles && defaultForbiddenRoles.length) {\n setAuthRedirectConfig({ defaultForbiddenRoles })\n }\n }, [])\n return <QueryClientProvider client={client}>{children}</QueryClientProvider>\n}\n"],
|
|
5
|
+
"mappings": ";AA6CS;AA5CT,YAAY,WAAW;AACvB,SAAS,eAAe,YAAY,aAAa,2BAA2B;AAC5E,SAAS,0BAA0B,uBAAuB,mBAAmB,gBAAgB,UAAU,6BAA6B;AAIpI,SAAS,gCAAgC;AACvC,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,IAAI;AACV,MAAI,EAAE,iBAAkB;AACxB,IAAE,mBAAmB;AACrB,IAAE,oBAAoB,OAAO;AAC7B,SAAO,SAAS,CAAC,OAA0B,SAAuB,SAAS,OAAO,IAAI;AACxF;AAEA,MAAM,SAAS,IAAI,YAAY;AAAA,EAC7B,YAAY,IAAI,WAAW;AAAA,IACzB,SAAS,CAAC,UAAU;AAClB,UAAI,iBAAiB,kBAAmB,0BAAyB;AAAA,eACxD,iBAAiB,eAAgB,uBAAsB;AAAA,eAEtD,OAAe,WAAW,IAAK,0BAAyB;AAAA,eACxD,OAAe,WAAW,IAAK,uBAAsB;AAAA,IACjE;AAAA,EACF,CAAC;AAAA,EACD,eAAe,IAAI,cAAc;AAAA,IAC/B,SAAS,CAAC,UAAU;AAClB,UAAI,iBAAiB,kBAAmB,0BAAyB;AAAA,eACxD,iBAAiB,eAAgB,uBAAsB;AAAA,eACtD,OAAe,WAAW,IAAK,0BAAyB;AAAA,eACxD,OAAe,WAAW,IAAK,uBAAsB;AAAA,IACjE;AAAA,EACF,CAAC;AACH,CAAC;AAIM,SAAS,cAAc,EAAE,UAAU,sBAAsB,GAAuB;AACrF,QAAM,UAAU,MAAM;AACpB,kCAA8B;AAC9B,QAAI,yBAAyB,sBAAsB,QAAQ;AACzD,4BAAsB,EAAE,sBAAsB,CAAC;AAAA,IACjD;AAAA,EACF,GAAG,CAAC,CAAC;AACL,SAAO,oBAAC,uBAAoB,QAAiB,UAAS;AACxD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3857.1.da89d7530c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -141,13 +141,13 @@
|
|
|
141
141
|
"remark-gfm": "^4.0.1"
|
|
142
142
|
},
|
|
143
143
|
"peerDependencies": {
|
|
144
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
144
|
+
"@open-mercato/shared": "0.6.3-develop.3857.1.da89d7530c",
|
|
145
145
|
"react": ">=18.0.0",
|
|
146
146
|
"react-dom": ">=18.0.0",
|
|
147
147
|
"react-is": ">=18.0.0"
|
|
148
148
|
},
|
|
149
149
|
"devDependencies": {
|
|
150
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
150
|
+
"@open-mercato/shared": "0.6.3-develop.3857.1.da89d7530c",
|
|
151
151
|
"@testing-library/dom": "^10.4.1",
|
|
152
152
|
"@testing-library/jest-dom": "^6.9.1",
|
|
153
153
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -66,6 +66,7 @@ export type AppShellProps = {
|
|
|
66
66
|
productName?: string
|
|
67
67
|
logo?: ShellLogo
|
|
68
68
|
email?: string
|
|
69
|
+
canManageUpgradeActions?: boolean
|
|
69
70
|
groups: {
|
|
70
71
|
id?: string
|
|
71
72
|
name: string
|
|
@@ -416,7 +417,7 @@ export function AppShell(props: AppShellProps) {
|
|
|
416
417
|
)
|
|
417
418
|
}
|
|
418
419
|
|
|
419
|
-
function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot }: AppShellProps) {
|
|
420
|
+
function AppShellBody({ productName, logo, email, canManageUpgradeActions = false, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot }: AppShellProps) {
|
|
420
421
|
const pathname = usePathname()
|
|
421
422
|
const searchParams = useSearchParams()
|
|
422
423
|
const t = useT()
|
|
@@ -1341,7 +1342,7 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
|
|
|
1341
1342
|
<InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1342
1343
|
<FlashMessages />
|
|
1343
1344
|
<PartialIndexBanner />
|
|
1344
|
-
<UpgradeActionBanner />
|
|
1345
|
+
{canManageUpgradeActions ? <UpgradeActionBanner /> : null}
|
|
1345
1346
|
<LastOperationBanner />
|
|
1346
1347
|
<InjectionSpot spotId={BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1347
1348
|
<InjectionSpot
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -243,6 +243,33 @@ describe('AppShell', () => {
|
|
|
243
243
|
})
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
+
it('renders the upgrade action banner only for users who can manage upgrade actions', () => {
|
|
247
|
+
const { rerender } = renderWithProviders(
|
|
248
|
+
<AppShell
|
|
249
|
+
email="demo@example.com"
|
|
250
|
+
groups={groups}
|
|
251
|
+
canManageUpgradeActions={false}
|
|
252
|
+
>
|
|
253
|
+
<div>Child content</div>
|
|
254
|
+
</AppShell>,
|
|
255
|
+
{ dict },
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
expect(screen.queryByTestId('upgrade-action-banner')).not.toBeInTheDocument()
|
|
259
|
+
|
|
260
|
+
rerender(
|
|
261
|
+
<AppShell
|
|
262
|
+
email="demo@example.com"
|
|
263
|
+
groups={groups}
|
|
264
|
+
canManageUpgradeActions
|
|
265
|
+
>
|
|
266
|
+
<div>Child content</div>
|
|
267
|
+
</AppShell>,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
expect(screen.getByTestId('upgrade-action-banner')).toBeInTheDocument()
|
|
271
|
+
})
|
|
272
|
+
|
|
246
273
|
it('resets breadcrumb to server-provided values when pathname changes', async () => {
|
|
247
274
|
mockPathname = '/backend/users'
|
|
248
275
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
import { UpgradeActionBanner } from '../upgrades/UpgradeActionBanner'
|
|
8
|
+
import { useBackendChrome } from '../BackendChromeProvider'
|
|
9
|
+
import { apiCall } from '../utils/apiCall'
|
|
10
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
11
|
+
|
|
12
|
+
jest.mock('../utils/apiCall', () => ({
|
|
13
|
+
apiCall: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('../FlashMessages', () => ({
|
|
17
|
+
flash: jest.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.mock('../BackendChromeProvider', () => ({
|
|
21
|
+
BackendChromeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
22
|
+
useBackendChrome: jest.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const UPGRADE_ACTIONS_RESPONSE = {
|
|
26
|
+
version: '0.3.4',
|
|
27
|
+
actions: [
|
|
28
|
+
{
|
|
29
|
+
id: 'seed-catalog',
|
|
30
|
+
version: '0.3.4',
|
|
31
|
+
message: 'Install example products',
|
|
32
|
+
ctaLabel: 'Install now',
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockChrome(opts: { isReady?: boolean; grantedFeatures?: string[] } = {}) {
|
|
38
|
+
const isReady = opts.isReady ?? true
|
|
39
|
+
;(useBackendChrome as jest.Mock).mockReturnValue({
|
|
40
|
+
payload: opts.grantedFeatures !== undefined ? { grantedFeatures: opts.grantedFeatures } : null,
|
|
41
|
+
isReady,
|
|
42
|
+
isLoading: !isReady,
|
|
43
|
+
refresh: jest.fn(),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
if (typeof globalThis.fetch === 'undefined') {
|
|
49
|
+
Object.defineProperty(globalThis, 'fetch', { value: jest.fn(), writable: true, configurable: true })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
jest.clearAllMocks()
|
|
55
|
+
process.env.NEXT_PUBLIC_UPGRADE_ACTIONS_ENABLED = 'true'
|
|
56
|
+
mockChrome()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
delete process.env.NEXT_PUBLIC_UPGRADE_ACTIONS_ENABLED
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('UpgradeActionBanner — feature guard', () => {
|
|
64
|
+
it('renders null and does not call apiCall when payload is null (no configs.manage)', async () => {
|
|
65
|
+
;(apiCall as jest.Mock).mockResolvedValue({ ok: true, result: UPGRADE_ACTIONS_RESPONSE })
|
|
66
|
+
|
|
67
|
+
renderWithProviders(<UpgradeActionBanner />)
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(screen.queryByText('Install now')).toBeNull()
|
|
74
|
+
expect(apiCall).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders null and does not call apiCall when grantedFeatures is empty (no configs.manage)', async () => {
|
|
78
|
+
mockChrome({ isReady: true, grantedFeatures: [] })
|
|
79
|
+
;(apiCall as jest.Mock).mockResolvedValue({ ok: true, result: UPGRADE_ACTIONS_RESPONSE })
|
|
80
|
+
|
|
81
|
+
renderWithProviders(<UpgradeActionBanner />)
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(screen.queryByText('Install now')).toBeNull()
|
|
88
|
+
expect(apiCall).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('calls apiCall and renders banner when grantedFeatures includes configs.manage', async () => {
|
|
92
|
+
mockChrome({ isReady: true, grantedFeatures: ['configs.manage'] })
|
|
93
|
+
;(apiCall as jest.Mock).mockResolvedValue({ ok: true, result: UPGRADE_ACTIONS_RESPONSE })
|
|
94
|
+
|
|
95
|
+
renderWithProviders(<UpgradeActionBanner />)
|
|
96
|
+
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(apiCall).toHaveBeenCalledWith(
|
|
99
|
+
'/api/configs/upgrade-actions',
|
|
100
|
+
{ headers: { 'x-om-forbidden-redirect': '0' } },
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(screen.getByRole('button', { name: 'Install now' })).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('calls apiCall when grantedFeatures includes configs.* wildcard', async () => {
|
|
110
|
+
mockChrome({ isReady: true, grantedFeatures: ['configs.*'] })
|
|
111
|
+
;(apiCall as jest.Mock).mockResolvedValue({ ok: true, result: UPGRADE_ACTIONS_RESPONSE })
|
|
112
|
+
|
|
113
|
+
renderWithProviders(<UpgradeActionBanner />)
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(apiCall).toHaveBeenCalledWith(
|
|
117
|
+
'/api/configs/upgrade-actions',
|
|
118
|
+
{ headers: { 'x-om-forbidden-redirect': '0' } },
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not call apiCall while chrome is still loading (isReady=false), then fires after ready', async () => {
|
|
124
|
+
mockChrome({ isReady: false })
|
|
125
|
+
;(apiCall as jest.Mock).mockResolvedValue({ ok: true, result: UPGRADE_ACTIONS_RESPONSE })
|
|
126
|
+
|
|
127
|
+
const { rerender } = renderWithProviders(<UpgradeActionBanner />)
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(screen.queryByText('Install now')).toBeNull()
|
|
134
|
+
expect(apiCall).not.toHaveBeenCalled()
|
|
135
|
+
|
|
136
|
+
mockChrome({ isReady: true, grantedFeatures: ['configs.manage'] })
|
|
137
|
+
rerender(<UpgradeActionBanner />)
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(apiCall).toHaveBeenCalledWith(
|
|
141
|
+
'/api/configs/upgrade-actions',
|
|
142
|
+
{ headers: { 'x-om-forbidden-redirect': '0' } },
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -129,4 +129,77 @@ describe('settings navigation helpers', () => {
|
|
|
129
129
|
|
|
130
130
|
expect(entries.map((item) => item.href)).toContain('/backend/customer_accounts/users')
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
// Regression for GH #2070: an employee without any `configs.*` grant must not
|
|
134
|
+
// see the system-status page in the sidebar (including settings sections),
|
|
135
|
+
// even when modules with adjacent prefixes are granted.
|
|
136
|
+
it('hides routes whose required features are not granted, including settings-context pages', async () => {
|
|
137
|
+
const employeeGrants = [
|
|
138
|
+
'sales.orders.view',
|
|
139
|
+
'sales.quotes.view',
|
|
140
|
+
'inbox_ops.proposals.view',
|
|
141
|
+
]
|
|
142
|
+
const entries = await buildAdminNav(
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
id: 'sales',
|
|
146
|
+
backendRoutes: [
|
|
147
|
+
{
|
|
148
|
+
pattern: '/backend/sales/orders',
|
|
149
|
+
title: 'Orders',
|
|
150
|
+
requireFeatures: ['sales.orders.view'],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
pattern: '/backend/sales/quotes',
|
|
154
|
+
title: 'Quotes',
|
|
155
|
+
requireFeatures: ['sales.quotes.view'],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'inbox_ops',
|
|
161
|
+
backendRoutes: [
|
|
162
|
+
{
|
|
163
|
+
pattern: '/backend/inbox-ops',
|
|
164
|
+
title: 'Proposals',
|
|
165
|
+
requireFeatures: ['inbox_ops.proposals.view'],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'configs',
|
|
171
|
+
backendRoutes: [
|
|
172
|
+
{
|
|
173
|
+
pattern: '/backend/config/system-status',
|
|
174
|
+
title: 'System status',
|
|
175
|
+
requireFeatures: ['configs.system_status.view'],
|
|
176
|
+
pageContext: 'settings',
|
|
177
|
+
group: 'System',
|
|
178
|
+
groupKey: 'settings.sections.system',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
{ auth: { roles: [] } },
|
|
184
|
+
undefined,
|
|
185
|
+
undefined,
|
|
186
|
+
{
|
|
187
|
+
checkFeatures: async (features: string[]) =>
|
|
188
|
+
features.filter((feature) => employeeGrants.includes(feature)),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const hrefs = entries.map((item) => item.href)
|
|
193
|
+
expect(hrefs).toContain('/backend/sales/orders')
|
|
194
|
+
expect(hrefs).toContain('/backend/sales/quotes')
|
|
195
|
+
expect(hrefs).toContain('/backend/inbox-ops')
|
|
196
|
+
expect(hrefs).not.toContain('/backend/config/system-status')
|
|
197
|
+
|
|
198
|
+
// The filtered settings entry must also be absent from any settings section.
|
|
199
|
+
const settingsSections = buildSettingsSections(entries, { system: 1 })
|
|
200
|
+
const allSettingsHrefs = settingsSections.flatMap((section) =>
|
|
201
|
+
section.items.map((item) => item.href),
|
|
202
|
+
)
|
|
203
|
+
expect(allSettingsHrefs).not.toContain('/backend/config/system-status')
|
|
204
|
+
})
|
|
132
205
|
})
|