@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,33 @@
1
+ // I18nProvider — merges `manifest.i18n` resources from every loaded addon
2
+ // into the host's i18next instance. The host passes the i18n instance; this
3
+ // component just hydrates new namespaces and keeps them live.
4
+ import { useEffect } from 'react'
5
+ import type { i18n as I18nInstance } from 'i18next'
6
+
7
+ export interface AddonI18nResources {
8
+ /** Addon key — used as the i18next namespace. */
9
+ source: string
10
+ /** Map of locale → key/value tree. */
11
+ resources: Record<string, Record<string, any>>
12
+ }
13
+
14
+ export interface I18nProviderProps {
15
+ /** The host's i18next instance. */
16
+ i18n: I18nInstance
17
+ /** All addon translations contributed via `manifest.i18n`. */
18
+ contributions: AddonI18nResources[]
19
+ children: React.ReactNode
20
+ }
21
+
22
+ export function I18nProvider({ i18n, contributions, children }: I18nProviderProps) {
23
+ useEffect(() => {
24
+ for (const c of contributions) {
25
+ for (const [locale, tree] of Object.entries(c.resources)) {
26
+ // addBundle(locale, namespace, resources, deep?, overwrite?)
27
+ i18n.addResourceBundle(locale, c.source, tree, true, false)
28
+ }
29
+ }
30
+ }, [i18n, contributions])
31
+
32
+ return <>{children}</>
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Public surface — keep names stable. `ActionMetadata` is intentionally
2
+ // re-exported once from `./types` (the mirror used by runtime-react internally)
3
+ // and NOT re-exported from `./action-modal-dispatcher` here to avoid a
4
+ // duplicate-symbol conflict; consumers who want the canonical SDK type
5
+ // should import from `@asteby/metacore-sdk` directly.
6
+ export * from './types'
7
+ export * from './options-context'
8
+ export * from './dynamic-table'
9
+ export * from './dynamic-form'
10
+ export {
11
+ ActionModalDispatcher,
12
+ type ActionModalProps,
13
+ } from './action-modal-dispatcher'
14
+ export * from './addon-loader'
15
+ export * from './slot'
16
+ export * from './capability-gate'
17
+ export * from './navigation-builder'
18
+ export * from './i18n-provider'
19
+ export * from './api-context'
20
+ export * from './metadata-cache'
21
+ export * from './dynamic-icon'
22
+ export type {
23
+ ColumnFilterConfig,
24
+ FilterOption as DynamicColumnFilterOption,
25
+ GetDynamicColumns,
26
+ DynamicIconComponent,
27
+ } from './dynamic-columns-shim'
28
+ export { DynamicRecordDialog } from './dialogs/dynamic-record'
29
+ export { ExportDialog } from './dialogs/export'
30
+ export { ImportDialog } from './dialogs/import'
@@ -0,0 +1,103 @@
1
+ // Metadata cache — a zustand store that memoizes table/modal metadata
2
+ // responses across dynamic-table mounts. Ported from the ops starter's
3
+ // `@/stores/metadata-cache` so the runtime-react package no longer depends
4
+ // on a host-specific alias.
5
+ //
6
+ // The prefetchAll() method needs an `api` client (axios-like); we keep that
7
+ // as an injectable parameter so the store stays host-agnostic. If a caller
8
+ // never invokes prefetchAll, the `api` dep is not required.
9
+ import { create } from 'zustand'
10
+ import { persist } from 'zustand/middleware'
11
+ import type { TableMetadata } from './types'
12
+
13
+ export interface MetadataApiClient {
14
+ get: (url: string, config?: any) => Promise<{ data: any }>
15
+ }
16
+
17
+ interface MetadataCacheState {
18
+ cache: Record<string, TableMetadata>
19
+ modalCache: Record<string, TableMetadata>
20
+ metadataVersion: string
21
+ prefetched: boolean
22
+ getMetadata: (key: string) => TableMetadata | undefined
23
+ getModalMetadata: (key: string) => TableMetadata | undefined
24
+ setMetadata: (key: string, metadata: TableMetadata) => void
25
+ setModalMetadata: (key: string, metadata: TableMetadata) => void
26
+ hasMetadata: (key: string) => boolean
27
+ hasModalMetadata: (key: string) => boolean
28
+ prefetchAll: (api: MetadataApiClient) => Promise<void>
29
+ }
30
+
31
+ export const useMetadataCache = create<MetadataCacheState>()(
32
+ persist(
33
+ (set, get) => ({
34
+ cache: {},
35
+ modalCache: {},
36
+ metadataVersion: '',
37
+ prefetched: false,
38
+
39
+ getMetadata: (key: string) => get().cache[key],
40
+ getModalMetadata: (key: string) => get().modalCache[key],
41
+
42
+ setMetadata: (key: string, metadata: TableMetadata) => {
43
+ set((state) => ({
44
+ cache: { ...state.cache, [key]: metadata },
45
+ }))
46
+ },
47
+
48
+ setModalMetadata: (key: string, metadata: TableMetadata) => {
49
+ set((state) => ({
50
+ modalCache: { ...state.modalCache, [key]: metadata },
51
+ }))
52
+ },
53
+
54
+ hasMetadata: (key: string) => key in get().cache,
55
+ hasModalMetadata: (key: string) => key in get().modalCache,
56
+
57
+ prefetchAll: async (api: MetadataApiClient) => {
58
+ if (get().prefetched) return
59
+ try {
60
+ const res = await api.get('/metadata/all')
61
+ const { tables, modals, version } = res.data.data
62
+
63
+ const serverVersion = version || ''
64
+ const localVersion = get().metadataVersion
65
+ const versionChanged = serverVersion !== localVersion && localVersion !== ''
66
+
67
+ const newCache: Record<string, TableMetadata> = versionChanged ? {} : { ...get().cache }
68
+ const newModalCache: Record<string, TableMetadata> = versionChanged ? {} : { ...get().modalCache }
69
+
70
+ if (tables) {
71
+ for (const [key, meta] of Object.entries(tables)) {
72
+ newCache[key] = meta as TableMetadata
73
+ }
74
+ }
75
+ if (modals) {
76
+ for (const [key, meta] of Object.entries(modals)) {
77
+ newModalCache[key] = meta as TableMetadata
78
+ }
79
+ }
80
+
81
+ set({
82
+ cache: newCache,
83
+ modalCache: newModalCache,
84
+ metadataVersion: serverVersion,
85
+ prefetched: true,
86
+ })
87
+ } catch {
88
+ // Offline or error — keep using cached data.
89
+ set({ prefetched: true })
90
+ }
91
+ },
92
+ }),
93
+ {
94
+ name: 'metacore-metadata-cache',
95
+ version: 3,
96
+ partialize: (state) => ({
97
+ cache: state.cache,
98
+ modalCache: state.modalCache,
99
+ metadataVersion: state.metadataVersion,
100
+ }),
101
+ }
102
+ )
103
+ )
@@ -0,0 +1,77 @@
1
+ // NavigationBuilder — merges a host's base sidebar with `manifest.navigation`
2
+ // contributions from every loaded addon. Pure function + a React hook.
3
+ import { useMemo } from 'react'
4
+
5
+ export interface NavItem {
6
+ key: string
7
+ label: string
8
+ icon?: string
9
+ to?: string
10
+ /** Sort weight; higher = earlier. Default 0. */
11
+ priority?: number
12
+ /** Capability required to see this item. */
13
+ requires?: string
14
+ /** Nested children (rendered as a collapsible section). */
15
+ children?: NavItem[]
16
+ /** Group this item belongs to — items with the same group are clustered. */
17
+ group?: string
18
+ /** Source addon key (for debugging / telemetry). */
19
+ source?: string
20
+ }
21
+
22
+ export interface AddonNavigationContribution {
23
+ source: string
24
+ items: NavItem[]
25
+ }
26
+
27
+ /** Deep-merge nav trees by `key`. Children are merged recursively; on
28
+ * conflict the higher-priority wins, then the later contribution.
29
+ */
30
+ export function mergeNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
31
+ const byKey = new Map<string, NavItem>()
32
+ const order: string[] = []
33
+
34
+ const absorb = (items: NavItem[], source?: string) => {
35
+ for (const raw of items) {
36
+ const item: NavItem = { ...raw, source: raw.source ?? source }
37
+ const existing = byKey.get(item.key)
38
+ if (!existing) {
39
+ byKey.set(item.key, item)
40
+ order.push(item.key)
41
+ continue
42
+ }
43
+ const a = existing.priority ?? 0
44
+ const b = item.priority ?? 0
45
+ const winner = b >= a ? item : existing
46
+ const loser = b >= a ? existing : item
47
+ const mergedChildren = (winner.children || loser.children)
48
+ ? mergeNavigation(winner.children ?? [], [{ source: winner.source ?? '', items: loser.children ?? [] }])
49
+ : undefined
50
+ byKey.set(item.key, { ...winner, children: mergedChildren })
51
+ }
52
+ }
53
+
54
+ absorb(base, 'host')
55
+ for (const c of contributions) absorb(c.items, c.source)
56
+
57
+ return order
58
+ .map(k => byKey.get(k)!)
59
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
60
+ }
61
+
62
+ export function useNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
63
+ return useMemo(() => mergeNavigation(base, contributions), [base, contributions])
64
+ }
65
+
66
+ export interface NavigationBuilderProps {
67
+ base: NavItem[]
68
+ contributions: AddonNavigationContribution[]
69
+ render: (items: NavItem[]) => React.ReactNode
70
+ }
71
+
72
+ /** Render-prop component for hosts that want the merge logic but render
73
+ * the sidebar with their own primitives. */
74
+ export function NavigationBuilder({ base, contributions, render }: NavigationBuilderProps) {
75
+ const items = useNavigation(base, contributions)
76
+ return <>{render(items)}</>
77
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react'
2
+
3
+ interface OptionsContextValue {
4
+ optionsMap: Map<string, any[]>
5
+ }
6
+
7
+ export const OptionsContext = React.createContext<OptionsContextValue>({
8
+ optionsMap: new Map(),
9
+ })
10
+
11
+ export const useOptions = () => React.useContext(OptionsContext)
package/src/slot.tsx ADDED
@@ -0,0 +1,77 @@
1
+ // Slot / SlotRegistry — named extension points the host renders and addons
2
+ // contribute to at register() time. Keyed by a slot id (e.g. "dashboard.widgets",
3
+ // "invoice.footer"). Each contribution is an arbitrary React element factory.
4
+ import React, { useSyncExternalStore } from 'react'
5
+
6
+ export type SlotComponent<P = any> = React.ComponentType<P>
7
+
8
+ interface SlotEntry {
9
+ id: string
10
+ component: SlotComponent
11
+ priority: number
12
+ source?: string
13
+ }
14
+
15
+ type Listener = () => void
16
+
17
+ class SlotRegistryImpl {
18
+ private slots = new Map<string, SlotEntry[]>()
19
+ private listeners = new Set<Listener>()
20
+
21
+ register(slotId: string, component: SlotComponent, opts?: { priority?: number; source?: string }): () => void {
22
+ const entry: SlotEntry = { id: slotId, component, priority: opts?.priority ?? 0, source: opts?.source }
23
+ const list = this.slots.get(slotId) ?? []
24
+ list.push(entry)
25
+ list.sort((a, b) => b.priority - a.priority)
26
+ this.slots.set(slotId, list)
27
+ this.emit()
28
+ return () => {
29
+ const arr = this.slots.get(slotId)
30
+ if (!arr) return
31
+ const idx = arr.indexOf(entry)
32
+ if (idx >= 0) {
33
+ arr.splice(idx, 1)
34
+ this.emit()
35
+ }
36
+ }
37
+ }
38
+
39
+ get(slotId: string): SlotEntry[] {
40
+ return this.slots.get(slotId) ?? []
41
+ }
42
+
43
+ subscribe(listener: Listener): () => void {
44
+ this.listeners.add(listener)
45
+ return () => { this.listeners.delete(listener) }
46
+ }
47
+
48
+ private emit() { this.listeners.forEach(l => l()) }
49
+ }
50
+
51
+ export const slotRegistry = new SlotRegistryImpl()
52
+
53
+ export interface SlotProps {
54
+ /** Slot id. */
55
+ name: string
56
+ /** Props forwarded to each contribution component. */
57
+ props?: Record<string, any>
58
+ /** Fallback element shown when no contribution is registered. */
59
+ fallback?: React.ReactNode
60
+ }
61
+
62
+ export function Slot({ name, props, fallback = null }: SlotProps) {
63
+ const entries = useSyncExternalStore(
64
+ (cb) => slotRegistry.subscribe(cb),
65
+ () => slotRegistry.get(name),
66
+ () => slotRegistry.get(name),
67
+ )
68
+ if (entries.length === 0) return <>{fallback}</>
69
+ return (
70
+ <>
71
+ {entries.map((entry, i) => {
72
+ const C = entry.component
73
+ return <C key={`${entry.source ?? 'anon'}-${i}`} {...(props ?? {})} />
74
+ })}
75
+ </>
76
+ )
77
+ }
package/src/types.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Union of the two host copies (link + ops). Ops adds the `link` action type + `linkUrl`.
2
+ export interface TableMetadata {
3
+ title: string
4
+ endpoint: string
5
+ columns: ColumnDefinition[]
6
+ actions: ActionDefinition[]
7
+ filters?: FilterDefinition[]
8
+ perPageOptions: number[]
9
+ defaultPerPage: number
10
+ searchPlaceholder: string
11
+ enableCRUDActions: boolean
12
+ hasActions: boolean
13
+ canExport?: boolean
14
+ canImport?: boolean
15
+ canCreate?: boolean
16
+ }
17
+
18
+ export interface FilterDefinition {
19
+ key: string
20
+ label: string
21
+ type: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text'
22
+ column: string
23
+ options?: { value: string | boolean; label: string; icon?: string; color?: string }[]
24
+ searchEndpoint?: string
25
+ }
26
+
27
+ export interface ColumnDefinition {
28
+ key: string
29
+ label: string
30
+ type: 'text' | 'number' | 'date' | 'select' | 'search' | 'relation-badge-list' | 'avatar' | 'boolean' | 'phone' | 'media-gallery' | 'image'
31
+ sortable: boolean
32
+ filterable: boolean
33
+ hidden?: boolean
34
+ styleConfig?: Record<string, any>
35
+ tooltip?: string
36
+ description?: string
37
+ cellStyle?: string
38
+ searchEndpoint?: string
39
+ filterField?: string
40
+ basePath?: string
41
+ displayField?: string
42
+ iconField?: string
43
+ relationPath?: string
44
+ useOptions?: boolean
45
+ options?: { value: string; label: string; icon?: string; color?: string }[]
46
+ }
47
+
48
+ export interface ActionCondition {
49
+ field: string
50
+ operator: 'eq' | 'neq' | 'in' | 'not_in'
51
+ value: string | string[]
52
+ }
53
+
54
+ export interface ActionFieldDef {
55
+ key: string
56
+ label: string
57
+ type: string
58
+ required?: boolean
59
+ options?: { value: string; label: string }[]
60
+ defaultValue?: any
61
+ placeholder?: string
62
+ searchEndpoint?: string
63
+ }
64
+
65
+ export interface ActionDefinition {
66
+ key: string
67
+ name: string
68
+ label: string
69
+ icon: string
70
+ class: string
71
+ color?: string
72
+ type: 'view' | 'edit' | 'delete' | 'custom' | 'link'
73
+ linkUrl?: string
74
+ condition?: ActionCondition
75
+ confirm?: boolean
76
+ confirmMessage?: string
77
+ fields?: ActionFieldDef[]
78
+ requiresState?: string[]
79
+ executable?: boolean
80
+ }
81
+
82
+ export interface ApiResponse<T> {
83
+ success: boolean
84
+ data: T
85
+ meta?: PaginationMeta
86
+ filters?: Record<string, any>
87
+ message?: string
88
+ }
89
+
90
+ export interface PaginationMeta {
91
+ current_page: number
92
+ from: number
93
+ last_page: number
94
+ per_page: number
95
+ to: number
96
+ total: number
97
+ }
98
+
99
+ // ActionMetadata re-exported from the sdk's action-registry. We mirror the
100
+ // subset needed for the dispatcher so consumers of runtime-react don't have to
101
+ // import the sdk directly for prop typings.
102
+ export interface ActionMetadata {
103
+ key: string
104
+ label: string
105
+ icon: string
106
+ color?: string
107
+ confirm?: boolean
108
+ confirmMessage?: string
109
+ fields?: ActionFieldDef[]
110
+ requiresState?: string[]
111
+ executable?: boolean
112
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "outDir": "./dist",
13
+ "rootDir": "./src"
14
+ },
15
+ "include": ["src/**/*"]
16
+ }