@asteby/metacore-runtime-react 39.1.0 → 39.2.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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * apply-sidebar-layout — pure overlay of an org sidebar layout onto a live
3
+ * catalog of leaves. Mirrors ops `services.ApplySidebarLayout` (Go). See
4
+ * CONTRACT-ORG-SIDEBAR-LAYOUT.md.
5
+ */
6
+
7
+ export type SidebarNodeType = 'group' | 'folder' | 'leaf'
8
+
9
+ export interface SidebarLayoutNode {
10
+ type: SidebarNodeType
11
+ key?: string
12
+ title?: string
13
+ icon?: string
14
+ ref?: string
15
+ hidden?: boolean
16
+ children?: SidebarLayoutNode[]
17
+ }
18
+
19
+ export interface SidebarLayoutDoc {
20
+ version?: number
21
+ tree: SidebarLayoutNode[]
22
+ }
23
+
24
+ export interface CatalogLeaf {
25
+ ref: string
26
+ title: string
27
+ url: string
28
+ icon?: string
29
+ /** Optional alternate refs that resolve to the same leaf. */
30
+ aliases?: string[]
31
+ }
32
+
33
+ export interface AppliedNavItem {
34
+ title: string
35
+ url: string
36
+ icon?: string
37
+ ref?: string
38
+ folderKey?: string
39
+ items?: AppliedNavItem[]
40
+ }
41
+
42
+ export interface AppliedNavGroup {
43
+ title: string
44
+ icon?: string
45
+ items: AppliedNavItem[]
46
+ }
47
+
48
+ /** Normalize a nav URL/path for stable refs. */
49
+ export function normalizeNavPath(raw: string): string {
50
+ const trimmed = (raw || '').trim()
51
+ if (!trimmed) return '/'
52
+ try {
53
+ const u = new URL(trimmed, 'http://local.invalid')
54
+ let path = u.pathname || '/'
55
+ if (path !== '/') path = path.replace(/\/+$/, '')
56
+ return u.search ? `${path}${u.search}` : path
57
+ } catch {
58
+ return trimmed.replace(/\/+$/, '') || '/'
59
+ }
60
+ }
61
+
62
+ export function coreRefKey(url: string): string {
63
+ return `core:${normalizeNavPath(url)}`
64
+ }
65
+
66
+ export function addonModelRefKey(addonKey: string, table: string, query = ''): string {
67
+ let ref = `addon:${addonKey.trim()}/model:${table.trim().toLowerCase()}`
68
+ const q = query.replace(/^\?/, '').trim()
69
+ if (q) ref += `?${q}`
70
+ return ref
71
+ }
72
+
73
+ export function addonUrlRefKey(addonKey: string, path: string): string {
74
+ return `addon:${addonKey.trim()}/url:${normalizeNavPath(path)}`
75
+ }
76
+
77
+ export function urlRefKey(url: string): string {
78
+ return `url:${normalizeNavPath(url)}`
79
+ }
80
+
81
+ export function folderAccessCapability(folderKey: string): string {
82
+ return `folder.${folderKey}.access`
83
+ }
84
+
85
+ /**
86
+ * Pick the best stable ref for a live nav leaf.
87
+ * Prefer addon-aware forms when metadata is known; fall back to url:/core:.
88
+ */
89
+ export function leafRefFor(
90
+ url: string,
91
+ meta?: { addonKey?: string; model?: string },
92
+ ): string {
93
+ const path = normalizeNavPath(url)
94
+ if (meta?.addonKey && meta?.model) {
95
+ const q = path.includes('?') ? path.slice(path.indexOf('?') + 1) : ''
96
+ return addonModelRefKey(meta.addonKey, meta.model, q)
97
+ }
98
+ const addonMatch = /^\/addons\/([^/]+)(?:\/(.*))?$/.exec(path.split('?')[0] ?? '')
99
+ if (addonMatch) {
100
+ const rest = addonMatch[2] ? `/${addonMatch[2]}` : '/'
101
+ const q = path.includes('?') ? path.slice(path.indexOf('?')) : ''
102
+ return addonUrlRefKey(addonMatch[1], rest + q)
103
+ }
104
+ if (path.startsWith('/m/')) return urlRefKey(path)
105
+ return coreRefKey(path)
106
+ }
107
+
108
+ function indexCatalog(catalog: CatalogLeaf[]): Map<string, CatalogLeaf> {
109
+ const map = new Map<string, CatalogLeaf>()
110
+ for (const leaf of catalog) {
111
+ if (!leaf.ref) continue
112
+ map.set(leaf.ref, leaf)
113
+ for (const a of leaf.aliases ?? []) map.set(a, leaf)
114
+ // Always index by url: form so layouts saved with either scheme resolve.
115
+ map.set(urlRefKey(leaf.url), leaf)
116
+ if (!leaf.url.startsWith('/m/') && !leaf.url.startsWith('/addons/')) {
117
+ map.set(coreRefKey(leaf.url), leaf)
118
+ }
119
+ }
120
+ return map
121
+ }
122
+
123
+ /**
124
+ * Apply an org layout onto a catalog. Returns `null` when the tree is empty
125
+ * (caller keeps the default merge).
126
+ */
127
+ export function applySidebarLayout(
128
+ catalog: CatalogLeaf[],
129
+ tree: SidebarLayoutNode[] | undefined | null,
130
+ ): AppliedNavGroup[] | null {
131
+ if (!tree || tree.length === 0) return null
132
+ const byRef = indexCatalog(catalog)
133
+ const groups: AppliedNavGroup[] = []
134
+
135
+ for (const n of tree) {
136
+ if (n.hidden) continue
137
+ switch (n.type) {
138
+ case 'group': {
139
+ const items = applyChildren(n.children ?? [], byRef)
140
+ if (items.length === 0) break
141
+ groups.push({ title: n.title || n.key || '', icon: n.icon, items })
142
+ break
143
+ }
144
+ case 'folder': {
145
+ const item = applyFolder(n, byRef)
146
+ if (!item) break
147
+ groups.push({ title: n.title || n.key || '', icon: n.icon, items: [item] })
148
+ break
149
+ }
150
+ case 'leaf': {
151
+ const item = applyLeaf(n, byRef)
152
+ if (item) groups.push({ title: 'sidebar.general', items: [item] })
153
+ break
154
+ }
155
+ }
156
+ }
157
+ return groups.length > 0 ? groups : null
158
+ }
159
+
160
+ function applyChildren(
161
+ nodes: SidebarLayoutNode[],
162
+ byRef: Map<string, CatalogLeaf>,
163
+ ): AppliedNavItem[] {
164
+ const out: AppliedNavItem[] = []
165
+ for (const n of nodes) {
166
+ if (n.hidden) continue
167
+ if (n.type === 'folder') {
168
+ const item = applyFolder(n, byRef)
169
+ if (item) out.push(item)
170
+ } else if (n.type === 'leaf') {
171
+ const item = applyLeaf(n, byRef)
172
+ if (item) out.push(item)
173
+ }
174
+ }
175
+ return out
176
+ }
177
+
178
+ function applyFolder(
179
+ n: SidebarLayoutNode,
180
+ byRef: Map<string, CatalogLeaf>,
181
+ ): AppliedNavItem | null {
182
+ const children = applyChildren(n.children ?? [], byRef)
183
+ if (children.length === 0) return null
184
+ return {
185
+ title: n.title || n.key || '',
186
+ url: children[0]?.url ?? '#',
187
+ icon: n.icon,
188
+ folderKey: n.key,
189
+ items: children,
190
+ }
191
+ }
192
+
193
+ function applyLeaf(
194
+ n: SidebarLayoutNode,
195
+ byRef: Map<string, CatalogLeaf>,
196
+ ): AppliedNavItem | null {
197
+ if (!n.ref) return null
198
+ const leaf = byRef.get(n.ref)
199
+ if (!leaf) return null
200
+ return {
201
+ title: n.title?.trim() ? n.title : leaf.title,
202
+ url: leaf.url,
203
+ icon: n.icon || leaf.icon,
204
+ ref: n.ref,
205
+ }
206
+ }
207
+
208
+ /** Collect folder keys from a tree (for permissions catalog). */
209
+ export function collectFolderKeys(tree: SidebarLayoutNode[]): string[] {
210
+ const out: string[] = []
211
+ const seen = new Set<string>()
212
+ const walk = (nodes: SidebarLayoutNode[]) => {
213
+ for (const n of nodes) {
214
+ if (n.type === 'folder' && n.key && !seen.has(n.key)) {
215
+ seen.add(n.key)
216
+ out.push(n.key)
217
+ }
218
+ if (n.children?.length) walk(n.children)
219
+ }
220
+ }
221
+ walk(tree)
222
+ return out
223
+ }
224
+
225
+ const FOLDER_KEY_RE = /^[a-z][a-z0-9_]{0,39}$/
226
+
227
+ export function slugifyFolderKey(title: string): string {
228
+ let s = title
229
+ .toLowerCase()
230
+ .trim()
231
+ .replace(/[^a-z0-9]+/g, '_')
232
+ .replace(/^_+|_+$/g, '')
233
+ if (!s) s = 'folder'
234
+ if (s.length > 40) s = s.slice(0, 40)
235
+ if (!/^[a-z]/.test(s)) s = `f_${s}`.slice(0, 40)
236
+ return s
237
+ }
238
+
239
+ export function isValidFolderKey(key: string): boolean {
240
+ return FOLDER_KEY_RE.test(key)
241
+ }
242
+
243
+ /**
244
+ * Build a default layout tree from the live nav groups (one group node per
245
+ * section, leaves + existing collapsibles as folders). Used by the editor
246
+ * "start from current sidebar" action.
247
+ */
248
+ export function layoutTreeFromNavGroups(
249
+ groups: Array<{
250
+ title: string
251
+ items: Array<{
252
+ title: string
253
+ url: string
254
+ icon?: string
255
+ items?: Array<{ title: string; url: string; icon?: string; items?: unknown[] }>
256
+ folderKey?: string
257
+ ref?: string
258
+ }>
259
+ }>,
260
+ refFor: (url: string) => string = (u) => leafRefFor(u),
261
+ ): SidebarLayoutNode[] {
262
+ return groups.map((g) => ({
263
+ type: 'group' as const,
264
+ key: g.title,
265
+ title: g.title,
266
+ children: g.items.map((it) => {
267
+ if (it.items && it.items.length > 0) {
268
+ const key = it.folderKey || slugifyFolderKey(it.title)
269
+ return {
270
+ type: 'folder' as const,
271
+ key,
272
+ title: it.title,
273
+ icon: typeof it.icon === 'string' ? it.icon : undefined,
274
+ children: it.items.map((child) => ({
275
+ type: 'leaf' as const,
276
+ ref: (child as { ref?: string }).ref || refFor(child.url),
277
+ title: child.title,
278
+ icon: typeof child.icon === 'string' ? child.icon : undefined,
279
+ })),
280
+ }
281
+ }
282
+ return {
283
+ type: 'leaf' as const,
284
+ ref: it.ref || refFor(it.url),
285
+ title: it.title,
286
+ icon: typeof it.icon === 'string' ? it.icon : undefined,
287
+ }
288
+ }),
289
+ }))
290
+ }
@@ -260,6 +260,12 @@ export interface DynamicTableProps {
260
260
  virtualizeRows?: boolean | number
261
261
  }
262
262
 
263
+ /** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
264
+ function isForbiddenError(error: unknown): boolean {
265
+ const e = error as { response?: { status?: number }; status?: number } | null
266
+ return e?.response?.status === 403 || e?.status === 403
267
+ }
268
+
263
269
  export function DynamicTable({
264
270
  model,
265
271
  endpoint,
@@ -322,6 +328,10 @@ export function DynamicTable({
322
328
  // even if meta.total says otherwise (count/list drift would otherwise
323
329
  // keep the sentinel re-firing forever). Reset by any page-1 fetch.
324
330
  const [infExhausted, setInfExhausted] = useState(false)
331
+ // The list endpoint answered 403: the user lacks read permission on this
332
+ // model. Rendered as "no permission" instead of masquerading as an empty
333
+ // list. Purely display state — never a fetch dependency (no refetch loop).
334
+ const [forbidden, setForbidden] = useState(false)
325
335
  const infPageRef = useRef(1)
326
336
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
327
337
 
@@ -859,6 +869,7 @@ export function DynamicTable({
859
869
  ...buildFilterParams(),
860
870
  }
861
871
  const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
872
+ setForbidden(false)
862
873
  if (res.data.success) {
863
874
  const rows = res.data.data || []
864
875
  setData(rows)
@@ -874,7 +885,13 @@ export function DynamicTable({
874
885
  }
875
886
  }
876
887
  } catch (error) {
877
- console.error('Error al cargar los datos', error)
888
+ if (isForbiddenError(error)) {
889
+ setForbidden(true)
890
+ setData([])
891
+ setRowCount(0)
892
+ } else {
893
+ console.error('Error al cargar los datos', error)
894
+ }
878
895
  } finally {
879
896
  setLoadingData(false)
880
897
  }
@@ -922,6 +939,7 @@ export function DynamicTable({
922
939
  const res = (await api.get(endpoint || `/data/${model}`, {
923
940
  params,
924
941
  })) as { data: ApiResponse<any[]> }
942
+ setForbidden(false)
925
943
  if (res.data.success) {
926
944
  const rows = res.data.data || []
927
945
  setData((prev) => (append ? dedupeById(prev, rows) : rows))
@@ -941,7 +959,16 @@ export function DynamicTable({
941
959
  setInfExhausted(rows.length < infPageSize)
942
960
  }
943
961
  } catch (error) {
944
- console.error('Error al cargar los datos', error)
962
+ if (isForbiddenError(error)) {
963
+ setForbidden(true)
964
+ if (!append) {
965
+ setData([])
966
+ setRowCount(0)
967
+ }
968
+ setInfExhausted(true)
969
+ } else {
970
+ console.error('Error al cargar los datos', error)
971
+ }
945
972
  } finally {
946
973
  if (append) setLoadingMore(false)
947
974
  else setLoadingData(false)
@@ -1546,8 +1573,17 @@ export function DynamicTable({
1546
1573
  <Inbox className="h-10 w-10" />
1547
1574
  </div>
1548
1575
  <div className="flex flex-col items-center gap-1">
1549
- <h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
1550
- <p className="text-sm text-muted-foreground">No hay datos para mostrar en este momento.</p>
1576
+ {forbidden ? (
1577
+ <>
1578
+ <h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
1579
+ <p className="text-sm text-muted-foreground">{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
1580
+ </>
1581
+ ) : (
1582
+ <>
1583
+ <h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
1584
+ <p className="text-sm text-muted-foreground">No hay datos para mostrar en este momento.</p>
1585
+ </>
1586
+ )}
1551
1587
  </div>
1552
1588
  </div>
1553
1589
  </TableCell>
@@ -1736,8 +1772,17 @@ export function DynamicTable({
1736
1772
  <div className='flex h-16 w-16 items-center justify-center rounded-full bg-muted/50'>
1737
1773
  <Inbox className='h-8 w-8' />
1738
1774
  </div>
1739
- <h3 className='text-base font-semibold text-foreground'>No se encontraron resultados</h3>
1740
- <p className='text-sm text-muted-foreground'>No hay datos para mostrar en este momento.</p>
1775
+ {forbidden ? (
1776
+ <>
1777
+ <h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
1778
+ <p className='text-sm text-muted-foreground'>{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
1779
+ </>
1780
+ ) : (
1781
+ <>
1782
+ <h3 className='text-base font-semibold text-foreground'>No se encontraron resultados</h3>
1783
+ <p className='text-sm text-muted-foreground'>No hay datos para mostrar en este momento.</p>
1784
+ </>
1785
+ )}
1741
1786
  </div>
1742
1787
  )}
1743
1788
  {infiniteScroll && (
package/src/index.ts CHANGED
@@ -216,6 +216,29 @@ export {
216
216
  type RoleDef,
217
217
  type RoleInput,
218
218
  } from './permissions-manager'
219
+ export {
220
+ applySidebarLayout,
221
+ collectFolderKeys,
222
+ folderAccessCapability,
223
+ coreRefKey,
224
+ addonModelRefKey,
225
+ addonUrlRefKey,
226
+ urlRefKey,
227
+ leafRefFor,
228
+ normalizeNavPath,
229
+ slugifyFolderKey,
230
+ isValidFolderKey,
231
+ layoutTreeFromNavGroups,
232
+ type CatalogLeaf,
233
+ type SidebarLayoutDoc,
234
+ type SidebarLayoutNode,
235
+ type AppliedNavGroup,
236
+ type AppliedNavItem,
237
+ } from './apply-sidebar-layout'
238
+ export {
239
+ SidebarLayoutManager,
240
+ type SidebarLayoutManagerProps,
241
+ } from './sidebar-layout-manager'
219
242
  export * from './org-runtime-context'
220
243
  export * from './org-runtime-provider'
221
244
  export * from './navigation-builder'