@asteby/metacore-runtime-react 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +201 -0
- package/README.md +59 -0
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -0
- package/dist/action-modal-dispatcher.js +123 -0
- package/dist/addon-loader.d.ts +27 -0
- package/dist/addon-loader.d.ts.map +1 -0
- package/dist/addon-loader.js +73 -0
- package/dist/api-context.d.ts +40 -0
- package/dist/api-context.d.ts.map +1 -0
- package/dist/api-context.js +25 -0
- package/dist/capability-gate.d.ts +29 -0
- package/dist/capability-gate.d.ts.map +1 -0
- package/dist/capability-gate.js +43 -0
- package/dist/dialogs/_primitives.d.ts +29 -0
- package/dist/dialogs/_primitives.d.ts.map +1 -0
- package/dist/dialogs/_primitives.js +35 -0
- package/dist/dialogs/dynamic-record.d.ts +11 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -0
- package/dist/dialogs/dynamic-record.js +377 -0
- package/dist/dialogs/export.d.ts +12 -0
- package/dist/dialogs/export.d.ts.map +1 -0
- package/dist/dialogs/export.js +146 -0
- package/dist/dialogs/import.d.ts +11 -0
- package/dist/dialogs/import.d.ts.map +1 -0
- package/dist/dialogs/import.js +128 -0
- package/dist/dynamic-columns-shim.d.ts +25 -0
- package/dist/dynamic-columns-shim.d.ts.map +1 -0
- package/dist/dynamic-columns-shim.js +1 -0
- package/dist/dynamic-form.d.ts +12 -0
- package/dist/dynamic-form.d.ts.map +1 -0
- package/dist/dynamic-form.js +51 -0
- package/dist/dynamic-icon.d.ts +6 -0
- package/dist/dynamic-icon.d.ts.map +1 -0
- package/dist/dynamic-icon.js +11 -0
- package/dist/dynamic-table.d.ts +22 -0
- package/dist/dynamic-table.d.ts.map +1 -0
- package/dist/dynamic-table.js +516 -0
- package/dist/i18n-provider.d.ts +16 -0
- package/dist/i18n-provider.d.ts.map +1 -0
- package/dist/i18n-provider.js +16 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/metadata-cache.d.ts +42 -0
- package/dist/metadata-cache.d.ts.map +1 -0
- package/dist/metadata-cache.js +71 -0
- package/dist/navigation-builder.d.ts +34 -0
- package/dist/navigation-builder.d.ts.map +1 -0
- package/dist/navigation-builder.js +45 -0
- package/dist/options-context.d.ts +8 -0
- package/dist/options-context.d.ts.map +1 -0
- package/dist/options-context.js +5 -0
- package/dist/slot.d.ts +32 -0
- package/dist/slot.d.ts.map +1 -0
- package/dist/slot.js +45 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
- package/src/action-modal-dispatcher.tsx +275 -0
- package/src/addon-loader.tsx +111 -0
- package/src/api-context.tsx +55 -0
- package/src/capability-gate.tsx +69 -0
- package/src/dialogs/_primitives.tsx +114 -0
- package/src/dialogs/dynamic-record.tsx +770 -0
- package/src/dialogs/export.tsx +339 -0
- package/src/dialogs/import.tsx +404 -0
- package/src/dynamic-columns-shim.ts +36 -0
- package/src/dynamic-form.tsx +108 -0
- package/src/dynamic-icon.tsx +15 -0
- package/src/dynamic-table.tsx +766 -0
- package/src/i18n-provider.tsx +33 -0
- package/src/index.ts +30 -0
- package/src/metadata-cache.ts +103 -0
- package/src/navigation-builder.tsx +77 -0
- package/src/options-context.tsx +11 -0
- package/src/slot.tsx +77 -0
- package/src/types.ts +112 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ActionModalDispatcher — renders the right modal for a custom action:
|
|
2
|
+
// 1) Custom component from the SDK registry → use it
|
|
3
|
+
// 2) action.fields[] → GenericActionModal (form)
|
|
4
|
+
// 3) action.confirm → ConfirmActionDialog
|
|
5
|
+
// 4) otherwise → nothing (caller executes immediately)
|
|
6
|
+
//
|
|
7
|
+
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
8
|
+
// depend on a bundler alias to `@/lib/api`.
|
|
9
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
DialogDescription,
|
|
17
|
+
DialogFooter,
|
|
18
|
+
AlertDialog,
|
|
19
|
+
AlertDialogAction,
|
|
20
|
+
AlertDialogCancel,
|
|
21
|
+
AlertDialogContent,
|
|
22
|
+
AlertDialogDescription,
|
|
23
|
+
AlertDialogFooter,
|
|
24
|
+
AlertDialogHeader,
|
|
25
|
+
AlertDialogTitle,
|
|
26
|
+
Button,
|
|
27
|
+
Input,
|
|
28
|
+
Textarea,
|
|
29
|
+
Label,
|
|
30
|
+
Select,
|
|
31
|
+
SelectContent,
|
|
32
|
+
SelectItem,
|
|
33
|
+
SelectTrigger,
|
|
34
|
+
SelectValue,
|
|
35
|
+
Switch,
|
|
36
|
+
} from '@asteby/metacore-ui/primitives'
|
|
37
|
+
import { Loader2 } from 'lucide-react'
|
|
38
|
+
import { toast } from 'sonner'
|
|
39
|
+
import { useApi } from './api-context'
|
|
40
|
+
import { DynamicIcon } from './dynamic-icon'
|
|
41
|
+
// Canonical registry lives in @asteby/metacore-sdk
|
|
42
|
+
import {
|
|
43
|
+
type ActionMetadata,
|
|
44
|
+
type ActionModalProps,
|
|
45
|
+
getActionComponent,
|
|
46
|
+
} from '@asteby/metacore-sdk'
|
|
47
|
+
|
|
48
|
+
export type { ActionMetadata, ActionModalProps }
|
|
49
|
+
|
|
50
|
+
export function ActionModalDispatcher({
|
|
51
|
+
open,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
action,
|
|
54
|
+
model,
|
|
55
|
+
record,
|
|
56
|
+
endpoint,
|
|
57
|
+
onSuccess,
|
|
58
|
+
}: ActionModalProps) {
|
|
59
|
+
const CustomComponent = useMemo(
|
|
60
|
+
() => getActionComponent(model, action.key),
|
|
61
|
+
[model, action.key],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (CustomComponent) {
|
|
65
|
+
return (
|
|
66
|
+
<CustomComponent
|
|
67
|
+
open={open}
|
|
68
|
+
onOpenChange={onOpenChange}
|
|
69
|
+
action={action}
|
|
70
|
+
model={model}
|
|
71
|
+
record={record}
|
|
72
|
+
endpoint={endpoint}
|
|
73
|
+
onSuccess={onSuccess}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (action.fields && action.fields.length > 0) {
|
|
79
|
+
return (
|
|
80
|
+
<GenericActionModal
|
|
81
|
+
open={open}
|
|
82
|
+
onOpenChange={onOpenChange}
|
|
83
|
+
action={action}
|
|
84
|
+
model={model}
|
|
85
|
+
record={record}
|
|
86
|
+
endpoint={endpoint}
|
|
87
|
+
onSuccess={onSuccess}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (action.confirm) {
|
|
93
|
+
return (
|
|
94
|
+
<ConfirmActionDialog
|
|
95
|
+
open={open}
|
|
96
|
+
onOpenChange={onOpenChange}
|
|
97
|
+
action={action}
|
|
98
|
+
model={model}
|
|
99
|
+
record={record}
|
|
100
|
+
endpoint={endpoint}
|
|
101
|
+
onSuccess={onSuccess}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildActionUrl(endpoint: string | undefined, model: string, recordId: string, actionKey: string) {
|
|
110
|
+
return endpoint ? `${endpoint}/${recordId}/action/${actionKey}` : `/data/${model}/me/${recordId}/action/${actionKey}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
114
|
+
const { t } = useTranslation()
|
|
115
|
+
const api = useApi()
|
|
116
|
+
const [executing, setExecuting] = useState(false)
|
|
117
|
+
|
|
118
|
+
const execute = async () => {
|
|
119
|
+
setExecuting(true)
|
|
120
|
+
try {
|
|
121
|
+
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
122
|
+
const res = await api.post(url, {})
|
|
123
|
+
if (res.data.success) {
|
|
124
|
+
toast.success(res.data.message || t('common.success'))
|
|
125
|
+
onOpenChange(false)
|
|
126
|
+
onSuccess()
|
|
127
|
+
} else {
|
|
128
|
+
toast.error(res.data.message || t('common.error'))
|
|
129
|
+
}
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
toast.error(err?.response?.data?.message || t('common.error'))
|
|
132
|
+
} finally {
|
|
133
|
+
setExecuting(false)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
139
|
+
<AlertDialogContent>
|
|
140
|
+
<AlertDialogHeader>
|
|
141
|
+
<AlertDialogTitle className="flex items-center gap-2">
|
|
142
|
+
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
143
|
+
{action.label}
|
|
144
|
+
</AlertDialogTitle>
|
|
145
|
+
<AlertDialogDescription>
|
|
146
|
+
{action.confirmMessage || `${action.label}?`}
|
|
147
|
+
</AlertDialogDescription>
|
|
148
|
+
</AlertDialogHeader>
|
|
149
|
+
<AlertDialogFooter>
|
|
150
|
+
<AlertDialogCancel disabled={executing}>{t('common.cancel')}</AlertDialogCancel>
|
|
151
|
+
<AlertDialogAction
|
|
152
|
+
onClick={(e: React.MouseEvent) => { e.preventDefault(); execute() }}
|
|
153
|
+
disabled={executing}
|
|
154
|
+
style={action.color ? { backgroundColor: action.color } : undefined}
|
|
155
|
+
>
|
|
156
|
+
{executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
|
|
157
|
+
{action.label}
|
|
158
|
+
</AlertDialogAction>
|
|
159
|
+
</AlertDialogFooter>
|
|
160
|
+
</AlertDialogContent>
|
|
161
|
+
</AlertDialog>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
166
|
+
const { t } = useTranslation()
|
|
167
|
+
const api = useApi()
|
|
168
|
+
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
169
|
+
const [executing, setExecuting] = useState(false)
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (open && action.fields) {
|
|
173
|
+
const defaults: Record<string, any> = {}
|
|
174
|
+
for (const field of action.fields) {
|
|
175
|
+
defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
|
|
176
|
+
}
|
|
177
|
+
setFormData(defaults)
|
|
178
|
+
}
|
|
179
|
+
}, [open, action.fields])
|
|
180
|
+
|
|
181
|
+
const updateField = (key: string, value: any) => setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
182
|
+
|
|
183
|
+
const execute = async () => {
|
|
184
|
+
if (action.fields) {
|
|
185
|
+
for (const field of action.fields) {
|
|
186
|
+
if (field.required && !formData[field.key] && formData[field.key] !== false) {
|
|
187
|
+
toast.error(`${field.label} es requerido`)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
setExecuting(true)
|
|
193
|
+
try {
|
|
194
|
+
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
195
|
+
const res = await api.post(url, formData)
|
|
196
|
+
if (res.data.success) {
|
|
197
|
+
toast.success(res.data.message || t('common.success'))
|
|
198
|
+
onOpenChange(false)
|
|
199
|
+
onSuccess()
|
|
200
|
+
} else {
|
|
201
|
+
toast.error(res.data.message || t('common.error'))
|
|
202
|
+
}
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
toast.error(err?.response?.data?.message || t('common.error'))
|
|
205
|
+
} finally {
|
|
206
|
+
setExecuting(false)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
212
|
+
<DialogContent className="sm:max-w-lg">
|
|
213
|
+
<DialogHeader>
|
|
214
|
+
<DialogTitle className="flex items-center gap-2">
|
|
215
|
+
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
216
|
+
{action.label}
|
|
217
|
+
</DialogTitle>
|
|
218
|
+
{action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
|
|
219
|
+
</DialogHeader>
|
|
220
|
+
<div className="grid gap-4 py-4">
|
|
221
|
+
{action.fields?.map((field) => (
|
|
222
|
+
<div key={field.key} className="grid gap-2">
|
|
223
|
+
<Label htmlFor={field.key}>
|
|
224
|
+
{field.label}
|
|
225
|
+
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
226
|
+
</Label>
|
|
227
|
+
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v))}
|
|
228
|
+
</div>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
<DialogFooter>
|
|
232
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
|
|
233
|
+
{t('common.cancel')}
|
|
234
|
+
</Button>
|
|
235
|
+
<Button
|
|
236
|
+
onClick={execute}
|
|
237
|
+
disabled={executing}
|
|
238
|
+
style={action.color ? { backgroundColor: action.color, color: 'white' } : undefined}
|
|
239
|
+
>
|
|
240
|
+
{executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
|
|
241
|
+
{action.label}
|
|
242
|
+
</Button>
|
|
243
|
+
</DialogFooter>
|
|
244
|
+
</DialogContent>
|
|
245
|
+
</Dialog>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderField(
|
|
250
|
+
field: { key: string; type: string; options?: { value: string; label: string }[]; placeholder?: string },
|
|
251
|
+
value: any,
|
|
252
|
+
onChange: (value: any) => void,
|
|
253
|
+
) {
|
|
254
|
+
switch (field.type) {
|
|
255
|
+
case 'textarea':
|
|
256
|
+
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
257
|
+
case 'select':
|
|
258
|
+
return (
|
|
259
|
+
<Select value={value || ''} onValueChange={onChange}>
|
|
260
|
+
<SelectTrigger><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
|
|
261
|
+
<SelectContent>
|
|
262
|
+
{field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
|
263
|
+
</SelectContent>
|
|
264
|
+
</Select>
|
|
265
|
+
)
|
|
266
|
+
case 'boolean':
|
|
267
|
+
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
268
|
+
case 'number':
|
|
269
|
+
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
|
|
270
|
+
case 'date':
|
|
271
|
+
return <Input id={field.key} type="date" value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
|
|
272
|
+
default:
|
|
273
|
+
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Minimal federated-module addon loader. Injects a remoteEntry.js <script>,
|
|
2
|
+
// waits for the `window[scope]` container to initialize, then calls the
|
|
3
|
+
// addon's `register(api)` export with the AddonAPI injected by the host.
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
5
|
+
import type { AddonAPI } from '@asteby/metacore-sdk'
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
[key: string]: any
|
|
10
|
+
__webpack_init_sharing__?: (scope: string) => Promise<void>
|
|
11
|
+
__webpack_share_scopes__?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AddonLoaderProps {
|
|
16
|
+
/** Unique key of the addon — maps to the federation container name. */
|
|
17
|
+
scope: string
|
|
18
|
+
/** URL of the addon's remoteEntry.js bundle. */
|
|
19
|
+
url: string
|
|
20
|
+
/** Exposed module to import from the remote (e.g. './register'). */
|
|
21
|
+
module?: string
|
|
22
|
+
/** Host-provided API passed to the addon's register() call. */
|
|
23
|
+
api: AddonAPI
|
|
24
|
+
/** Optional rendering while loading. */
|
|
25
|
+
fallback?: React.ReactNode
|
|
26
|
+
/** Called once the addon has successfully registered. */
|
|
27
|
+
onReady?: () => void
|
|
28
|
+
/** Called if loading fails. */
|
|
29
|
+
onError?: (err: Error) => void
|
|
30
|
+
children?: React.ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const loadedScripts = new Map<string, Promise<void>>()
|
|
34
|
+
|
|
35
|
+
function loadScript(url: string, scope: string): Promise<void> {
|
|
36
|
+
const key = `${scope}::${url}`
|
|
37
|
+
const existing = loadedScripts.get(key)
|
|
38
|
+
if (existing) return existing
|
|
39
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
40
|
+
const el = document.createElement('script')
|
|
41
|
+
el.src = url
|
|
42
|
+
el.type = 'text/javascript'
|
|
43
|
+
el.async = true
|
|
44
|
+
el.onload = () => resolve()
|
|
45
|
+
el.onerror = () => reject(new Error(`Failed to load addon script: ${url}`))
|
|
46
|
+
document.head.appendChild(el)
|
|
47
|
+
})
|
|
48
|
+
loadedScripts.set(key, promise)
|
|
49
|
+
return promise
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FederationContainer {
|
|
53
|
+
init: (shareScope: unknown) => Promise<void>
|
|
54
|
+
get: (module: string) => Promise<() => any>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadRemote(scope: string, module: string) {
|
|
58
|
+
if (typeof window.__webpack_init_sharing__ === 'function') {
|
|
59
|
+
await window.__webpack_init_sharing__('default')
|
|
60
|
+
}
|
|
61
|
+
const container = window[scope] as FederationContainer | undefined
|
|
62
|
+
if (!container) throw new Error(`Addon container "${scope}" not found on window`)
|
|
63
|
+
if (typeof container.init === 'function' && window.__webpack_share_scopes__) {
|
|
64
|
+
await container.init(window.__webpack_share_scopes__.default)
|
|
65
|
+
}
|
|
66
|
+
const factory = await container.get(module)
|
|
67
|
+
return factory()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function AddonLoader({
|
|
71
|
+
scope,
|
|
72
|
+
url,
|
|
73
|
+
module = './register',
|
|
74
|
+
api,
|
|
75
|
+
fallback = null,
|
|
76
|
+
onReady,
|
|
77
|
+
onError,
|
|
78
|
+
children,
|
|
79
|
+
}: AddonLoaderProps) {
|
|
80
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
81
|
+
const [error, setError] = useState<Error | null>(null)
|
|
82
|
+
const didRegister = useRef(false)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let cancelled = false
|
|
86
|
+
;(async () => {
|
|
87
|
+
try {
|
|
88
|
+
await loadScript(url, scope)
|
|
89
|
+
if (cancelled) return
|
|
90
|
+
const mod = await loadRemote(scope, module)
|
|
91
|
+
if (cancelled) return
|
|
92
|
+
if (!didRegister.current && typeof mod?.register === 'function') {
|
|
93
|
+
didRegister.current = true
|
|
94
|
+
await Promise.resolve(mod.register(api))
|
|
95
|
+
}
|
|
96
|
+
setStatus('ready')
|
|
97
|
+
onReady?.()
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
setError(e)
|
|
101
|
+
setStatus('error')
|
|
102
|
+
onError?.(e)
|
|
103
|
+
}
|
|
104
|
+
})()
|
|
105
|
+
return () => { cancelled = true }
|
|
106
|
+
}, [scope, url, module])
|
|
107
|
+
|
|
108
|
+
if (status === 'loading') return <>{fallback}</>
|
|
109
|
+
if (status === 'error') return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
|
|
110
|
+
return <>{children}</>
|
|
111
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ApiContext — the host injects its HTTP client (axios-like interface) so
|
|
2
|
+
// runtime-react components (DynamicTable, dialogs, action dispatcher) can
|
|
3
|
+
// talk to the backend without a bundler alias to `@/lib/api`. Hosts wrap
|
|
4
|
+
// their app in <ApiProvider value={axiosInstance}> once at the root.
|
|
5
|
+
import React, { createContext, useContext } from 'react'
|
|
6
|
+
|
|
7
|
+
/** Minimal axios-compatible client shape consumed by runtime-react. */
|
|
8
|
+
export interface ApiClient {
|
|
9
|
+
get: (url: string, config?: any) => Promise<{ data: any; headers?: any }>
|
|
10
|
+
post: (url: string, body?: any, config?: any) => Promise<{ data: any; headers?: any }>
|
|
11
|
+
put: (url: string, body?: any, config?: any) => Promise<{ data: any; headers?: any }>
|
|
12
|
+
delete: (url: string, config?: any) => Promise<{ data: any; headers?: any }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ApiContext = createContext<ApiClient | null>(null)
|
|
16
|
+
|
|
17
|
+
export interface ApiProviderProps {
|
|
18
|
+
client: ApiClient
|
|
19
|
+
children: React.ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
23
|
+
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
27
|
+
export function useApi(): ApiClient {
|
|
28
|
+
const ctx = useContext(ApiContext)
|
|
29
|
+
if (!ctx) {
|
|
30
|
+
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
|
|
31
|
+
}
|
|
32
|
+
return ctx
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Optional branch context — hosts that support tenant branches can supply
|
|
36
|
+
* a `currentBranch` so DynamicTable resets pagination/selection on branch
|
|
37
|
+
* switches. Hosts without branches can omit this provider entirely. */
|
|
38
|
+
export interface BranchState {
|
|
39
|
+
id: string | number | null | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BranchContext = createContext<BranchState>({ id: undefined })
|
|
43
|
+
|
|
44
|
+
export interface BranchProviderProps {
|
|
45
|
+
branch: BranchState
|
|
46
|
+
children: React.ReactNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function BranchProvider({ branch, children }: BranchProviderProps) {
|
|
50
|
+
return <BranchContext.Provider value={branch}>{children}</BranchContext.Provider>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useCurrentBranch(): BranchState {
|
|
54
|
+
return useContext(BranchContext)
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// CapabilityGate — conditionally renders its children based on the current
|
|
2
|
+
// user's capability set. Capabilities are sourced from AddonAPI.capabilities
|
|
3
|
+
// or a React context the host provides.
|
|
4
|
+
import React, { createContext, useContext, useMemo } from 'react'
|
|
5
|
+
|
|
6
|
+
export type CapabilitySet = ReadonlySet<string> | string[] | Record<string, boolean>
|
|
7
|
+
|
|
8
|
+
interface CapabilityContextValue {
|
|
9
|
+
has: (capability: string) => boolean
|
|
10
|
+
all: (capabilities: string[]) => boolean
|
|
11
|
+
any: (capabilities: string[]) => boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CapabilityContext = createContext<CapabilityContextValue>({
|
|
15
|
+
has: () => false,
|
|
16
|
+
all: () => false,
|
|
17
|
+
any: () => false,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export interface CapabilityProviderProps {
|
|
21
|
+
capabilities: CapabilitySet
|
|
22
|
+
children: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalize(cs: CapabilitySet): Set<string> {
|
|
26
|
+
if (cs instanceof Set) return cs as Set<string>
|
|
27
|
+
if (Array.isArray(cs)) return new Set(cs)
|
|
28
|
+
return new Set(Object.entries(cs).filter(([, v]) => v).map(([k]) => k))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CapabilityProvider({ capabilities, children }: CapabilityProviderProps) {
|
|
32
|
+
const value = useMemo<CapabilityContextValue>(() => {
|
|
33
|
+
const set = normalize(capabilities)
|
|
34
|
+
return {
|
|
35
|
+
has: (c) => set.has(c),
|
|
36
|
+
all: (cs) => cs.every(c => set.has(c)),
|
|
37
|
+
any: (cs) => cs.some(c => set.has(c)),
|
|
38
|
+
}
|
|
39
|
+
}, [capabilities])
|
|
40
|
+
return <CapabilityContext.Provider value={value}>{children}</CapabilityContext.Provider>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useCapabilities() {
|
|
44
|
+
return useContext(CapabilityContext)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CapabilityGateProps {
|
|
48
|
+
/** Single capability required to render children. */
|
|
49
|
+
require?: string
|
|
50
|
+
/** All of these capabilities must be present. */
|
|
51
|
+
all?: string[]
|
|
52
|
+
/** At least one of these capabilities must be present. */
|
|
53
|
+
any?: string[]
|
|
54
|
+
/** Content rendered when the user lacks the required capabilities. */
|
|
55
|
+
fallback?: React.ReactNode
|
|
56
|
+
/** Optional negation: render children when capability is ABSENT. */
|
|
57
|
+
invert?: boolean
|
|
58
|
+
children: React.ReactNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function CapabilityGate({ require, all, any, fallback = null, invert = false, children }: CapabilityGateProps) {
|
|
62
|
+
const ctx = useCapabilities()
|
|
63
|
+
let allowed = true
|
|
64
|
+
if (require) allowed = allowed && ctx.has(require)
|
|
65
|
+
if (all && all.length) allowed = allowed && ctx.all(all)
|
|
66
|
+
if (any && any.length) allowed = allowed && ctx.any(any)
|
|
67
|
+
const show = invert ? !allowed : allowed
|
|
68
|
+
return <>{show ? children : fallback}</>
|
|
69
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Minimal local primitives used by the dialogs copied into runtime-react.
|
|
2
|
+
// These are intentionally dependency-free shims — hosts that want richer
|
|
3
|
+
// visuals (date picker calendar, radix progress, radix radio-group) should
|
|
4
|
+
// override by wrapping these components. The UI package does not currently
|
|
5
|
+
// export these three primitives so we keep them inside the runtime.
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
|
|
8
|
+
// ── Progress ────────────────────────────────────────────────────────────────
|
|
9
|
+
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
value?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Progress({ value = 0, className = '', ...props }: ProgressProps) {
|
|
14
|
+
const pct = Math.max(0, Math.min(100, value))
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
role="progressbar"
|
|
18
|
+
aria-valuenow={pct}
|
|
19
|
+
aria-valuemin={0}
|
|
20
|
+
aria-valuemax={100}
|
|
21
|
+
className={`relative h-2 w-full overflow-hidden rounded-full bg-secondary ${className}`}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
<div
|
|
25
|
+
className="h-full bg-primary transition-all"
|
|
26
|
+
style={{ width: `${pct}%` }}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── RadioGroup ──────────────────────────────────────────────────────────────
|
|
33
|
+
interface RadioGroupContextValue {
|
|
34
|
+
value: string
|
|
35
|
+
onChange: (value: string) => void
|
|
36
|
+
name: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null)
|
|
40
|
+
|
|
41
|
+
export interface RadioGroupProps {
|
|
42
|
+
value: string
|
|
43
|
+
onValueChange: (value: string) => void
|
|
44
|
+
className?: string
|
|
45
|
+
children: React.ReactNode
|
|
46
|
+
name?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function RadioGroup({ value, onValueChange, className = '', children, name = 'radio-group' }: RadioGroupProps) {
|
|
50
|
+
return (
|
|
51
|
+
<RadioGroupContext.Provider value={{ value, onChange: onValueChange, name }}>
|
|
52
|
+
<div role="radiogroup" className={className}>{children}</div>
|
|
53
|
+
</RadioGroupContext.Provider>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RadioGroupItemProps {
|
|
58
|
+
value: string
|
|
59
|
+
id?: string
|
|
60
|
+
className?: string
|
|
61
|
+
disabled?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function RadioGroupItem({ value, id, className = '', disabled }: RadioGroupItemProps) {
|
|
65
|
+
const ctx = React.useContext(RadioGroupContext)
|
|
66
|
+
if (!ctx) throw new Error('RadioGroupItem must be used inside <RadioGroup>')
|
|
67
|
+
const checked = ctx.value === value
|
|
68
|
+
return (
|
|
69
|
+
<input
|
|
70
|
+
type="radio"
|
|
71
|
+
id={id}
|
|
72
|
+
name={ctx.name}
|
|
73
|
+
value={value}
|
|
74
|
+
checked={checked}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
onChange={() => ctx.onChange(value)}
|
|
77
|
+
className={`h-4 w-4 border-primary text-primary ${className}`}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Calendar (minimal) ──────────────────────────────────────────────────────
|
|
83
|
+
// We expose a tiny date-input based Calendar so the record-dialog still
|
|
84
|
+
// renders when no host-provided calendar is available. Hosts wanting a full
|
|
85
|
+
// calendar grid can pass their own via the `calendar` prop on DynamicTable
|
|
86
|
+
// (not wired here) or wrap the dialog. For the build to succeed, this shim
|
|
87
|
+
// is sufficient.
|
|
88
|
+
export interface CalendarProps {
|
|
89
|
+
mode?: 'single'
|
|
90
|
+
selected?: Date
|
|
91
|
+
onSelect?: (date: Date | undefined) => void
|
|
92
|
+
locale?: any
|
|
93
|
+
className?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function Calendar({ selected, onSelect, className = '' }: CalendarProps) {
|
|
97
|
+
const value = selected && !isNaN(selected.getTime())
|
|
98
|
+
? selected.toISOString().slice(0, 10)
|
|
99
|
+
: ''
|
|
100
|
+
return (
|
|
101
|
+
<div className={`p-3 ${className}`}>
|
|
102
|
+
<input
|
|
103
|
+
type="date"
|
|
104
|
+
value={value}
|
|
105
|
+
onChange={(e) => {
|
|
106
|
+
const v = e.target.value
|
|
107
|
+
if (!v) { onSelect?.(undefined); return }
|
|
108
|
+
onSelect?.(new Date(v + 'T00:00:00'))
|
|
109
|
+
}}
|
|
110
|
+
className="w-full rounded-md border px-3 py-2 text-sm"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|