@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
|
@@ -7,6 +7,11 @@ import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
|
7
7
|
import { useTheme } from '@open-mercato/ui/theme'
|
|
8
8
|
import { Button } from '../primitives/button'
|
|
9
9
|
import { IconButton } from '../primitives/icon-button'
|
|
10
|
+
import { useInjectedMenuItems } from './injection/useInjectedMenuItems'
|
|
11
|
+
import { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'
|
|
12
|
+
import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
13
|
+
import { InjectionSpot } from './injection/InjectionSpot'
|
|
14
|
+
import { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'
|
|
10
15
|
|
|
11
16
|
export type ProfileDropdownProps = {
|
|
12
17
|
email?: string
|
|
@@ -36,6 +41,7 @@ export function ProfileDropdown({
|
|
|
36
41
|
const [mounted, setMounted] = React.useState(false)
|
|
37
42
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
38
43
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
|
44
|
+
const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')
|
|
39
45
|
|
|
40
46
|
React.useEffect(() => {
|
|
41
47
|
setMounted(true)
|
|
@@ -96,47 +102,86 @@ export function ProfileDropdown({
|
|
|
96
102
|
const menuItemClass =
|
|
97
103
|
'w-full text-left text-sm cursor-pointer px-3 py-2 rounded hover:bg-accent inline-flex items-center gap-2.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
title={email || t('ui.userMenu.userFallback', 'User')}
|
|
109
|
-
>
|
|
110
|
-
<User className="size-4" />
|
|
111
|
-
</IconButton>
|
|
105
|
+
const resolveMenuLabel = React.useCallback(
|
|
106
|
+
(item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {
|
|
107
|
+
if (item.labelKey && item.label) return t(item.labelKey, item.label)
|
|
108
|
+
if (item.labelKey) return t(item.labelKey, item.id)
|
|
109
|
+
if (item.label && item.label.includes('.')) return t(item.label, item.id)
|
|
110
|
+
return item.label ?? item.id
|
|
111
|
+
},
|
|
112
|
+
[t],
|
|
113
|
+
)
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
const builtInMenuItems = React.useMemo(
|
|
116
|
+
() => {
|
|
117
|
+
const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]
|
|
118
|
+
if (notificationsHref) items.push({ id: 'notifications' })
|
|
119
|
+
items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })
|
|
120
|
+
return items
|
|
121
|
+
},
|
|
122
|
+
[notificationsHref],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const mergedMenuItems = React.useMemo(
|
|
126
|
+
() => mergeMenuItems(builtInMenuItems, injectedItems),
|
|
127
|
+
[builtInMenuItems, injectedItems],
|
|
128
|
+
)
|
|
129
|
+
const injectionContext = React.useMemo(
|
|
130
|
+
() => ({
|
|
131
|
+
email,
|
|
132
|
+
displayName,
|
|
133
|
+
locale: currentLocale,
|
|
134
|
+
}),
|
|
135
|
+
[currentLocale, displayName, email],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const renderInjectedItem = React.useCallback(
|
|
139
|
+
(item: MergedMenuItem) => {
|
|
140
|
+
const label = resolveMenuLabel(item)
|
|
141
|
+
const icon = resolveInjectedIcon(item.icon)
|
|
142
|
+
if (item.href) {
|
|
143
|
+
return (
|
|
144
|
+
<Link
|
|
145
|
+
key={item.id}
|
|
146
|
+
href={item.href}
|
|
147
|
+
className={menuItemClass}
|
|
148
|
+
role="menuitem"
|
|
149
|
+
data-menu-item-id={item.id}
|
|
150
|
+
onClick={() => setOpen(false)}
|
|
151
|
+
>
|
|
152
|
+
{icon}
|
|
153
|
+
<span>{label}</span>
|
|
154
|
+
</Link>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
<Button
|
|
159
|
+
key={item.id}
|
|
160
|
+
type="button"
|
|
161
|
+
variant="ghost"
|
|
162
|
+
size="sm"
|
|
163
|
+
className="w-full justify-start"
|
|
164
|
+
role="menuitem"
|
|
165
|
+
data-menu-item-id={item.id}
|
|
166
|
+
onClick={() => {
|
|
167
|
+
item.onClick?.()
|
|
168
|
+
setOpen(false)
|
|
169
|
+
}}
|
|
118
170
|
>
|
|
119
|
-
{
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
</div>
|
|
127
|
-
)}
|
|
128
|
-
{displayName && email && (
|
|
129
|
-
<div className="text-xs text-muted-foreground mt-0.5 ml-6">{email}</div>
|
|
130
|
-
)}
|
|
131
|
-
{!displayName && email && (
|
|
132
|
-
<div className="text-xs text-muted-foreground">
|
|
133
|
-
{t('ui.userMenu.loggedInAs', 'Logged in as:')} {email}
|
|
134
|
-
</div>
|
|
135
|
-
)}
|
|
136
|
-
</div>
|
|
137
|
-
)}
|
|
171
|
+
{icon}
|
|
172
|
+
<span>{label}</span>
|
|
173
|
+
</Button>
|
|
174
|
+
)
|
|
175
|
+
},
|
|
176
|
+
[menuItemClass, resolveMenuLabel],
|
|
177
|
+
)
|
|
138
178
|
|
|
179
|
+
const renderBuiltInItem = React.useCallback(
|
|
180
|
+
(id: string) => {
|
|
181
|
+
if (id === 'change-password') {
|
|
182
|
+
return (
|
|
139
183
|
<Link
|
|
184
|
+
key={id}
|
|
140
185
|
href={changePasswordHref}
|
|
141
186
|
className={menuItemClass}
|
|
142
187
|
role="menuitem"
|
|
@@ -145,44 +190,49 @@ export function ProfileDropdown({
|
|
|
145
190
|
<Key className="size-4" />
|
|
146
191
|
<span>{t('ui.profileMenu.changePassword', 'Change Password')}</span>
|
|
147
192
|
</Link>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
</
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
196
|
+
if (id === 'notifications' && notificationsHref) {
|
|
197
|
+
return (
|
|
198
|
+
<Link
|
|
199
|
+
key={id}
|
|
200
|
+
href={notificationsHref}
|
|
201
|
+
className={menuItemClass}
|
|
202
|
+
role="menuitem"
|
|
203
|
+
onClick={() => setOpen(false)}
|
|
204
|
+
>
|
|
205
|
+
<Bell className="size-4" />
|
|
206
|
+
<span>{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
|
|
207
|
+
</Link>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
163
210
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
</span>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
</
|
|
182
|
-
|
|
211
|
+
if (id === 'theme-toggle') {
|
|
212
|
+
return mounted ? (
|
|
213
|
+
<Button
|
|
214
|
+
key={id}
|
|
215
|
+
type="button"
|
|
216
|
+
variant="ghost"
|
|
217
|
+
size="sm"
|
|
218
|
+
className="w-full justify-between"
|
|
219
|
+
role="menuitem"
|
|
220
|
+
onClick={handleThemeToggle}
|
|
221
|
+
>
|
|
222
|
+
<span className="inline-flex items-center gap-2.5">
|
|
223
|
+
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
|
224
|
+
<span>{t('ui.profileMenu.theme', 'Dark Mode')}</span>
|
|
225
|
+
</span>
|
|
226
|
+
<div className={`w-8 h-4 rounded-full transition-colors ${isDark ? 'bg-primary' : 'bg-muted'} relative`}>
|
|
227
|
+
<div className={`absolute top-0.5 w-3 h-3 rounded-full bg-background shadow transition-transform ${isDark ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
|
228
|
+
</div>
|
|
229
|
+
</Button>
|
|
230
|
+
) : null
|
|
231
|
+
}
|
|
183
232
|
|
|
184
|
-
|
|
185
|
-
|
|
233
|
+
if (id === 'language') {
|
|
234
|
+
return (
|
|
235
|
+
<div key={id} className="relative">
|
|
186
236
|
<Button
|
|
187
237
|
type="button"
|
|
188
238
|
variant="ghost"
|
|
@@ -200,8 +250,6 @@ export function ProfileDropdown({
|
|
|
200
250
|
{localeLabels[currentLocale]}
|
|
201
251
|
</span>
|
|
202
252
|
</Button>
|
|
203
|
-
|
|
204
|
-
{/* Language submenu - inline below */}
|
|
205
253
|
{languageOpen && (
|
|
206
254
|
<div className="mt-1 ml-6 space-y-0.5 border-l pl-2">
|
|
207
255
|
{locales.map((locale) => (
|
|
@@ -220,11 +268,12 @@ export function ProfileDropdown({
|
|
|
220
268
|
</div>
|
|
221
269
|
)}
|
|
222
270
|
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
223
273
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
<form action="/api/auth/logout" method="POST">
|
|
274
|
+
if (id === 'sign-out') {
|
|
275
|
+
return (
|
|
276
|
+
<form key={id} action="/api/auth/logout" method="POST">
|
|
228
277
|
<Button
|
|
229
278
|
variant="ghost"
|
|
230
279
|
size="sm"
|
|
@@ -236,6 +285,78 @@ export function ProfileDropdown({
|
|
|
236
285
|
<span>{t('ui.userMenu.logout', 'Sign Out')}</span>
|
|
237
286
|
</Button>
|
|
238
287
|
</form>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null
|
|
292
|
+
},
|
|
293
|
+
[
|
|
294
|
+
changePasswordHref,
|
|
295
|
+
currentLocale,
|
|
296
|
+
handleThemeToggle,
|
|
297
|
+
isDark,
|
|
298
|
+
languageOpen,
|
|
299
|
+
menuItemClass,
|
|
300
|
+
mounted,
|
|
301
|
+
notificationsHref,
|
|
302
|
+
t,
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div className="relative">
|
|
308
|
+
<IconButton
|
|
309
|
+
ref={buttonRef}
|
|
310
|
+
variant="ghost"
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => setOpen(!open)}
|
|
313
|
+
aria-expanded={open}
|
|
314
|
+
aria-haspopup="menu"
|
|
315
|
+
data-testid="profile-dropdown-trigger"
|
|
316
|
+
title={email || t('ui.userMenu.userFallback', 'User')}
|
|
317
|
+
>
|
|
318
|
+
<User className="size-4" />
|
|
319
|
+
</IconButton>
|
|
320
|
+
|
|
321
|
+
{open && (
|
|
322
|
+
<div
|
|
323
|
+
ref={menuRef}
|
|
324
|
+
className="absolute right-0 top-full mt-1 w-56 rounded-md border bg-background p-1 shadow-lg z-50"
|
|
325
|
+
role="menu"
|
|
326
|
+
data-testid="profile-dropdown"
|
|
327
|
+
>
|
|
328
|
+
{/* User info header */}
|
|
329
|
+
{(displayName || email) && (
|
|
330
|
+
<div className="px-3 py-2.5 border-b mb-1">
|
|
331
|
+
{displayName && (
|
|
332
|
+
<div className="font-medium text-sm flex items-center gap-2">
|
|
333
|
+
<User className="size-4" />
|
|
334
|
+
{displayName}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
{displayName && email && (
|
|
338
|
+
<div className="text-xs text-muted-foreground mt-0.5 ml-6">{email}</div>
|
|
339
|
+
)}
|
|
340
|
+
{!displayName && email && (
|
|
341
|
+
<div className="text-xs text-muted-foreground">
|
|
342
|
+
{t('ui.userMenu.loggedInAs', 'Logged in as:')} {email}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{mergedMenuItems.map((item) => (
|
|
349
|
+
<React.Fragment key={item.id}>
|
|
350
|
+
{item.separator ? <div className="my-1 border-t" /> : null}
|
|
351
|
+
{item.source === 'injected'
|
|
352
|
+
? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)
|
|
353
|
+
: renderBuiltInItem(item.id)}
|
|
354
|
+
</React.Fragment>
|
|
355
|
+
))}
|
|
356
|
+
<InjectionSpot
|
|
357
|
+
spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}
|
|
358
|
+
context={injectionContext}
|
|
359
|
+
/>
|
|
239
360
|
</div>
|
|
240
361
|
)}
|
|
241
362
|
</div>
|
|
@@ -6,8 +6,11 @@ import type {
|
|
|
6
6
|
WidgetInjectionEventHandlers,
|
|
7
7
|
WidgetBeforeDeleteResult,
|
|
8
8
|
WidgetBeforeSaveResult,
|
|
9
|
+
FieldChangeResult,
|
|
10
|
+
NavigateGuardResult,
|
|
9
11
|
} from '@open-mercato/shared/modules/widgets/injection'
|
|
10
12
|
import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
13
|
+
import { getWidgetSharedState } from './WidgetSharedState'
|
|
11
14
|
|
|
12
15
|
export type InjectionSpotProps<TContext = unknown, TData = unknown> = {
|
|
13
16
|
spotId: InjectionSpotId
|
|
@@ -16,20 +19,21 @@ export type InjectionSpotProps<TContext = unknown, TData = unknown> = {
|
|
|
16
19
|
onDataChange?: (data: TData) => void
|
|
17
20
|
disabled?: boolean
|
|
18
21
|
onEvent?: (
|
|
19
|
-
event:
|
|
20
|
-
| 'onLoad'
|
|
21
|
-
| 'onBeforeSave'
|
|
22
|
-
| 'onSave'
|
|
23
|
-
| 'onAfterSave'
|
|
24
|
-
| 'onBeforeDelete'
|
|
25
|
-
| 'onDelete'
|
|
26
|
-
| 'onAfterDelete'
|
|
27
|
-
| 'onDeleteError',
|
|
22
|
+
event: keyof WidgetInjectionEventHandlers<TContext, TData>,
|
|
28
23
|
widgetId: string,
|
|
29
24
|
) => void
|
|
30
25
|
widgetsOverride?: LoadedWidget[]
|
|
31
26
|
}
|
|
32
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Transformer events use pipeline dispatch: output of widget N becomes input of widget N+1.
|
|
30
|
+
*/
|
|
31
|
+
const TRANSFORMER_EVENTS = new Set<string>([
|
|
32
|
+
'transformFormData',
|
|
33
|
+
'transformDisplayData',
|
|
34
|
+
'transformValidation',
|
|
35
|
+
])
|
|
36
|
+
|
|
33
37
|
type LoadedWidget = {
|
|
34
38
|
widgetId: string
|
|
35
39
|
module: InjectionWidgetModule<any, any>
|
|
@@ -38,6 +42,20 @@ type LoadedWidget = {
|
|
|
38
42
|
placement?: LoadedInjectionWidget['placement']
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
function injectSharedStateIntoContext<TContext>(context: TContext, moduleId: string): TContext {
|
|
46
|
+
const sharedState = getWidgetSharedState(moduleId)
|
|
47
|
+
if (typeof context === 'object' && context !== null && !Array.isArray(context)) {
|
|
48
|
+
return {
|
|
49
|
+
...(context as Record<string, unknown>),
|
|
50
|
+
sharedState,
|
|
51
|
+
} as TContext
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
value: context,
|
|
55
|
+
sharedState,
|
|
56
|
+
} as TContext
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
export function useInjectionWidgets<TContext = unknown>(
|
|
42
60
|
spotId: InjectionSpotId | null | undefined,
|
|
43
61
|
options?: {
|
|
@@ -80,7 +98,8 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
80
98
|
for (const widget of widgetList) {
|
|
81
99
|
if (widget.module.eventHandlers?.onLoad) {
|
|
82
100
|
try {
|
|
83
|
-
|
|
101
|
+
const widgetContext = injectSharedStateIntoContext(options.context as TContext, widget.moduleId)
|
|
102
|
+
await widget.module.eventHandlers.onLoad(widgetContext)
|
|
84
103
|
options.onEvent?.('onLoad', widget.widgetId)
|
|
85
104
|
} catch (err) {
|
|
86
105
|
console.error(`[InjectionSpot] Error in onLoad for widget ${widget.widgetId}:`, err)
|
|
@@ -144,7 +163,7 @@ export function InjectionSpot<TContext = unknown, TData = unknown>({
|
|
|
144
163
|
return (
|
|
145
164
|
<Widget
|
|
146
165
|
key={widget.widgetId}
|
|
147
|
-
context={context}
|
|
166
|
+
context={injectSharedStateIntoContext(context, widget.moduleId)}
|
|
148
167
|
data={data}
|
|
149
168
|
onDataChange={onDataChange}
|
|
150
169
|
disabled={disabled}
|
|
@@ -195,8 +214,28 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
195
214
|
event: keyof WidgetInjectionEventHandlers<TContext, TData>,
|
|
196
215
|
data: TData,
|
|
197
216
|
context: TContext,
|
|
198
|
-
meta?: {
|
|
199
|
-
|
|
217
|
+
meta?: {
|
|
218
|
+
error?: unknown
|
|
219
|
+
fieldId?: string
|
|
220
|
+
fieldValue?: unknown
|
|
221
|
+
originalData?: TData
|
|
222
|
+
target?: unknown
|
|
223
|
+
visible?: boolean
|
|
224
|
+
appEvent?: unknown
|
|
225
|
+
}
|
|
226
|
+
): Promise<{
|
|
227
|
+
ok: boolean
|
|
228
|
+
message?: string
|
|
229
|
+
fieldErrors?: Record<string, string>
|
|
230
|
+
requestHeaders?: Record<string, string>
|
|
231
|
+
details?: unknown
|
|
232
|
+
data?: TData
|
|
233
|
+
fieldChange?: {
|
|
234
|
+
value?: unknown
|
|
235
|
+
sideEffects?: Record<string, unknown>
|
|
236
|
+
messages?: Array<{ text: string; severity: 'info' | 'warning' | 'error' }>
|
|
237
|
+
}
|
|
238
|
+
}> => {
|
|
200
239
|
const normalizeBeforeSave = (
|
|
201
240
|
result: WidgetBeforeSaveResult,
|
|
202
241
|
): { ok: boolean; message?: string; fieldErrors?: Record<string, string>; requestHeaders?: Record<string, string>; details?: unknown } => {
|
|
@@ -247,21 +286,56 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
247
286
|
return { ok: true }
|
|
248
287
|
}
|
|
249
288
|
|
|
289
|
+
// --- Transformer events: pipeline dispatch ---
|
|
290
|
+
// Output of widget N becomes input of widget N+1
|
|
291
|
+
if (TRANSFORMER_EVENTS.has(event)) {
|
|
292
|
+
let pipelineData = data
|
|
293
|
+
for (const widget of widgets) {
|
|
294
|
+
const handler = widget.module.eventHandlers?.[event]
|
|
295
|
+
if (!handler) continue
|
|
296
|
+
try {
|
|
297
|
+
const widgetContext = injectSharedStateIntoContext(context, widget.moduleId)
|
|
298
|
+
if (event === 'transformValidation') {
|
|
299
|
+
pipelineData = await (handler as any)(pipelineData, meta?.originalData ?? data, widgetContext)
|
|
300
|
+
} else {
|
|
301
|
+
pipelineData = await (handler as any)(pipelineData, widgetContext)
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(`[useInjectionSpotEvents] Error in ${event} for widget ${widget.widgetId}:`, err)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { ok: true, data: pipelineData }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Action events: sequential dispatch ---
|
|
250
311
|
const mergedRequestHeaders: Record<string, string> = {}
|
|
251
312
|
let hasRequestHeaders = false
|
|
313
|
+
let fieldValue = meta?.fieldValue
|
|
314
|
+
let fieldSideEffects: Record<string, unknown> | undefined
|
|
315
|
+
let fieldMessages: Array<{ text: string; severity: 'info' | 'warning' | 'error' }> | undefined
|
|
252
316
|
|
|
253
317
|
for (const widget of widgets) {
|
|
254
318
|
const eventHandlers = widget.module.eventHandlers
|
|
255
319
|
let handler = eventHandlers?.[event]
|
|
320
|
+
// Delete-to-save fallback chain
|
|
256
321
|
if (!handler && event === 'onBeforeDelete') handler = eventHandlers?.onBeforeSave as typeof handler
|
|
257
322
|
if (!handler && event === 'onDelete') handler = eventHandlers?.onSave as typeof handler
|
|
258
323
|
if (!handler && event === 'onAfterDelete') handler = eventHandlers?.onAfterSave as typeof handler
|
|
259
324
|
if (handler) {
|
|
260
325
|
try {
|
|
326
|
+
const widgetContext = injectSharedStateIntoContext(context, widget.moduleId)
|
|
261
327
|
const result =
|
|
262
328
|
event === 'onDeleteError'
|
|
263
|
-
? await (handler as any)(data,
|
|
264
|
-
:
|
|
329
|
+
? await (handler as any)(data, widgetContext, meta?.error)
|
|
330
|
+
: event === 'onFieldChange'
|
|
331
|
+
? await (handler as any)(meta?.fieldId, fieldValue, data, widgetContext)
|
|
332
|
+
: event === 'onBeforeNavigate'
|
|
333
|
+
? await (handler as any)(meta?.target, widgetContext)
|
|
334
|
+
: event === 'onVisibilityChange'
|
|
335
|
+
? await (handler as any)(meta?.visible, widgetContext)
|
|
336
|
+
: event === 'onAppEvent'
|
|
337
|
+
? await (handler as any)(meta?.appEvent, widgetContext)
|
|
338
|
+
: await (handler as any)(data, widgetContext)
|
|
265
339
|
if (event === 'onBeforeSave') {
|
|
266
340
|
const normalized = normalizeBeforeSave(result as WidgetBeforeSaveResult)
|
|
267
341
|
if (!normalized.ok) {
|
|
@@ -284,9 +358,27 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
284
358
|
hasRequestHeaders = true
|
|
285
359
|
}
|
|
286
360
|
}
|
|
361
|
+
if (event === 'onBeforeNavigate') {
|
|
362
|
+
const navResult = result as NavigateGuardResult | undefined
|
|
363
|
+
if (navResult && navResult.ok === false) {
|
|
364
|
+
return { ok: false, message: navResult.message }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (event === 'onFieldChange') {
|
|
368
|
+
const changeResult = result as FieldChangeResult | void
|
|
369
|
+
if (changeResult?.value !== undefined) {
|
|
370
|
+
fieldValue = changeResult.value
|
|
371
|
+
}
|
|
372
|
+
if (changeResult?.sideEffects && typeof changeResult.sideEffects === 'object') {
|
|
373
|
+
fieldSideEffects = { ...(fieldSideEffects ?? {}), ...changeResult.sideEffects }
|
|
374
|
+
}
|
|
375
|
+
if (changeResult?.message?.text) {
|
|
376
|
+
fieldMessages = [...(fieldMessages ?? []), changeResult.message]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
287
379
|
} catch (err) {
|
|
288
380
|
console.error(`[useInjectionSpotEvents] Error in ${event} for widget ${widget.widgetId}:`, err)
|
|
289
|
-
if (event === 'onBeforeSave' || event === 'onBeforeDelete') {
|
|
381
|
+
if (event === 'onBeforeSave' || event === 'onBeforeDelete' || event === 'onBeforeNavigate') {
|
|
290
382
|
const message =
|
|
291
383
|
err instanceof Error
|
|
292
384
|
? err.message || 'Validation blocked'
|
|
@@ -301,6 +393,16 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
301
393
|
if ((event === 'onBeforeSave' || event === 'onBeforeDelete') && hasRequestHeaders) {
|
|
302
394
|
return { ok: true, requestHeaders: mergedRequestHeaders }
|
|
303
395
|
}
|
|
396
|
+
if (event === 'onFieldChange') {
|
|
397
|
+
return {
|
|
398
|
+
ok: true,
|
|
399
|
+
fieldChange: {
|
|
400
|
+
value: fieldValue,
|
|
401
|
+
sideEffects: fieldSideEffects,
|
|
402
|
+
messages: fieldMessages,
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
}
|
|
304
406
|
return { ok: true }
|
|
305
407
|
},
|
|
306
408
|
[widgets]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useAppEvent } from './useAppEvent'
|
|
4
|
+
import { flash } from '../FlashMessages'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Global SSE Event Indicator
|
|
8
|
+
*
|
|
9
|
+
* Mount once in AppShell to provide visible feedback when server events
|
|
10
|
+
* arrive via the DOM Event Bridge. Shows a flash message for every
|
|
11
|
+
* broadcast event received.
|
|
12
|
+
*
|
|
13
|
+
* This component renders nothing — it only listens and triggers flash messages.
|
|
14
|
+
*/
|
|
15
|
+
export function SseEventIndicator(): null {
|
|
16
|
+
useAppEvent('*', (event) => {
|
|
17
|
+
const parts = event.id.split('.')
|
|
18
|
+
const module = parts[0] ?? ''
|
|
19
|
+
const action = parts[parts.length - 1] ?? 'event'
|
|
20
|
+
flash(`[SSE] ${module}: ${action} (${event.id})`, 'info')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
type Subscriber = (value: unknown) => void
|
|
2
|
+
|
|
3
|
+
export interface WidgetSharedState {
|
|
4
|
+
get<T>(key: string): T | undefined
|
|
5
|
+
set<T>(key: string, value: T): void
|
|
6
|
+
subscribe(key: string, handler: Subscriber): () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class NamespacedWidgetSharedState implements WidgetSharedState {
|
|
10
|
+
private readonly values = new Map<string, unknown>()
|
|
11
|
+
private readonly subscribers = new Map<string, Set<Subscriber>>()
|
|
12
|
+
|
|
13
|
+
constructor(private readonly namespace: string) {}
|
|
14
|
+
|
|
15
|
+
get<T>(key: string): T | undefined {
|
|
16
|
+
return this.values.get(this.toScopedKey(key)) as T | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
set<T>(key: string, value: T): void {
|
|
20
|
+
const scopedKey = this.toScopedKey(key)
|
|
21
|
+
this.values.set(scopedKey, value)
|
|
22
|
+
const handlers = this.subscribers.get(scopedKey)
|
|
23
|
+
if (!handlers || handlers.size === 0) return
|
|
24
|
+
for (const handler of handlers) {
|
|
25
|
+
handler(value)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
subscribe(key: string, handler: Subscriber): () => void {
|
|
30
|
+
const scopedKey = this.toScopedKey(key)
|
|
31
|
+
const handlers = this.subscribers.get(scopedKey) ?? new Set<Subscriber>()
|
|
32
|
+
handlers.add(handler)
|
|
33
|
+
this.subscribers.set(scopedKey, handlers)
|
|
34
|
+
return () => {
|
|
35
|
+
const current = this.subscribers.get(scopedKey)
|
|
36
|
+
if (!current) return
|
|
37
|
+
current.delete(handler)
|
|
38
|
+
if (current.size === 0) {
|
|
39
|
+
this.subscribers.delete(scopedKey)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private toScopedKey(key: string): string {
|
|
45
|
+
return `${this.namespace}:${key}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const storeByNamespace = new Map<string, WidgetSharedState>()
|
|
50
|
+
|
|
51
|
+
export function getWidgetSharedState(namespace: string): WidgetSharedState {
|
|
52
|
+
const normalized = namespace.trim().length > 0 ? namespace.trim() : 'global'
|
|
53
|
+
const existing = storeByNamespace.get(normalized)
|
|
54
|
+
if (existing) return existing
|
|
55
|
+
const created = new NamespacedWidgetSharedState(normalized)
|
|
56
|
+
storeByNamespace.set(normalized, created)
|
|
57
|
+
return created
|
|
58
|
+
}
|