@asteby/metacore-runtime-react 39.1.1 → 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,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ applySidebarLayout,
4
+ coreRefKey,
5
+ folderAccessCapability,
6
+ leafRefFor,
7
+ slugifyFolderKey,
8
+ type CatalogLeaf,
9
+ type SidebarLayoutNode,
10
+ } from './apply-sidebar-layout'
11
+
12
+ describe('applySidebarLayout', () => {
13
+ const catalog: CatalogLeaf[] = [
14
+ { ref: coreRefKey('/'), title: 'Dashboard', url: '/' },
15
+ { ref: 'addon:crm/model:prices', title: 'Precios', url: '/m/prices', aliases: ['url:/m/prices'] },
16
+ { ref: coreRefKey('/organization'), title: 'Org', url: '/organization' },
17
+ ]
18
+
19
+ it('returns null for empty tree', () => {
20
+ expect(applySidebarLayout(catalog, [])).toBeNull()
21
+ })
22
+
23
+ it('moves prices under a configuration folder', () => {
24
+ const tree: SidebarLayoutNode[] = [
25
+ {
26
+ type: 'group',
27
+ title: 'Sistema',
28
+ children: [
29
+ {
30
+ type: 'folder',
31
+ key: 'configuracion',
32
+ title: 'Configuración',
33
+ children: [
34
+ { type: 'leaf', ref: coreRefKey('/organization') },
35
+ { type: 'leaf', ref: 'addon:crm/model:prices' },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ ]
41
+ const groups = applySidebarLayout(catalog, tree)
42
+ expect(groups).toHaveLength(1)
43
+ expect(groups![0].items[0].folderKey).toBe('configuracion')
44
+ expect(groups![0].items[0].items?.map((i) => i.url)).toEqual([
45
+ '/organization',
46
+ '/m/prices',
47
+ ])
48
+ })
49
+
50
+ it('resolves url: alias', () => {
51
+ const tree: SidebarLayoutNode[] = [
52
+ {
53
+ type: 'group',
54
+ title: 'G',
55
+ children: [{ type: 'leaf', ref: 'url:/m/prices' }],
56
+ },
57
+ ]
58
+ const groups = applySidebarLayout(catalog, tree)
59
+ expect(groups![0].items[0].url).toBe('/m/prices')
60
+ })
61
+ })
62
+
63
+ describe('helpers', () => {
64
+ it('slugifies folder keys', () => {
65
+ expect(slugifyFolderKey('Mi Config')).toBe('mi_config')
66
+ })
67
+ it('folder capability', () => {
68
+ expect(folderAccessCapability('configuracion')).toBe('folder.configuracion.access')
69
+ })
70
+ it('leafRefFor model pages', () => {
71
+ expect(leafRefFor('/m/prices')).toBe('url:/m/prices')
72
+ expect(leafRefFor('/organization')).toBe('core:/organization')
73
+ })
74
+ })
@@ -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
+ }
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'