@asteby/metacore-runtime-react 39.1.1 → 39.2.1

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,384 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * SidebarLayoutManager — Discord-style org sidebar editor.
4
+ * The canvas IS the sidebar: sections, folders, leaves — drag, rename, nest.
5
+ */
6
+ import * as React from 'react';
7
+ import { DndContext, DragOverlay, PointerSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core';
8
+ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
9
+ import { CSS } from '@dnd-kit/utilities';
10
+ import { ChevronDown, ChevronRight, FolderPlus, GripVertical, Pencil, Plus, RotateCcw, Save, Trash2, Eye, EyeOff, Hash, Folder, } from 'lucide-react';
11
+ import { toast } from 'sonner';
12
+ import { Button, Input, ScrollArea, } from '@asteby/metacore-ui/primitives';
13
+ import { cn } from '@asteby/metacore-ui/lib';
14
+ import { collectFolderKeys, folderAccessCapability, layoutTreeFromNavGroups, leafRefFor, slugifyFolderKey, } from './apply-sidebar-layout';
15
+ function pathKey(path) {
16
+ return path.length ? path.join('.') : 'root';
17
+ }
18
+ function getAt(tree, path) {
19
+ let cur = tree;
20
+ let node = null;
21
+ for (const i of path) {
22
+ node = cur[i] ?? null;
23
+ if (!node)
24
+ return null;
25
+ cur = node.children ?? [];
26
+ }
27
+ return node;
28
+ }
29
+ function setChildren(tree, parentPath, children) {
30
+ if (parentPath.length === 0)
31
+ return children;
32
+ const clone = structuredClone(tree);
33
+ let cur = clone;
34
+ for (let d = 0; d < parentPath.length - 1; d++) {
35
+ const n = cur[parentPath[d]];
36
+ if (!n)
37
+ return tree;
38
+ n.children = n.children ?? [];
39
+ cur = n.children;
40
+ }
41
+ const parent = cur[parentPath[parentPath.length - 1]];
42
+ if (!parent)
43
+ return tree;
44
+ parent.children = children;
45
+ return clone;
46
+ }
47
+ function removeAt(tree, path) {
48
+ if (path.length === 0)
49
+ return { tree, node: null };
50
+ const parentPath = path.slice(0, -1);
51
+ const idx = path[path.length - 1];
52
+ const parentKids = parentPath.length === 0
53
+ ? [...tree]
54
+ : [...(getAt(tree, parentPath)?.children ?? [])];
55
+ const [node] = parentKids.splice(idx, 1);
56
+ return { tree: setChildren(tree, parentPath, parentKids), node: node ?? null };
57
+ }
58
+ function insertAt(tree, parentPath, index, node) {
59
+ const kids = parentPath.length === 0
60
+ ? [...tree]
61
+ : [...(getAt(tree, parentPath)?.children ?? [])];
62
+ const i = Math.max(0, Math.min(index, kids.length));
63
+ kids.splice(i, 0, node);
64
+ return setChildren(tree, parentPath, kids);
65
+ }
66
+ function updateAt(tree, path, patch) {
67
+ const clone = structuredClone(tree);
68
+ let cur = clone;
69
+ for (let d = 0; d < path.length - 1; d++) {
70
+ const n = cur[path[d]];
71
+ if (!n)
72
+ return tree;
73
+ n.children = n.children ?? [];
74
+ cur = n.children;
75
+ }
76
+ const i = path[path.length - 1];
77
+ if (!cur[i])
78
+ return tree;
79
+ cur[i] = { ...cur[i], ...patch };
80
+ return clone;
81
+ }
82
+ function collectUsedRefs(tree) {
83
+ const used = new Set();
84
+ const walk = (nodes) => {
85
+ for (const n of nodes) {
86
+ if (n.type === 'leaf' && n.ref)
87
+ used.add(n.ref);
88
+ if (n.children)
89
+ walk(n.children);
90
+ }
91
+ };
92
+ walk(tree);
93
+ return used;
94
+ }
95
+ function labelOf(node, catalogByRef, resolveLabel) {
96
+ if (node.type === 'leaf') {
97
+ const leaf = node.ref ? catalogByRef.get(node.ref) : undefined;
98
+ const raw = node.title || leaf?.title || node.ref || '…';
99
+ return resolveLabel(raw);
100
+ }
101
+ return resolveLabel(node.title || node.key || '…');
102
+ }
103
+ // ── Sortable row ──────────────────────────────────────────────────────────
104
+ function SortableRow({ id, depth, kind, label, hidden, collapsed, onToggleCollapse, onRename, onToggleHidden, onDelete, onAddFolder, onAddLeaf, unusedLeaves, children, }) {
105
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
106
+ const [editing, setEditing] = React.useState(false);
107
+ const [draft, setDraft] = React.useState(label);
108
+ const inputRef = React.useRef(null);
109
+ React.useEffect(() => {
110
+ if (editing) {
111
+ setDraft(label);
112
+ requestAnimationFrame(() => inputRef.current?.select());
113
+ }
114
+ }, [editing, label]);
115
+ const style = {
116
+ transform: CSS.Transform.toString(transform),
117
+ transition,
118
+ opacity: isDragging ? 0.45 : hidden ? 0.45 : 1,
119
+ paddingLeft: 8 + depth * 14,
120
+ };
121
+ const isCategory = kind === 'group' || kind === 'folder';
122
+ const commitRename = () => {
123
+ const next = draft.trim();
124
+ setEditing(false);
125
+ if (next && next !== label)
126
+ onRename(next);
127
+ };
128
+ return (_jsxs("div", { ref: setNodeRef, style: style, className: "group/row", children: [_jsxs("div", { className: cn('flex items-center gap-1 rounded-md py-1 pr-1', isCategory ? 'mt-2 first:mt-0' : 'hover:bg-muted/50', isDragging && 'bg-muted/80'), children: [_jsx("button", { type: "button", className: "text-muted-foreground hover:text-foreground cursor-grab px-0.5 active:cursor-grabbing", "aria-label": "Arrastrar", ...attributes, ...listeners, children: _jsx(GripVertical, { className: "size-3.5 opacity-40 group-hover/row:opacity-100" }) }), isCategory ? (_jsx("button", { type: "button", className: "text-muted-foreground hover:text-foreground p-0.5", onClick: onToggleCollapse, children: collapsed ? (_jsx(ChevronRight, { className: "size-3.5" })) : (_jsx(ChevronDown, { className: "size-3.5" })) })) : (_jsx(Hash, { className: "text-muted-foreground size-3.5 shrink-0 opacity-60" })), kind === 'folder' ? (_jsx(Folder, { className: "size-3.5 shrink-0 text-amber-500/90" })) : null, editing ? (_jsx(Input, { ref: inputRef, value: draft, onChange: (e) => setDraft(e.target.value), onBlur: commitRename, onKeyDown: (e) => {
129
+ if (e.key === 'Enter')
130
+ commitRename();
131
+ if (e.key === 'Escape')
132
+ setEditing(false);
133
+ }, className: "h-7 flex-1 text-sm" })) : (_jsx("button", { type: "button", className: cn('min-w-0 flex-1 truncate text-left text-sm', isCategory
134
+ ? 'text-muted-foreground text-[11px] font-semibold uppercase tracking-wider'
135
+ : 'font-medium'), onDoubleClick: () => setEditing(true), title: "Doble clic para renombrar", children: label })), _jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100", children: [kind === 'group' || kind === 'folder' ? (_jsxs(_Fragment, { children: [onAddFolder ? (_jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "size-6", title: "Nueva carpeta", onClick: onAddFolder, children: _jsx(FolderPlus, { className: "size-3" }) })) : null, onAddLeaf && unusedLeaves && unusedLeaves.length > 0 ? (_jsxs("select", { className: "border-input bg-background h-6 max-w-[110px] rounded border px-1 text-[10px]", defaultValue: "", title: "Agregar \u00EDtem", onChange: (e) => {
136
+ const ref = e.target.value;
137
+ e.target.value = '';
138
+ if (ref)
139
+ onAddLeaf(ref);
140
+ }, children: [_jsx("option", { value: "", children: "+ \u00EDtem" }), unusedLeaves.map((l) => (_jsx("option", { value: l.ref, children: l.title }, l.ref)))] })) : null] })) : null, _jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "size-6", title: "Renombrar", onClick: () => setEditing(true), children: _jsx(Pencil, { className: "size-3" }) }), _jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "size-6", title: hidden ? 'Mostrar' : 'Ocultar', onClick: onToggleHidden, children: hidden ? _jsx(EyeOff, { className: "size-3" }) : _jsx(Eye, { className: "size-3" }) }), _jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "text-destructive size-6", title: "Quitar", onClick: onDelete, children: _jsx(Trash2, { className: "size-3" }) })] })] }), !collapsed ? children : null] }));
141
+ }
142
+ // ── Manager ───────────────────────────────────────────────────────────────
143
+ export function SidebarLayoutManager({ loadLayout, saveLayout, resetLayout, catalog, currentGroups, resolveLabel = (s) => s, title = 'Menú lateral', description = 'Arrastrá, renombrá y agrupá como en Discord. Así lo verá toda la organización.', }) {
144
+ const [tree, setTree] = React.useState([]);
145
+ const [baseline, setBaseline] = React.useState('[]');
146
+ const [loading, setLoading] = React.useState(true);
147
+ const [saving, setSaving] = React.useState(false);
148
+ const [collapsed, setCollapsed] = React.useState({});
149
+ const [activeId, setActiveId] = React.useState(null);
150
+ const [newCategory, setNewCategory] = React.useState('');
151
+ const catalogByRef = React.useMemo(() => {
152
+ const m = new Map();
153
+ for (const l of catalog) {
154
+ m.set(l.ref, { ...l, title: resolveLabel(l.title) });
155
+ for (const a of l.aliases ?? [])
156
+ m.set(a, m.get(l.ref));
157
+ }
158
+ return m;
159
+ }, [catalog, resolveLabel]);
160
+ const unused = React.useMemo(() => {
161
+ const used = collectUsedRefs(tree);
162
+ return catalog
163
+ .filter((l) => !used.has(l.ref) && !(l.aliases ?? []).some((a) => used.has(a)))
164
+ .map((l) => ({ ...l, title: resolveLabel(l.title) }));
165
+ }, [catalog, tree, resolveLabel]);
166
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
167
+ const seedFromCurrent = React.useCallback(() => {
168
+ if (!currentGroups?.length) {
169
+ toast.message('No hay menú actual para copiar');
170
+ return [];
171
+ }
172
+ return layoutTreeFromNavGroups(currentGroups, (u) => leafRefFor(u));
173
+ }, [currentGroups]);
174
+ const reload = React.useCallback(async () => {
175
+ setLoading(true);
176
+ try {
177
+ const doc = await loadLayout();
178
+ let t = doc.tree ?? [];
179
+ // Empty overlay → show live menu as editable draft (Discord empty-server feel).
180
+ if (t.length === 0 && currentGroups?.length) {
181
+ t = seedFromCurrent();
182
+ setBaseline('[]'); // still "unsaved overlay"
183
+ }
184
+ else {
185
+ setBaseline(JSON.stringify(t));
186
+ }
187
+ setTree(t);
188
+ }
189
+ catch (e) {
190
+ toast.error(e instanceof Error ? e.message : 'No se pudo cargar el menú');
191
+ }
192
+ finally {
193
+ setLoading(false);
194
+ }
195
+ }, [loadLayout, currentGroups, seedFromCurrent]);
196
+ React.useEffect(() => {
197
+ void reload();
198
+ }, [reload]);
199
+ const dirty = JSON.stringify(tree) !== baseline;
200
+ const folderKeys = React.useMemo(() => collectFolderKeys(tree), [tree]);
201
+ // Flat id → path index for DnD (ids are path keys)
202
+ const idToPath = React.useMemo(() => {
203
+ const map = new Map();
204
+ const walk = (nodes, parent) => {
205
+ nodes.forEach((_, i) => {
206
+ const path = [...parent, i];
207
+ map.set(pathKey(path), path);
208
+ const n = nodes[i];
209
+ if (n.children?.length)
210
+ walk(n.children, path);
211
+ });
212
+ };
213
+ walk(tree, []);
214
+ return map;
215
+ }, [tree]);
216
+ const handleDragStart = (e) => {
217
+ setActiveId(String(e.active.id));
218
+ };
219
+ const handleDragEnd = (e) => {
220
+ setActiveId(null);
221
+ const { active, over } = e;
222
+ if (!over || active.id === over.id)
223
+ return;
224
+ const fromPath = idToPath.get(String(active.id));
225
+ const toPath = idToPath.get(String(over.id));
226
+ if (!fromPath || !toPath)
227
+ return;
228
+ // Same parent → reorder siblings
229
+ const fromParent = fromPath.slice(0, -1);
230
+ const toParent = toPath.slice(0, -1);
231
+ if (pathKey(fromParent) === pathKey(toParent)) {
232
+ const kids = fromParent.length === 0
233
+ ? [...tree]
234
+ : [...(getAt(tree, fromParent)?.children ?? [])];
235
+ const oldIndex = fromPath[fromPath.length - 1];
236
+ const newIndex = toPath[toPath.length - 1];
237
+ setTree(setChildren(tree, fromParent, arrayMove(kids, oldIndex, newIndex)));
238
+ return;
239
+ }
240
+ // Different parent → move node under target's parent at target index
241
+ // (or into target if it's a folder/group)
242
+ const overNode = getAt(tree, toPath);
243
+ let destParent = toParent;
244
+ let destIndex = toPath[toPath.length - 1];
245
+ if (overNode && (overNode.type === 'group' || overNode.type === 'folder')) {
246
+ destParent = toPath;
247
+ destIndex = overNode.children?.length ?? 0;
248
+ }
249
+ // Prevent dropping into own descendant
250
+ const fromKey = pathKey(fromPath);
251
+ if (pathKey(destParent).startsWith(fromKey + '.') || pathKey(destParent) === fromKey) {
252
+ return;
253
+ }
254
+ setTree((prev) => {
255
+ const { tree: without, node } = removeAt(prev, fromPath);
256
+ if (!node)
257
+ return prev;
258
+ // Recompute dest index if we removed from earlier sibling in same parent
259
+ let idx = destIndex;
260
+ if (pathKey(fromParent) === pathKey(destParent) && fromPath[fromPath.length - 1] < destIndex) {
261
+ idx = Math.max(0, destIndex - 1);
262
+ }
263
+ return insertAt(without, destParent, idx, node);
264
+ });
265
+ };
266
+ const handleSave = async () => {
267
+ setSaving(true);
268
+ try {
269
+ await saveLayout({ version: 1, tree });
270
+ setBaseline(JSON.stringify(tree));
271
+ toast.success('Menú guardado para toda la organización');
272
+ }
273
+ catch (e) {
274
+ toast.error(e instanceof Error ? e.message : 'Error al guardar');
275
+ }
276
+ finally {
277
+ setSaving(false);
278
+ }
279
+ };
280
+ const handleReset = async () => {
281
+ setSaving(true);
282
+ try {
283
+ await resetLayout();
284
+ const seeded = seedFromCurrent();
285
+ setTree(seeded);
286
+ setBaseline('[]');
287
+ toast.success('Overlay restaurado — mostrando el menú default');
288
+ }
289
+ catch (e) {
290
+ toast.error(e instanceof Error ? e.message : 'Error al resetear');
291
+ }
292
+ finally {
293
+ setSaving(false);
294
+ }
295
+ };
296
+ const addRootCategory = () => {
297
+ const name = newCategory.trim() || 'Nueva sección';
298
+ const key = slugifyFolderKey(name);
299
+ setTree((t) => [
300
+ ...t,
301
+ {
302
+ type: 'group',
303
+ key: `group_${key}_${Date.now() % 10000}`,
304
+ title: name,
305
+ children: [],
306
+ },
307
+ ]);
308
+ setNewCategory('');
309
+ };
310
+ const renderList = (nodes, parentPath) => {
311
+ const ids = nodes.map((_, i) => pathKey([...parentPath, i]));
312
+ return (_jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: nodes.map((node, i) => {
313
+ const path = [...parentPath, i];
314
+ const id = pathKey(path);
315
+ const depth = parentPath.length;
316
+ const isOpen = !(collapsed[id] ?? false);
317
+ const lbl = labelOf(node, catalogByRef, resolveLabel);
318
+ return (_jsx(SortableRow, { id: id, depth: depth, kind: node.type, label: lbl, hidden: node.hidden, collapsed: !isOpen, onToggleCollapse: () => setCollapsed((c) => ({ ...c, [id]: !(c[id] ?? false) })), onRename: (next) => setTree((t) => updateAt(t, path, { title: next })), onToggleHidden: () => setTree((t) => updateAt(t, path, { hidden: !node.hidden })), onDelete: () => setTree((t) => removeAt(t, path).tree), onAddFolder: node.type === 'group' || node.type === 'folder'
319
+ ? () => {
320
+ const key = slugifyFolderKey(`carpeta_${Date.now() % 1000}`);
321
+ setTree((t) => insertAt(t, path, node.children?.length ?? 0, {
322
+ type: 'folder',
323
+ key,
324
+ title: 'Nueva carpeta',
325
+ icon: 'Folder',
326
+ children: [],
327
+ }));
328
+ setCollapsed((c) => ({ ...c, [id]: false }));
329
+ }
330
+ : undefined, onAddLeaf: node.type === 'group' || node.type === 'folder'
331
+ ? (ref) => {
332
+ const leaf = catalog.find((l) => l.ref === ref);
333
+ setTree((t) => insertAt(t, path, node.children?.length ?? 0, {
334
+ type: 'leaf',
335
+ ref,
336
+ title: leaf?.title,
337
+ icon: leaf?.icon,
338
+ }));
339
+ }
340
+ : undefined, unusedLeaves: unused, children: node.type !== 'leaf' && isOpen
341
+ ? renderList(node.children ?? [], path)
342
+ : null }, id));
343
+ }) }));
344
+ };
345
+ const activeLabel = activeId
346
+ ? (() => {
347
+ const p = idToPath.get(activeId);
348
+ if (!p)
349
+ return null;
350
+ const n = getAt(tree, p);
351
+ return n ? labelOf(n, catalogByRef, resolveLabel) : null;
352
+ })()
353
+ : null;
354
+ return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h2", { className: "text-xl font-semibold tracking-tight", children: title }), _jsx("p", { className: "text-muted-foreground max-w-xl text-sm", children: description })] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", disabled: saving, onClick: () => {
355
+ setTree(seedFromCurrent());
356
+ toast.message('Árbol recargado desde el menú actual');
357
+ }, children: "Recargar men\u00FA actual" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: saving, onClick: () => void handleReset(), children: [_jsx(RotateCcw, { className: "mr-1 size-3.5" }), "Restaurar default"] }), _jsxs(Button, { type: "button", size: "sm", disabled: !dirty || saving, onClick: () => void handleSave(), children: [_jsx(Save, { className: "mr-1 size-3.5" }), saving ? 'Guardando…' : dirty ? 'Guardar cambios' : 'Guardado'] })] })] }), loading ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Cargando men\u00FA\u2026" })) : (_jsxs("div", { className: "grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]", children: [_jsxs("div", { className: "bg-muted/20 border-border overflow-hidden rounded-xl border", children: [_jsxs("div", { className: "border-border/60 flex items-center justify-between border-b px-3 py-2", children: [_jsx("span", { className: "text-muted-foreground text-[11px] font-semibold uppercase tracking-wider", children: "Vista del men\u00FA" }), baseline === '[]' && dirty ? (_jsx("span", { className: "text-amber-600 text-[11px]", children: "Sin guardar \u00B7 borrador del men\u00FA actual" })) : dirty ? (_jsx("span", { className: "text-amber-600 text-[11px]", children: "Cambios sin guardar" })) : (_jsx("span", { className: "text-muted-foreground text-[11px]", children: folderKeys.length
358
+ ? `${folderKeys.length} carpeta(s) con permiso`
359
+ : 'Layout activo' }))] }), _jsx(ScrollArea, { className: "h-[min(70vh,640px)]", children: _jsxs("div", { className: "p-2 pb-4", children: [_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragStart: handleDragStart, onDragEnd: handleDragEnd, children: [tree.length === 0 ? (_jsx("p", { className: "text-muted-foreground px-3 py-8 text-center text-sm", children: "Men\u00FA vac\u00EDo. Cre\u00E1 una secci\u00F3n o recarg\u00E1 el men\u00FA actual." })) : (renderList(tree, [])), _jsx(DragOverlay, { children: activeLabel ? (_jsx("div", { className: "bg-background border-border rounded-md border px-3 py-1.5 text-sm shadow-lg", children: activeLabel })) : null })] }), _jsxs("div", { className: "mt-3 flex gap-2 px-2", children: [_jsx(Input, { value: newCategory, onChange: (e) => setNewCategory(e.target.value), placeholder: "Nombre de secci\u00F3n\u2026", className: "h-8 text-sm", onKeyDown: (e) => {
360
+ if (e.key === 'Enter')
361
+ addRootCategory();
362
+ } }), _jsxs(Button, { type: "button", size: "sm", className: "h-8 shrink-0", onClick: addRootCategory, children: [_jsx(Plus, { className: "mr-1 size-3.5" }), "Secci\u00F3n"] })] })] }) })] }), _jsxs("div", { className: "bg-muted/10 border-border flex flex-col overflow-hidden rounded-xl border", children: [_jsxs("div", { className: "border-border/60 border-b px-3 py-2", children: [_jsx("p", { className: "text-muted-foreground text-[11px] font-semibold uppercase tracking-wider", children: "Sin colocar" }), _jsx("p", { className: "text-muted-foreground mt-0.5 text-[11px]", children: "Agregalos desde el men\u00FA + de cada secci\u00F3n" })] }), _jsx(ScrollArea, { className: "flex-1", children: _jsx("ul", { className: "space-y-1 p-2", children: unused.length === 0 ? (_jsx("li", { className: "text-muted-foreground px-1 py-4 text-center text-xs", children: "Todo est\u00E1 en el men\u00FA" })) : (unused.map((l) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "hover:bg-muted/60 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs", title: l.ref, onClick: () => {
363
+ if (tree.length === 0) {
364
+ setTree([
365
+ {
366
+ type: 'group',
367
+ key: 'sidebar.general',
368
+ title: 'General',
369
+ children: [
370
+ { type: 'leaf', ref: l.ref, title: l.title, icon: l.icon },
371
+ ],
372
+ },
373
+ ]);
374
+ }
375
+ else {
376
+ setTree((t) => insertAt(t, [0], t[0]?.children?.length ?? 0, {
377
+ type: 'leaf',
378
+ ref: l.ref,
379
+ title: l.title,
380
+ icon: l.icon,
381
+ }));
382
+ }
383
+ }, children: [_jsx(Hash, { className: "text-muted-foreground size-3 shrink-0" }), _jsx("span", { className: "truncate font-medium", children: l.title }), _jsx(Plus, { className: "text-muted-foreground ml-auto size-3 shrink-0 opacity-50" })] }) }, l.ref)))) }) }), folderKeys.length > 0 ? (_jsxs("div", { className: "border-border/60 text-muted-foreground border-t px-3 py-2 text-[10px] leading-relaxed", children: ["Permisos:", ' ', folderKeys.map((k) => folderAccessCapability(k)).join(', ')] })) : null] })] }))] }));
384
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "39.1.1",
3
+ "version": "39.2.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
+ })