@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +201 -0
  3. package/README.md +59 -0
  4. package/dist/action-modal-dispatcher.d.ts +4 -0
  5. package/dist/action-modal-dispatcher.d.ts.map +1 -0
  6. package/dist/action-modal-dispatcher.js +123 -0
  7. package/dist/addon-loader.d.ts +27 -0
  8. package/dist/addon-loader.d.ts.map +1 -0
  9. package/dist/addon-loader.js +73 -0
  10. package/dist/api-context.d.ts +40 -0
  11. package/dist/api-context.d.ts.map +1 -0
  12. package/dist/api-context.js +25 -0
  13. package/dist/capability-gate.d.ts +29 -0
  14. package/dist/capability-gate.d.ts.map +1 -0
  15. package/dist/capability-gate.js +43 -0
  16. package/dist/dialogs/_primitives.d.ts +29 -0
  17. package/dist/dialogs/_primitives.d.ts.map +1 -0
  18. package/dist/dialogs/_primitives.js +35 -0
  19. package/dist/dialogs/dynamic-record.d.ts +11 -0
  20. package/dist/dialogs/dynamic-record.d.ts.map +1 -0
  21. package/dist/dialogs/dynamic-record.js +377 -0
  22. package/dist/dialogs/export.d.ts +12 -0
  23. package/dist/dialogs/export.d.ts.map +1 -0
  24. package/dist/dialogs/export.js +146 -0
  25. package/dist/dialogs/import.d.ts +11 -0
  26. package/dist/dialogs/import.d.ts.map +1 -0
  27. package/dist/dialogs/import.js +128 -0
  28. package/dist/dynamic-columns-shim.d.ts +25 -0
  29. package/dist/dynamic-columns-shim.d.ts.map +1 -0
  30. package/dist/dynamic-columns-shim.js +1 -0
  31. package/dist/dynamic-form.d.ts +12 -0
  32. package/dist/dynamic-form.d.ts.map +1 -0
  33. package/dist/dynamic-form.js +51 -0
  34. package/dist/dynamic-icon.d.ts +6 -0
  35. package/dist/dynamic-icon.d.ts.map +1 -0
  36. package/dist/dynamic-icon.js +11 -0
  37. package/dist/dynamic-table.d.ts +22 -0
  38. package/dist/dynamic-table.d.ts.map +1 -0
  39. package/dist/dynamic-table.js +516 -0
  40. package/dist/i18n-provider.d.ts +16 -0
  41. package/dist/i18n-provider.d.ts.map +1 -0
  42. package/dist/i18n-provider.js +16 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +21 -0
  46. package/dist/metadata-cache.d.ts +42 -0
  47. package/dist/metadata-cache.d.ts.map +1 -0
  48. package/dist/metadata-cache.js +71 -0
  49. package/dist/navigation-builder.d.ts +34 -0
  50. package/dist/navigation-builder.d.ts.map +1 -0
  51. package/dist/navigation-builder.js +45 -0
  52. package/dist/options-context.d.ts +8 -0
  53. package/dist/options-context.d.ts.map +1 -0
  54. package/dist/options-context.js +5 -0
  55. package/dist/slot.d.ts +32 -0
  56. package/dist/slot.d.ts.map +1 -0
  57. package/dist/slot.js +45 -0
  58. package/dist/types.d.ts +114 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +1 -0
  61. package/package.json +67 -0
  62. package/src/action-modal-dispatcher.tsx +275 -0
  63. package/src/addon-loader.tsx +111 -0
  64. package/src/api-context.tsx +55 -0
  65. package/src/capability-gate.tsx +69 -0
  66. package/src/dialogs/_primitives.tsx +114 -0
  67. package/src/dialogs/dynamic-record.tsx +770 -0
  68. package/src/dialogs/export.tsx +339 -0
  69. package/src/dialogs/import.tsx +404 -0
  70. package/src/dynamic-columns-shim.ts +36 -0
  71. package/src/dynamic-form.tsx +108 -0
  72. package/src/dynamic-icon.tsx +15 -0
  73. package/src/dynamic-table.tsx +766 -0
  74. package/src/i18n-provider.tsx +33 -0
  75. package/src/index.ts +30 -0
  76. package/src/metadata-cache.ts +103 -0
  77. package/src/navigation-builder.tsx +77 -0
  78. package/src/options-context.tsx +11 -0
  79. package/src/slot.tsx +77 -0
  80. package/src/types.ts +112 -0
  81. 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
+ }