@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.
Files changed (54) hide show
  1. package/AGENTS.md +8 -0
  2. package/dist/backend/AppShell.js +395 -134
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +232 -21
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/ProfileDropdown.js +214 -94
  7. package/dist/backend/ProfileDropdown.js.map +2 -2
  8. package/dist/backend/injection/InjectionSpot.js +74 -4
  9. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  10. package/dist/backend/injection/SseEventIndicator.js +16 -0
  11. package/dist/backend/injection/SseEventIndicator.js.map +7 -0
  12. package/dist/backend/injection/WidgetSharedState.js +49 -0
  13. package/dist/backend/injection/WidgetSharedState.js.map +7 -0
  14. package/dist/backend/injection/eventBridge.js +105 -0
  15. package/dist/backend/injection/eventBridge.js.map +7 -0
  16. package/dist/backend/injection/mergeMenuItems.js +43 -0
  17. package/dist/backend/injection/mergeMenuItems.js.map +7 -0
  18. package/dist/backend/injection/resolveInjectedIcon.js +23 -0
  19. package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
  20. package/dist/backend/injection/spotIds.js +40 -1
  21. package/dist/backend/injection/spotIds.js.map +2 -2
  22. package/dist/backend/injection/useAppEvent.js +35 -0
  23. package/dist/backend/injection/useAppEvent.js.map +7 -0
  24. package/dist/backend/injection/useInjectedMenuItems.js +92 -0
  25. package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
  26. package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
  27. package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
  28. package/dist/backend/injection/useOperationProgress.js +64 -0
  29. package/dist/backend/injection/useOperationProgress.js.map +7 -0
  30. package/dist/backend/injection/useWidgetSharedState.js +26 -0
  31. package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
  32. package/dist/backend/section-page/SectionNav.js +22 -2
  33. package/dist/backend/section-page/SectionNav.js.map +2 -2
  34. package/dist/backend/utils/api.js +9 -1
  35. package/dist/backend/utils/api.js.map +2 -2
  36. package/package.json +2 -2
  37. package/src/backend/AGENTS.md +50 -0
  38. package/src/backend/AppShell.tsx +317 -30
  39. package/src/backend/CrudForm.tsx +238 -21
  40. package/src/backend/ProfileDropdown.tsx +199 -78
  41. package/src/backend/injection/InjectionSpot.tsx +118 -16
  42. package/src/backend/injection/SseEventIndicator.tsx +24 -0
  43. package/src/backend/injection/WidgetSharedState.ts +58 -0
  44. package/src/backend/injection/eventBridge.ts +134 -0
  45. package/src/backend/injection/mergeMenuItems.ts +71 -0
  46. package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
  47. package/src/backend/injection/spotIds.ts +38 -0
  48. package/src/backend/injection/useAppEvent.ts +76 -0
  49. package/src/backend/injection/useInjectedMenuItems.ts +125 -0
  50. package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
  51. package/src/backend/injection/useOperationProgress.ts +105 -0
  52. package/src/backend/injection/useWidgetSharedState.ts +28 -0
  53. package/src/backend/section-page/SectionNav.tsx +22 -1
  54. package/src/backend/utils/api.ts +14 -5
@@ -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((href: unknown) => (typeof href === 'string' ? href.trim() : ''))
292
- .filter((href: string) => href.length > 0)
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 href of responseHiddenItems) {
316
- if (!itemDefaults.has(href)) continue
317
- hiddenItemIds[href] = true
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 [href, value] of Object.entries(customDraft.itemLabels)) {
586
+ for (const [itemId, value] of Object.entries(customDraft.itemLabels)) {
379
587
  const trimmed = value.trim()
380
- const base = itemDefaults.get(href)
588
+ const base = itemDefaults.get(itemId)
381
589
  if (!trimmed || !base) continue
382
- if (trimmed !== base) sanitizedItemLabels[href] = trimmed
590
+ if (trimmed !== base) sanitizedItemLabels[itemId] = trimmed
383
591
  }
384
592
  const sanitizedHiddenItems: string[] = []
385
- for (const [href, hidden] of Object.entries(customDraft.hiddenItemIds)) {
593
+ for (const [itemId, hidden] of Object.entries(customDraft.hiddenItemIds)) {
386
594
  if (!hidden) continue
387
- if (!itemDefaults.has(href)) continue
388
- sanitizedHiddenItems.push(href)
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((href: string, value: string) => {
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[href]
470
- else next[href] = value
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((href: string, hidden: boolean) => {
682
+ const setItemHidden = React.useCallback((itemId: string, hidden: boolean) => {
475
683
  updateDraft((draft) => {
476
684
  const next = { ...draft.hiddenItemIds }
477
- if (hidden) next[href] = true
478
- else delete next[href]
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
- return renderSectionSidebar(
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
- return renderSectionSidebar(
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 ?? navGroups
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
- : navGroups.map((group) => resolveGroupKey(group))
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[baseItem.href] ?? ''
799
- const hidden = customDraft.hiddenItemIds[baseItem.href] === true
1018
+ const value = customDraft.itemLabels[itemKey] ?? ''
1019
+ const hidden = customDraft.hiddenItemIds[itemKey] === true
800
1020
  return (
801
1021
  <div
802
- key={baseItem.href}
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(baseItem.href, !event.target.checked)}
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(baseItem.href, event.target.value)}
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 = navGroups.map((g) => ({
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[item.href]?.trim()
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[item.href] === true
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
  }