@open-mercato/ui 0.4.5-develop-6bdcebbece → 0.4.5-develop-986cfd8c37
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/AGENTS.md +8 -0
- package/dist/backend/AppShell.js +395 -134
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +232 -21
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +214 -94
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +74 -4
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/backend/injection/SseEventIndicator.js +16 -0
- package/dist/backend/injection/SseEventIndicator.js.map +7 -0
- package/dist/backend/injection/WidgetSharedState.js +49 -0
- package/dist/backend/injection/WidgetSharedState.js.map +7 -0
- package/dist/backend/injection/eventBridge.js +105 -0
- package/dist/backend/injection/eventBridge.js.map +7 -0
- package/dist/backend/injection/mergeMenuItems.js +43 -0
- package/dist/backend/injection/mergeMenuItems.js.map +7 -0
- package/dist/backend/injection/resolveInjectedIcon.js +23 -0
- package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
- package/dist/backend/injection/spotIds.js +40 -1
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/injection/useAppEvent.js +35 -0
- package/dist/backend/injection/useAppEvent.js.map +7 -0
- package/dist/backend/injection/useInjectedMenuItems.js +92 -0
- package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
- package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
- package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
- package/dist/backend/injection/useOperationProgress.js +64 -0
- package/dist/backend/injection/useOperationProgress.js.map +7 -0
- package/dist/backend/injection/useWidgetSharedState.js +26 -0
- package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
- package/dist/backend/section-page/SectionNav.js +22 -2
- package/dist/backend/section-page/SectionNav.js.map +2 -2
- package/dist/backend/utils/api.js +9 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/package.json +2 -2
- package/src/backend/AGENTS.md +50 -0
- package/src/backend/AppShell.tsx +317 -30
- package/src/backend/CrudForm.tsx +238 -21
- package/src/backend/ProfileDropdown.tsx +199 -78
- package/src/backend/injection/InjectionSpot.tsx +118 -16
- package/src/backend/injection/SseEventIndicator.tsx +24 -0
- package/src/backend/injection/WidgetSharedState.ts +58 -0
- package/src/backend/injection/eventBridge.ts +134 -0
- package/src/backend/injection/mergeMenuItems.ts +71 -0
- package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
- package/src/backend/injection/spotIds.ts +38 -0
- package/src/backend/injection/useAppEvent.ts +76 -0
- package/src/backend/injection/useInjectedMenuItems.ts +125 -0
- package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
- package/src/backend/injection/useOperationProgress.ts +105 -0
- package/src/backend/injection/useWidgetSharedState.ts +28 -0
- package/src/backend/section-page/SectionNav.tsx +22 -1
- package/src/backend/utils/api.ts +14 -5
package/src/backend/AppShell.tsx
CHANGED
|
@@ -17,13 +17,24 @@ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
17
17
|
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
18
18
|
import type { SectionNavGroup } from './section-page/types'
|
|
19
19
|
import { InjectionSpot } from './injection/InjectionSpot'
|
|
20
|
+
import type { InjectionMenuItem } from '@open-mercato/shared/modules/widgets/injection'
|
|
20
21
|
import { LEGACY_GLOBAL_MUTATION_INJECTION_SPOT_ID } from './injection/mutationEvents'
|
|
22
|
+
import { mergeMenuItems } from './injection/mergeMenuItems'
|
|
23
|
+
import { useInjectedMenuItems } from './injection/useInjectedMenuItems'
|
|
24
|
+
import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
25
|
+
import { useEventBridge } from './injection/eventBridge'
|
|
26
|
+
import { SseEventIndicator } from './injection/SseEventIndicator'
|
|
21
27
|
import {
|
|
22
28
|
BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID,
|
|
23
29
|
BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID,
|
|
24
30
|
BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID,
|
|
25
31
|
BACKEND_SIDEBAR_FOOTER_INJECTION_SPOT_ID,
|
|
26
32
|
BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID,
|
|
33
|
+
BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID,
|
|
34
|
+
BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID,
|
|
35
|
+
BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID,
|
|
36
|
+
GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID,
|
|
37
|
+
GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID,
|
|
27
38
|
} from './injection/spotIds'
|
|
28
39
|
|
|
29
40
|
export type AppShellProps = {
|
|
@@ -34,6 +45,7 @@ export type AppShellProps = {
|
|
|
34
45
|
name: string
|
|
35
46
|
defaultName?: string
|
|
36
47
|
items: {
|
|
48
|
+
id?: string
|
|
37
49
|
href: string
|
|
38
50
|
title: string
|
|
39
51
|
defaultTitle?: string
|
|
@@ -42,6 +54,7 @@ export type AppShellProps = {
|
|
|
42
54
|
hidden?: boolean
|
|
43
55
|
pageContext?: 'main' | 'admin' | 'settings' | 'profile'
|
|
44
56
|
children?: {
|
|
57
|
+
id?: string
|
|
45
58
|
href: string
|
|
46
59
|
title: string
|
|
47
60
|
defaultTitle?: string
|
|
@@ -82,12 +95,197 @@ type SidebarGroup = AppShellProps['groups'][number]
|
|
|
82
95
|
type SidebarItem = SidebarGroup['items'][number]
|
|
83
96
|
type SidebarRoleTarget = { id: string; name: string; hasPreference: boolean }
|
|
84
97
|
|
|
98
|
+
function convertInjectedMenuItemToSidebarItem(item: InjectionMenuItem, title: string): SidebarItem | null {
|
|
99
|
+
if (!item.href) return null
|
|
100
|
+
return {
|
|
101
|
+
id: item.id,
|
|
102
|
+
href: item.href,
|
|
103
|
+
title,
|
|
104
|
+
defaultTitle: title,
|
|
105
|
+
icon: resolveInjectedIcon(item.icon) ?? undefined,
|
|
106
|
+
enabled: true,
|
|
107
|
+
hidden: false,
|
|
108
|
+
pageContext: 'main',
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveInjectedMenuLabel(
|
|
113
|
+
item: { id: string; label?: string; labelKey?: string },
|
|
114
|
+
t: (key: string, fallback?: string) => string,
|
|
115
|
+
): string {
|
|
116
|
+
if (item.labelKey && item.label) return t(item.labelKey, item.label)
|
|
117
|
+
if (item.labelKey) return t(item.labelKey, item.id)
|
|
118
|
+
if (item.label && item.label.includes('.')) return t(item.label, item.id)
|
|
119
|
+
return item.label ?? item.id
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mergeSidebarItemsWithInjected(
|
|
123
|
+
items: SidebarItem[],
|
|
124
|
+
injectedItems: InjectionMenuItem[],
|
|
125
|
+
t: (key: string, fallback?: string) => string,
|
|
126
|
+
): SidebarItem[] {
|
|
127
|
+
if (injectedItems.length === 0) return items
|
|
128
|
+
|
|
129
|
+
const builtInById = new Map<string, SidebarItem>()
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
builtInById.set(item.id ?? item.href, item)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const merged = mergeMenuItems(
|
|
135
|
+
items.map((item) => ({
|
|
136
|
+
id: item.id ?? item.href,
|
|
137
|
+
})),
|
|
138
|
+
injectedItems,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const result: SidebarItem[] = []
|
|
142
|
+
for (const entry of merged) {
|
|
143
|
+
if (entry.source === 'built-in') {
|
|
144
|
+
const original = builtInById.get(entry.id)
|
|
145
|
+
if (original) result.push(original)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const translatedLabel = resolveInjectedMenuLabel(
|
|
149
|
+
{ id: entry.id, label: entry.label, labelKey: entry.labelKey },
|
|
150
|
+
t,
|
|
151
|
+
)
|
|
152
|
+
const converted = convertInjectedMenuItemToSidebarItem(
|
|
153
|
+
{
|
|
154
|
+
id: entry.id,
|
|
155
|
+
label: translatedLabel,
|
|
156
|
+
icon: entry.icon,
|
|
157
|
+
href: entry.href,
|
|
158
|
+
},
|
|
159
|
+
translatedLabel,
|
|
160
|
+
)
|
|
161
|
+
if (converted) result.push(converted)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function mergeSidebarGroupsWithInjected(
|
|
168
|
+
groups: SidebarGroup[],
|
|
169
|
+
injectedItems: InjectionMenuItem[],
|
|
170
|
+
t: (key: string, fallback?: string) => string,
|
|
171
|
+
): SidebarGroup[] {
|
|
172
|
+
if (injectedItems.length === 0) return groups
|
|
173
|
+
|
|
174
|
+
const injectedByGroup = new Map<string, InjectionMenuItem[]>()
|
|
175
|
+
const ungrouped: InjectionMenuItem[] = []
|
|
176
|
+
|
|
177
|
+
for (const item of injectedItems) {
|
|
178
|
+
if (item.groupId && item.groupId.trim().length > 0) {
|
|
179
|
+
const groupItems = injectedByGroup.get(item.groupId) ?? []
|
|
180
|
+
groupItems.push(item)
|
|
181
|
+
injectedByGroup.set(item.groupId, groupItems)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
ungrouped.push(item)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const nextGroups = groups.map((group, index) => {
|
|
188
|
+
const groupId = group.id || resolveGroupKey(group)
|
|
189
|
+
const groupInjected = [
|
|
190
|
+
...(injectedByGroup.get(groupId) ?? []),
|
|
191
|
+
...(index === 0 ? ungrouped : []),
|
|
192
|
+
]
|
|
193
|
+
return {
|
|
194
|
+
...group,
|
|
195
|
+
items: mergeSidebarItemsWithInjected(group.items, groupInjected, t),
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const existingIds = new Set(nextGroups.map((group) => group.id || resolveGroupKey(group)))
|
|
200
|
+
for (const [groupId, items] of injectedByGroup.entries()) {
|
|
201
|
+
if (existingIds.has(groupId)) continue
|
|
202
|
+
const first = items[0]
|
|
203
|
+
const label = first.groupLabelKey
|
|
204
|
+
? t(first.groupLabelKey, first.groupLabel ?? groupId)
|
|
205
|
+
: (first.groupLabel ?? groupId)
|
|
206
|
+
const groupItems = mergeSidebarItemsWithInjected([], items, t)
|
|
207
|
+
if (groupItems.length === 0) continue
|
|
208
|
+
nextGroups.push({
|
|
209
|
+
id: groupId,
|
|
210
|
+
name: label,
|
|
211
|
+
defaultName: label,
|
|
212
|
+
items: groupItems,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return nextGroups
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function mergeSectionGroupsWithInjected(
|
|
220
|
+
sections: SectionNavGroup[],
|
|
221
|
+
injectedItems: InjectionMenuItem[],
|
|
222
|
+
t: (key: string, fallback?: string) => string,
|
|
223
|
+
): SectionNavGroup[] {
|
|
224
|
+
if (injectedItems.length === 0) return sections
|
|
225
|
+
const byGroup = new Map<string, InjectionMenuItem[]>()
|
|
226
|
+
for (const item of injectedItems) {
|
|
227
|
+
const groupId = item.groupId && item.groupId.trim().length > 0 ? item.groupId : 'injected'
|
|
228
|
+
const bucket = byGroup.get(groupId) ?? []
|
|
229
|
+
bucket.push(item)
|
|
230
|
+
byGroup.set(groupId, bucket)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const nextSections = sections.map((section) => {
|
|
234
|
+
const sectionItems = byGroup.get(section.id) ?? []
|
|
235
|
+
if (sectionItems.length === 0) return section
|
|
236
|
+
const mergedItems = mergeMenuItems(
|
|
237
|
+
section.items.map((item) => ({ id: item.id, item })),
|
|
238
|
+
sectionItems,
|
|
239
|
+
).flatMap((item) => {
|
|
240
|
+
if (item.source === 'built-in') {
|
|
241
|
+
const original = section.items.find((entry) => entry.id === item.id)
|
|
242
|
+
return original ? [original] : []
|
|
243
|
+
}
|
|
244
|
+
if (!item.href) return []
|
|
245
|
+
const label = resolveInjectedMenuLabel(item, t)
|
|
246
|
+
return [{
|
|
247
|
+
id: item.id,
|
|
248
|
+
label,
|
|
249
|
+
href: item.href,
|
|
250
|
+
}]
|
|
251
|
+
})
|
|
252
|
+
return {
|
|
253
|
+
...section,
|
|
254
|
+
items: mergedItems,
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
for (const [sectionId, sectionItems] of byGroup.entries()) {
|
|
259
|
+
const exists = nextSections.some((section) => section.id === sectionId)
|
|
260
|
+
if (exists) continue
|
|
261
|
+
const first = sectionItems[0]
|
|
262
|
+
const label = first.groupLabelKey
|
|
263
|
+
? t(first.groupLabelKey, first.groupLabel ?? sectionId)
|
|
264
|
+
: (first.groupLabel ?? sectionId)
|
|
265
|
+
const items = sectionItems.flatMap((item) => {
|
|
266
|
+
if (!item.href) return []
|
|
267
|
+
const itemLabel = resolveInjectedMenuLabel(item, t)
|
|
268
|
+
return [{ id: item.id, label: itemLabel, href: item.href }]
|
|
269
|
+
})
|
|
270
|
+
if (items.length === 0) continue
|
|
271
|
+
nextSections.push({ id: sectionId, label, items })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return nextSections
|
|
275
|
+
}
|
|
276
|
+
|
|
85
277
|
function resolveGroupKey(group: SidebarGroup): string {
|
|
86
278
|
if (group.id && group.id.length) return group.id
|
|
87
279
|
if (group.defaultName && group.defaultName.length) return slugifySidebarId(group.defaultName)
|
|
88
280
|
return slugifySidebarId(group.name)
|
|
89
281
|
}
|
|
90
282
|
|
|
283
|
+
function resolveItemKey(item: { id?: string; href: string }): string {
|
|
284
|
+
const candidate = item.id?.trim()
|
|
285
|
+
if (candidate && candidate.length > 0) return candidate
|
|
286
|
+
return item.href
|
|
287
|
+
}
|
|
288
|
+
|
|
91
289
|
const HeaderContext = React.createContext<{
|
|
92
290
|
setBreadcrumb: (b?: Breadcrumb) => void
|
|
93
291
|
setTitle: (t?: string) => void
|
|
@@ -161,6 +359,11 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
161
359
|
const searchParams = useSearchParams()
|
|
162
360
|
const t = useT()
|
|
163
361
|
const locale = useLocale()
|
|
362
|
+
const { items: mainSidebarInjectedMenuItems } = useInjectedMenuItems('menu:sidebar:main')
|
|
363
|
+
const { items: settingsSidebarInjectedMenuItems } = useInjectedMenuItems('menu:sidebar:settings')
|
|
364
|
+
const { items: profileSidebarInjectedMenuItems } = useInjectedMenuItems('menu:sidebar:profile')
|
|
365
|
+
const { items: topbarInjectedMenuItems } = useInjectedMenuItems('menu:topbar:actions')
|
|
366
|
+
useEventBridge() // SSE DOM Event Bridge — singleton SSE connection for real-time server events
|
|
164
367
|
const resolvedProductName = productName ?? t('appShell.productName')
|
|
165
368
|
const [mobileOpen, setMobileOpen] = React.useState(false)
|
|
166
369
|
// Initialize from server-provided prop only to avoid hydration flicker
|
|
@@ -208,6 +411,11 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
208
411
|
isOnProfilePath ? 'profile' :
|
|
209
412
|
'main'
|
|
210
413
|
|
|
414
|
+
const mainNavGroupsWithInjected = React.useMemo(
|
|
415
|
+
() => mergeSidebarGroupsWithInjected(navGroups, mainSidebarInjectedMenuItems, t),
|
|
416
|
+
[mainSidebarInjectedMenuItems, navGroups, t],
|
|
417
|
+
)
|
|
418
|
+
|
|
211
419
|
// Lock body scroll when mobile drawer is open so touch scroll stays in the drawer
|
|
212
420
|
React.useEffect(() => {
|
|
213
421
|
if (!mobileOpen || typeof document === 'undefined') return
|
|
@@ -288,8 +496,8 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
288
496
|
}
|
|
289
497
|
const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems)
|
|
290
498
|
? rawSettings.hiddenItems
|
|
291
|
-
.map((
|
|
292
|
-
.filter((
|
|
499
|
+
.map((itemId: unknown) => (typeof itemId === 'string' ? itemId.trim() : ''))
|
|
500
|
+
.filter((itemId: string) => itemId.length > 0)
|
|
293
501
|
: []
|
|
294
502
|
const canManageRoles = data?.canApplyToRoles === true
|
|
295
503
|
setCanApplyToRoles(canManageRoles)
|
|
@@ -312,9 +520,9 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
312
520
|
const order = mergeGroupOrder(responseOrder, currentIds)
|
|
313
521
|
const { itemDefaults } = collectSidebarDefaults(baseSnapshot)
|
|
314
522
|
const hiddenItemIds: Record<string, boolean> = {}
|
|
315
|
-
for (const
|
|
316
|
-
if (!itemDefaults.has(
|
|
317
|
-
hiddenItemIds[
|
|
523
|
+
for (const itemId of responseHiddenItems) {
|
|
524
|
+
if (!itemDefaults.has(itemId)) continue
|
|
525
|
+
hiddenItemIds[itemId] = true
|
|
318
526
|
}
|
|
319
527
|
const draft: SidebarCustomizationDraft = {
|
|
320
528
|
order,
|
|
@@ -375,17 +583,17 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
375
583
|
if (trimmed !== base) sanitizedGroupLabels[key] = trimmed
|
|
376
584
|
}
|
|
377
585
|
const sanitizedItemLabels: Record<string, string> = {}
|
|
378
|
-
for (const [
|
|
586
|
+
for (const [itemId, value] of Object.entries(customDraft.itemLabels)) {
|
|
379
587
|
const trimmed = value.trim()
|
|
380
|
-
const base = itemDefaults.get(
|
|
588
|
+
const base = itemDefaults.get(itemId)
|
|
381
589
|
if (!trimmed || !base) continue
|
|
382
|
-
if (trimmed !== base) sanitizedItemLabels[
|
|
590
|
+
if (trimmed !== base) sanitizedItemLabels[itemId] = trimmed
|
|
383
591
|
}
|
|
384
592
|
const sanitizedHiddenItems: string[] = []
|
|
385
|
-
for (const [
|
|
593
|
+
for (const [itemId, hidden] of Object.entries(customDraft.hiddenItemIds)) {
|
|
386
594
|
if (!hidden) continue
|
|
387
|
-
if (!itemDefaults.has(
|
|
388
|
-
sanitizedHiddenItems.push(
|
|
595
|
+
if (!itemDefaults.has(itemId)) continue
|
|
596
|
+
sanitizedHiddenItems.push(itemId)
|
|
389
597
|
}
|
|
390
598
|
const applyToRolesPayload = canApplyToRoles ? [...selectedRoleIds] : []
|
|
391
599
|
const clearRoleIdsPayload = canApplyToRoles
|
|
@@ -463,19 +671,19 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
463
671
|
})
|
|
464
672
|
}, [updateDraft])
|
|
465
673
|
|
|
466
|
-
const setItemLabel = React.useCallback((
|
|
674
|
+
const setItemLabel = React.useCallback((itemId: string, value: string) => {
|
|
467
675
|
updateDraft((draft) => {
|
|
468
676
|
const next = { ...draft.itemLabels }
|
|
469
|
-
if (value.trim().length === 0) delete next[
|
|
470
|
-
else next[
|
|
677
|
+
if (value.trim().length === 0) delete next[itemId]
|
|
678
|
+
else next[itemId] = value
|
|
471
679
|
return { ...draft, itemLabels: next }
|
|
472
680
|
})
|
|
473
681
|
}, [updateDraft])
|
|
474
|
-
const setItemHidden = React.useCallback((
|
|
682
|
+
const setItemHidden = React.useCallback((itemId: string, hidden: boolean) => {
|
|
475
683
|
updateDraft((draft) => {
|
|
476
684
|
const next = { ...draft.hiddenItemIds }
|
|
477
|
-
if (hidden) next[
|
|
478
|
-
else delete next[
|
|
685
|
+
if (hidden) next[itemId] = true
|
|
686
|
+
else delete next[itemId]
|
|
479
687
|
return { ...draft, hiddenItemIds: next }
|
|
480
688
|
})
|
|
481
689
|
}, [updateDraft])
|
|
@@ -710,6 +918,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
710
918
|
}`}
|
|
711
919
|
style={spacingStyle}
|
|
712
920
|
title={compact ? label : undefined}
|
|
921
|
+
data-menu-item-id={item.id}
|
|
713
922
|
onClick={() => setMobileOpen(false)}
|
|
714
923
|
>
|
|
715
924
|
{isActive && (
|
|
@@ -753,8 +962,13 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
753
962
|
|
|
754
963
|
function renderSidebar(compact: boolean, hideHeader?: boolean) {
|
|
755
964
|
if (sidebarMode === 'settings' && settingsSections && settingsSections.length > 0) {
|
|
756
|
-
|
|
965
|
+
const mergedSettingsSections = mergeSectionGroupsWithInjected(
|
|
757
966
|
settingsSections,
|
|
967
|
+
settingsSidebarInjectedMenuItems,
|
|
968
|
+
t,
|
|
969
|
+
)
|
|
970
|
+
return renderSectionSidebar(
|
|
971
|
+
mergedSettingsSections,
|
|
758
972
|
settingsSectionTitle ?? t('backend.nav.settings', 'Settings'),
|
|
759
973
|
compact,
|
|
760
974
|
hideHeader
|
|
@@ -762,8 +976,13 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
762
976
|
}
|
|
763
977
|
|
|
764
978
|
if (sidebarMode === 'profile' && profileSections && profileSections.length > 0) {
|
|
765
|
-
|
|
979
|
+
const mergedProfileSections = mergeSectionGroupsWithInjected(
|
|
766
980
|
profileSections,
|
|
981
|
+
profileSidebarInjectedMenuItems,
|
|
982
|
+
t,
|
|
983
|
+
)
|
|
984
|
+
return renderSectionSidebar(
|
|
985
|
+
mergedProfileSections,
|
|
767
986
|
profileSectionTitle ?? t('backend.nav.profile', 'Profile'),
|
|
768
987
|
compact,
|
|
769
988
|
hideHeader
|
|
@@ -772,7 +991,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
772
991
|
|
|
773
992
|
const isMobileVariant = !!hideHeader
|
|
774
993
|
const shouldRenderSidebarInjectionSpots = !isMobileVariant
|
|
775
|
-
const baseGroupsForDefaults = originalNavRef.current ??
|
|
994
|
+
const baseGroupsForDefaults = originalNavRef.current ?? mainNavGroupsWithInjected
|
|
776
995
|
const baseGroupMap = new Map<string, SidebarGroup>()
|
|
777
996
|
for (const group of baseGroupsForDefaults) {
|
|
778
997
|
baseGroupMap.set(resolveGroupKey(group), group)
|
|
@@ -781,7 +1000,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
781
1000
|
|
|
782
1001
|
const orderedGroupIds = customDraft
|
|
783
1002
|
? mergeGroupOrder(customDraft.order, Array.from(baseGroupMap.keys()))
|
|
784
|
-
:
|
|
1003
|
+
: mainNavGroupsWithInjected.map((group) => resolveGroupKey(group))
|
|
785
1004
|
|
|
786
1005
|
const lastVisibleGroupIndex = (() => {
|
|
787
1006
|
for (let idx = navGroups.length - 1; idx >= 0; idx -= 1) {
|
|
@@ -793,13 +1012,14 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
793
1012
|
const renderEditableItems = (baseItems: SidebarItem[], currentItems: SidebarItem[], depth = 0): React.ReactNode => {
|
|
794
1013
|
if (!customDraft) return null
|
|
795
1014
|
return baseItems.map((baseItem) => {
|
|
1015
|
+
const itemKey = resolveItemKey(baseItem)
|
|
796
1016
|
const current = currentItems.find((item) => item.href === baseItem.href) ?? baseItem
|
|
797
1017
|
const placeholder = baseItem.defaultTitle ?? baseItem.title
|
|
798
|
-
const value = customDraft.itemLabels[
|
|
799
|
-
const hidden = customDraft.hiddenItemIds[
|
|
1018
|
+
const value = customDraft.itemLabels[itemKey] ?? ''
|
|
1019
|
+
const hidden = customDraft.hiddenItemIds[itemKey] === true
|
|
800
1020
|
return (
|
|
801
1021
|
<div
|
|
802
|
-
key={
|
|
1022
|
+
key={itemKey}
|
|
803
1023
|
className={`flex flex-col gap-1 ${hidden ? 'opacity-60' : ''}`}
|
|
804
1024
|
style={depth ? { marginLeft: depth * 16 } : undefined}
|
|
805
1025
|
>
|
|
@@ -809,14 +1029,14 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
809
1029
|
type="checkbox"
|
|
810
1030
|
className="h-4 w-4 accent-foreground"
|
|
811
1031
|
checked={!hidden}
|
|
812
|
-
onChange={(event) => setItemHidden(
|
|
1032
|
+
onChange={(event) => setItemHidden(itemKey, !event.target.checked)}
|
|
813
1033
|
disabled={savingPreferences}
|
|
814
1034
|
aria-label={t('appShell.sidebarCustomizationShowItem')}
|
|
815
1035
|
title={t('appShell.sidebarCustomizationShowItem')}
|
|
816
1036
|
/>
|
|
817
1037
|
<input
|
|
818
1038
|
value={value}
|
|
819
|
-
onChange={(event) => setItemLabel(
|
|
1039
|
+
onChange={(event) => setItemLabel(itemKey, event.target.value)}
|
|
820
1040
|
placeholder={placeholder}
|
|
821
1041
|
disabled={savingPreferences}
|
|
822
1042
|
className="h-8 flex-1 rounded border bg-background px-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
|
@@ -991,7 +1211,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
991
1211
|
return true
|
|
992
1212
|
}
|
|
993
1213
|
|
|
994
|
-
const mainGroups =
|
|
1214
|
+
const mainGroups = mainNavGroupsWithInjected.map((g) => ({
|
|
995
1215
|
...g,
|
|
996
1216
|
items: g.items.filter((item) => isMainItem(item) && item.hidden !== true),
|
|
997
1217
|
})).filter((g) => g.items.length > 0)
|
|
@@ -1005,7 +1225,13 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1005
1225
|
|
|
1006
1226
|
return (
|
|
1007
1227
|
<>
|
|
1008
|
-
<nav className="flex flex-col gap-2">
|
|
1228
|
+
<nav className="flex flex-col gap-2" data-testid="sidebar">
|
|
1229
|
+
{shouldRenderSidebarInjectionSpots ? (
|
|
1230
|
+
<InjectionSpot
|
|
1231
|
+
spotId={BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID}
|
|
1232
|
+
context={injectionContext}
|
|
1233
|
+
/>
|
|
1234
|
+
) : null}
|
|
1009
1235
|
{mainGroups.map((g, gi) => {
|
|
1010
1236
|
const groupId = resolveGroupKey(g)
|
|
1011
1237
|
const open = openGroups[groupId] !== false
|
|
@@ -1039,6 +1265,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1039
1265
|
} ${i.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
|
|
1040
1266
|
aria-disabled={i.enabled === false}
|
|
1041
1267
|
title={compact ? i.title : undefined}
|
|
1268
|
+
data-menu-item-id={i.id ?? i.href}
|
|
1042
1269
|
onClick={() => setMobileOpen(false)}
|
|
1043
1270
|
>
|
|
1044
1271
|
{isParentActive ? (
|
|
@@ -1063,6 +1290,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1063
1290
|
} ${c.enabled === false ? 'pointer-events-none opacity-50' : ''}`}
|
|
1064
1291
|
aria-disabled={c.enabled === false}
|
|
1065
1292
|
title={compact ? c.title : undefined}
|
|
1293
|
+
data-menu-item-id={c.id ?? c.href}
|
|
1066
1294
|
onClick={() => setMobileOpen(false)}
|
|
1067
1295
|
>
|
|
1068
1296
|
{childActive ? (
|
|
@@ -1088,6 +1316,12 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1088
1316
|
})}
|
|
1089
1317
|
</nav>
|
|
1090
1318
|
<div className="mt-4 pt-4 border-t">
|
|
1319
|
+
{shouldRenderSidebarInjectionSpots ? (
|
|
1320
|
+
<InjectionSpot
|
|
1321
|
+
spotId={BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID}
|
|
1322
|
+
context={injectionContext}
|
|
1323
|
+
/>
|
|
1324
|
+
) : null}
|
|
1091
1325
|
<Link
|
|
1092
1326
|
href="/backend/settings"
|
|
1093
1327
|
className={`relative text-sm rounded inline-flex items-center w-full ${
|
|
@@ -1119,6 +1353,12 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1119
1353
|
</div>
|
|
1120
1354
|
{!customizing && (
|
|
1121
1355
|
<>
|
|
1356
|
+
{shouldRenderSidebarInjectionSpots ? (
|
|
1357
|
+
<InjectionSpot
|
|
1358
|
+
spotId={GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID}
|
|
1359
|
+
context={injectionContext}
|
|
1360
|
+
/>
|
|
1361
|
+
) : null}
|
|
1122
1362
|
{compact || isMobileVariant ? (
|
|
1123
1363
|
<IconButton
|
|
1124
1364
|
variant="outline"
|
|
@@ -1162,6 +1402,38 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1162
1402
|
setBreadcrumb: setHeaderBreadcrumb,
|
|
1163
1403
|
setTitle: setHeaderTitle,
|
|
1164
1404
|
}), [])
|
|
1405
|
+
const renderedTopbarInjectedActions = React.useMemo(
|
|
1406
|
+
() =>
|
|
1407
|
+
topbarInjectedMenuItems.map((item) => {
|
|
1408
|
+
const label = resolveInjectedMenuLabel(item, t)
|
|
1409
|
+
if (item.href) {
|
|
1410
|
+
return (
|
|
1411
|
+
<Link
|
|
1412
|
+
key={item.id}
|
|
1413
|
+
href={item.href}
|
|
1414
|
+
className="inline-flex items-center rounded border px-2 py-1 text-xs hover:bg-accent hover:text-accent-foreground"
|
|
1415
|
+
data-menu-item-id={item.id}
|
|
1416
|
+
>
|
|
1417
|
+
{label}
|
|
1418
|
+
</Link>
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
return (
|
|
1422
|
+
<Button
|
|
1423
|
+
key={item.id}
|
|
1424
|
+
type="button"
|
|
1425
|
+
variant="outline"
|
|
1426
|
+
size="sm"
|
|
1427
|
+
className="h-7 text-xs"
|
|
1428
|
+
data-menu-item-id={item.id}
|
|
1429
|
+
onClick={() => item.onClick?.()}
|
|
1430
|
+
>
|
|
1431
|
+
{label}
|
|
1432
|
+
</Button>
|
|
1433
|
+
)
|
|
1434
|
+
}),
|
|
1435
|
+
[t, topbarInjectedMenuItems],
|
|
1436
|
+
)
|
|
1165
1437
|
|
|
1166
1438
|
return (
|
|
1167
1439
|
<HeaderContext.Provider value={headerCtxValue}>
|
|
@@ -1227,6 +1499,15 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1227
1499
|
})()}
|
|
1228
1500
|
</div>
|
|
1229
1501
|
<div className="flex items-center gap-1 md:gap-2 text-sm shrink-0">
|
|
1502
|
+
<InjectionSpot
|
|
1503
|
+
spotId={GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID}
|
|
1504
|
+
context={injectionContext}
|
|
1505
|
+
/>
|
|
1506
|
+
<InjectionSpot
|
|
1507
|
+
spotId={BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID}
|
|
1508
|
+
context={injectionContext}
|
|
1509
|
+
/>
|
|
1510
|
+
{renderedTopbarInjectedActions}
|
|
1230
1511
|
{rightHeaderSlot ? (
|
|
1231
1512
|
rightHeaderSlot
|
|
1232
1513
|
) : (
|
|
@@ -1238,6 +1519,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1238
1519
|
<main className="flex-1 p-4 lg:p-6">
|
|
1239
1520
|
<InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1240
1521
|
<FlashMessages />
|
|
1522
|
+
<SseEventIndicator />
|
|
1241
1523
|
<PartialIndexBanner />
|
|
1242
1524
|
<UpgradeActionBanner />
|
|
1243
1525
|
<LastOperationBanner />
|
|
@@ -1299,6 +1581,7 @@ export function AppShell({ productName, email, groups, rightHeaderSlot, children
|
|
|
1299
1581
|
// Helper: deep-clone minimal shape we mutate (children arrays)
|
|
1300
1582
|
AppShell.cloneGroups = function cloneGroups(groups: AppShellProps['groups']): AppShellProps['groups'] {
|
|
1301
1583
|
const cloneItem = (item: SidebarItem): SidebarItem => ({
|
|
1584
|
+
id: item.id,
|
|
1302
1585
|
href: item.href,
|
|
1303
1586
|
title: item.title,
|
|
1304
1587
|
defaultTitle: item.defaultTitle,
|
|
@@ -1342,13 +1625,14 @@ function applyCustomizationDraft(baseGroups: SidebarGroup[], draft: SidebarCusto
|
|
|
1342
1625
|
}
|
|
1343
1626
|
|
|
1344
1627
|
function applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {
|
|
1628
|
+
const itemKey = resolveItemKey(item)
|
|
1345
1629
|
const baseTitle = item.defaultTitle ?? item.title
|
|
1346
|
-
const override = draft.itemLabels[
|
|
1630
|
+
const override = draft.itemLabels[itemKey]?.trim()
|
|
1347
1631
|
const children = item.children
|
|
1348
1632
|
? item.children
|
|
1349
1633
|
.map((child) => applyItemDraft(child, draft))
|
|
1350
1634
|
: undefined
|
|
1351
|
-
const hidden = draft.hiddenItemIds[
|
|
1635
|
+
const hidden = draft.hiddenItemIds[itemKey] === true
|
|
1352
1636
|
return {
|
|
1353
1637
|
...item,
|
|
1354
1638
|
title: override && override.length > 0 ? override : baseTitle,
|
|
@@ -1380,7 +1664,10 @@ function collectSidebarDefaults(groups: SidebarGroup[]) {
|
|
|
1380
1664
|
|
|
1381
1665
|
const visitItems = (items: SidebarItem[]) => {
|
|
1382
1666
|
for (const item of items) {
|
|
1667
|
+
const key = resolveItemKey(item)
|
|
1383
1668
|
const baseTitle = item.defaultTitle ?? item.title
|
|
1669
|
+
itemDefaults.set(key, baseTitle)
|
|
1670
|
+
// Backward-compatible alias for legacy stored href-based preferences.
|
|
1384
1671
|
itemDefaults.set(item.href, baseTitle)
|
|
1385
1672
|
if (item.children && item.children.length > 0) visitItems(item.children)
|
|
1386
1673
|
}
|