@asteby/metacore-runtime-react 18.13.3 → 18.15.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.
- package/CHANGELOG.md +35 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +8 -3
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +20 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +6 -1
- package/dist/permissions-context.d.ts +48 -0
- package/dist/permissions-context.d.ts.map +1 -0
- package/dist/permissions-context.js +124 -0
- package/dist/permissions-manager.d.ts +84 -0
- package/dist/permissions-manager.d.ts.map +1 -0
- package/dist/permissions-manager.js +429 -0
- package/package.json +11 -9
- package/src/__tests__/dynamic-table-permissions.test.tsx +147 -0
- package/src/__tests__/permissions-context.test.tsx +102 -0
- package/src/__tests__/permissions-manager.test.tsx +258 -0
- package/src/dynamic-crud-page.tsx +8 -3
- package/src/dynamic-table.tsx +22 -9
- package/src/index.ts +26 -0
- package/src/model-action-toolbar.tsx +9 -1
- package/src/permissions-context.tsx +158 -0
- package/src/permissions-manager.tsx +1143 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// permissions-manager — "Permisos y Roles" pro view (rol × módulo × acción).
|
|
3
|
+
//
|
|
4
|
+
// Transport-agnostic: every read/write arrives via props (loaders/mutators),
|
|
5
|
+
// so each host wires them to its own api client (ops → /api/permissions/*).
|
|
6
|
+
// The capability universe (modules × actions + general flags) is derived from
|
|
7
|
+
// the installed manifests server-side; this component only renders it.
|
|
8
|
+
//
|
|
9
|
+
// Layout (mirrors the app sidebar so admins recognise what they grant):
|
|
10
|
+
// header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
|
|
11
|
+
// left — Card "Rol": clean role combobox with inline Editar/Eliminar
|
|
12
|
+
// icons (no removable chip) + "Permisos Generales" flags.
|
|
13
|
+
// — Card "Módulos": a searchable, accordion *tree* grouped by
|
|
14
|
+
// `addon_label` (modules without one → "Sistema"). Each group
|
|
15
|
+
// lists its modules with their icon + a granted-count badge.
|
|
16
|
+
// Clicking a module selects it and reveals its action grid.
|
|
17
|
+
// right — Card "Acciones permitidas": granted counter N/M, mark-all /
|
|
18
|
+
// clear, checkbox grid (icon + label per action). Clear empty
|
|
19
|
+
// states for "pick a role" / "pick a module" / loading.
|
|
20
|
+
//
|
|
21
|
+
// Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
|
|
22
|
+
// granted set of the active role (baseline + the edits made here). Dirty
|
|
23
|
+
// state is tracked against the loaded baseline and surfaced next to the
|
|
24
|
+
// save button.
|
|
25
|
+
import * as React from 'react';
|
|
26
|
+
import { Check, ChevronRight, ChevronsUpDown, CheckCheck, Eraser, Folder, Pencil, Plus, Save, Search, Shield, Trash2, } from 'lucide-react';
|
|
27
|
+
import { toast } from 'sonner';
|
|
28
|
+
import { cn } from '@asteby/metacore-ui/lib';
|
|
29
|
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
30
|
+
import { DynamicIcon } from './dynamic-icon';
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Pure helpers (exported for hosts/tests)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Capability for a catalog module action: `lowercase(moduleKey).actionKey`. */
|
|
35
|
+
export function moduleActionCapability(moduleKey, actionKey) {
|
|
36
|
+
return `${moduleKey.toLowerCase()}.${actionKey}`;
|
|
37
|
+
}
|
|
38
|
+
/** All capabilities of one module. */
|
|
39
|
+
export function moduleCapabilities(module) {
|
|
40
|
+
return module.actions.map((a) => moduleActionCapability(module.key, a.key));
|
|
41
|
+
}
|
|
42
|
+
/** How many of the module's capabilities are in the granted set. */
|
|
43
|
+
export function grantedCountForModule(granted, module) {
|
|
44
|
+
return moduleCapabilities(module).filter((c) => granted.has(c)).length;
|
|
45
|
+
}
|
|
46
|
+
export function capabilitySetsEqual(a, b) {
|
|
47
|
+
if (a.size !== b.size)
|
|
48
|
+
return false;
|
|
49
|
+
for (const v of a)
|
|
50
|
+
if (!b.has(v))
|
|
51
|
+
return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/** Default lucide icon when the manifest action doesn't declare one. */
|
|
55
|
+
export function defaultActionIcon(actionKey, kind) {
|
|
56
|
+
switch (actionKey) {
|
|
57
|
+
case 'index':
|
|
58
|
+
return 'List';
|
|
59
|
+
case 'create':
|
|
60
|
+
return 'Plus';
|
|
61
|
+
case 'update':
|
|
62
|
+
return 'Pencil';
|
|
63
|
+
case 'delete':
|
|
64
|
+
return 'Trash2';
|
|
65
|
+
case 'export':
|
|
66
|
+
return 'Download';
|
|
67
|
+
case 'import':
|
|
68
|
+
return 'Upload';
|
|
69
|
+
default:
|
|
70
|
+
return kind === 'crud' ? 'List' : 'Zap';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Group label fallback when a module has no addon ("Sistema" = core/infra). */
|
|
74
|
+
const SYSTEM_GROUP = 'Sistema';
|
|
75
|
+
function moduleGroupLabel(mod) {
|
|
76
|
+
return mod.addon_label || mod.addon_key || SYSTEM_GROUP;
|
|
77
|
+
}
|
|
78
|
+
/** Accent-insensitive, lowercase fold for tree search. */
|
|
79
|
+
function fold(s) {
|
|
80
|
+
return s
|
|
81
|
+
.normalize('NFD')
|
|
82
|
+
.replace(/[̀-ͯ]/g, '')
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
function slugify(label) {
|
|
86
|
+
return fold(label)
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
89
|
+
.replace(/^_+|_+$/g, '');
|
|
90
|
+
}
|
|
91
|
+
const ROLE_COLORS = [
|
|
92
|
+
'#ef4444',
|
|
93
|
+
'#f97316',
|
|
94
|
+
'#eab308',
|
|
95
|
+
'#22c55e',
|
|
96
|
+
'#06b6d4',
|
|
97
|
+
'#3b82f6',
|
|
98
|
+
'#8b5cf6',
|
|
99
|
+
'#ec4899',
|
|
100
|
+
'#6b7280',
|
|
101
|
+
];
|
|
102
|
+
export function groupModules(modules) {
|
|
103
|
+
const order = [];
|
|
104
|
+
const byGroup = new Map();
|
|
105
|
+
for (const mod of modules) {
|
|
106
|
+
const g = moduleGroupLabel(mod);
|
|
107
|
+
if (!byGroup.has(g)) {
|
|
108
|
+
byGroup.set(g, []);
|
|
109
|
+
order.push(g);
|
|
110
|
+
}
|
|
111
|
+
byGroup.get(g).push(mod);
|
|
112
|
+
}
|
|
113
|
+
return order.map((label) => ({ label, modules: byGroup.get(label) }));
|
|
114
|
+
}
|
|
115
|
+
/** Filter the grouped tree by a folded query against module + group labels. */
|
|
116
|
+
export function filterModuleGroups(groups, query) {
|
|
117
|
+
const q = fold(query).trim();
|
|
118
|
+
if (!q)
|
|
119
|
+
return groups;
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const g of groups) {
|
|
122
|
+
const groupMatches = fold(g.label).includes(q);
|
|
123
|
+
const mods = groupMatches
|
|
124
|
+
? g.modules
|
|
125
|
+
: g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q));
|
|
126
|
+
if (mods.length)
|
|
127
|
+
out.push({ label: g.label, modules: mods });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Internal sub-components
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
/** Checkbox row used by both the action grid and the general flags. */
|
|
135
|
+
function CapabilityCheck({ checked, disabled, onToggle, icon, label, description, }) {
|
|
136
|
+
return (_jsxs("div", { role: "checkbox", "aria-checked": checked, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, onClick: disabled ? undefined : onToggle, onKeyDown: disabled
|
|
137
|
+
? undefined
|
|
138
|
+
: (e) => {
|
|
139
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
onToggle();
|
|
142
|
+
}
|
|
143
|
+
}, className: cn('flex items-start gap-2.5 rounded-md border border-border/60 bg-card px-3 py-2.5 text-sm transition-colors', disabled ? 'opacity-50' : 'cursor-pointer hover:bg-muted/40', checked && 'border-primary/40 bg-primary/5'), children: [_jsx(Checkbox, { checked: checked, "aria-hidden": "true", tabIndex: -1, className: "pointer-events-none mt-0.5 shrink-0" }), icon && (_jsx(DynamicIcon, { name: icon, className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" })), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium text-foreground", children: label }), description && (_jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: description }))] })] }));
|
|
144
|
+
}
|
|
145
|
+
/** One module row inside the tree. */
|
|
146
|
+
function ModuleTreeItem({ module, active, granted, total, onSelect, }) {
|
|
147
|
+
return (_jsxs("button", { type: "button", onClick: onSelect, "aria-current": active || undefined, className: cn('group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', active
|
|
148
|
+
? 'bg-primary/10 font-medium text-foreground'
|
|
149
|
+
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(DynamicIcon, { name: module.icon || 'Square', className: cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground') }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: module.label }), granted > 0 && (_jsxs(Badge, { variant: granted === total ? 'default' : 'secondary', className: "h-5 shrink-0 px-1.5 text-[10px] tabular-nums", children: [granted, "/", total] }))] }));
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Component
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions, syncRolePermissions, createRole, updateRole, deleteRole, title = 'Permisos y Roles', className, }) {
|
|
155
|
+
const [catalog, setCatalog] = React.useState(null);
|
|
156
|
+
const [roles, setRoles] = React.useState(null);
|
|
157
|
+
const [loadError, setLoadError] = React.useState(false);
|
|
158
|
+
const [activeRoleId, setActiveRoleId] = React.useState(null);
|
|
159
|
+
const [activeModuleKey, setActiveModuleKey] = React.useState(null);
|
|
160
|
+
// baseline = capabilities as persisted; draft = baseline + local edits.
|
|
161
|
+
const [baseline, setBaseline] = React.useState(null);
|
|
162
|
+
const [draft, setDraft] = React.useState(null);
|
|
163
|
+
const [loadingPerms, setLoadingPerms] = React.useState(false);
|
|
164
|
+
const [saving, setSaving] = React.useState(false);
|
|
165
|
+
const [roleOpen, setRoleOpen] = React.useState(false);
|
|
166
|
+
const [moduleQuery, setModuleQuery] = React.useState('');
|
|
167
|
+
// Groups the user explicitly collapsed (default: every group open).
|
|
168
|
+
const [collapsedGroups, setCollapsedGroups] = React.useState(new Set());
|
|
169
|
+
// Pending role switch while there are unsaved changes.
|
|
170
|
+
const [pendingRoleId, setPendingRoleId] = React.useState(null);
|
|
171
|
+
const [roleDialog, setRoleDialog] = React.useState({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] });
|
|
172
|
+
const [roleSaving, setRoleSaving] = React.useState(false);
|
|
173
|
+
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
|
174
|
+
const [deleting, setDeleting] = React.useState(false);
|
|
175
|
+
const loading = catalog === null || roles === null;
|
|
176
|
+
// ---- initial load: catalog + roles in parallel -------------------------
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
let cancelled = false;
|
|
179
|
+
Promise.all([loadModules(), loadRoles()])
|
|
180
|
+
.then(([cat, rs]) => {
|
|
181
|
+
if (cancelled)
|
|
182
|
+
return;
|
|
183
|
+
setCatalog(cat);
|
|
184
|
+
setRoles(rs);
|
|
185
|
+
setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null);
|
|
186
|
+
setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null);
|
|
187
|
+
})
|
|
188
|
+
.catch(() => {
|
|
189
|
+
if (!cancelled)
|
|
190
|
+
setLoadError(true);
|
|
191
|
+
});
|
|
192
|
+
return () => {
|
|
193
|
+
cancelled = true;
|
|
194
|
+
};
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
196
|
+
}, []);
|
|
197
|
+
// ---- per-role permissions ----------------------------------------------
|
|
198
|
+
React.useEffect(() => {
|
|
199
|
+
if (!activeRoleId) {
|
|
200
|
+
setBaseline(null);
|
|
201
|
+
setDraft(null);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
let cancelled = false;
|
|
205
|
+
setLoadingPerms(true);
|
|
206
|
+
loadRolePermissions(activeRoleId)
|
|
207
|
+
.then((caps) => {
|
|
208
|
+
if (cancelled)
|
|
209
|
+
return;
|
|
210
|
+
setBaseline(new Set(caps));
|
|
211
|
+
setDraft(new Set(caps));
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {
|
|
214
|
+
if (cancelled)
|
|
215
|
+
return;
|
|
216
|
+
toast.error('No se pudieron cargar los permisos del rol');
|
|
217
|
+
setBaseline(null);
|
|
218
|
+
setDraft(null);
|
|
219
|
+
})
|
|
220
|
+
.finally(() => {
|
|
221
|
+
if (!cancelled)
|
|
222
|
+
setLoadingPerms(false);
|
|
223
|
+
});
|
|
224
|
+
return () => {
|
|
225
|
+
cancelled = true;
|
|
226
|
+
};
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
+
}, [activeRoleId]);
|
|
229
|
+
const activeRole = React.useMemo(() => roles?.find((r) => r.id === activeRoleId) ?? null, [roles, activeRoleId]);
|
|
230
|
+
const activeModule = React.useMemo(() => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null, [catalog, activeModuleKey]);
|
|
231
|
+
const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft);
|
|
232
|
+
// Module tree: grouped by addon label, optionally filtered by the search.
|
|
233
|
+
const allGroups = React.useMemo(() => groupModules(catalog?.modules ?? []), [catalog]);
|
|
234
|
+
const visibleGroups = React.useMemo(() => filterModuleGroups(allGroups, moduleQuery), [allGroups, moduleQuery]);
|
|
235
|
+
const searching = moduleQuery.trim().length > 0;
|
|
236
|
+
// ---- capability edits ---------------------------------------------------
|
|
237
|
+
const toggleCapability = React.useCallback((cap) => {
|
|
238
|
+
setDraft((prev) => {
|
|
239
|
+
if (!prev)
|
|
240
|
+
return prev;
|
|
241
|
+
const next = new Set(prev);
|
|
242
|
+
if (next.has(cap))
|
|
243
|
+
next.delete(cap);
|
|
244
|
+
else
|
|
245
|
+
next.add(cap);
|
|
246
|
+
return next;
|
|
247
|
+
});
|
|
248
|
+
}, []);
|
|
249
|
+
const setModuleAll = React.useCallback((on) => {
|
|
250
|
+
if (!activeModule)
|
|
251
|
+
return;
|
|
252
|
+
const caps = moduleCapabilities(activeModule);
|
|
253
|
+
setDraft((prev) => {
|
|
254
|
+
if (!prev)
|
|
255
|
+
return prev;
|
|
256
|
+
const next = new Set(prev);
|
|
257
|
+
for (const c of caps) {
|
|
258
|
+
if (on)
|
|
259
|
+
next.add(c);
|
|
260
|
+
else
|
|
261
|
+
next.delete(c);
|
|
262
|
+
}
|
|
263
|
+
return next;
|
|
264
|
+
});
|
|
265
|
+
}, [activeModule]);
|
|
266
|
+
const handleSave = async () => {
|
|
267
|
+
if (!activeRoleId || !draft)
|
|
268
|
+
return;
|
|
269
|
+
setSaving(true);
|
|
270
|
+
try {
|
|
271
|
+
await syncRolePermissions(activeRoleId, Array.from(draft).sort());
|
|
272
|
+
setBaseline(new Set(draft));
|
|
273
|
+
toast.success('Permisos guardados');
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
toast.error('No se pudieron guardar los permisos');
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
setSaving(false);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
// ---- role switching (dirty guard) ---------------------------------------
|
|
283
|
+
const requestRoleSwitch = (roleId) => {
|
|
284
|
+
if (roleId === activeRoleId)
|
|
285
|
+
return;
|
|
286
|
+
if (dirty)
|
|
287
|
+
setPendingRoleId(roleId);
|
|
288
|
+
else
|
|
289
|
+
setActiveRoleId(roleId);
|
|
290
|
+
};
|
|
291
|
+
const toggleGroup = (label) => setCollapsedGroups((prev) => {
|
|
292
|
+
const next = new Set(prev);
|
|
293
|
+
if (next.has(label))
|
|
294
|
+
next.delete(label);
|
|
295
|
+
else
|
|
296
|
+
next.add(label);
|
|
297
|
+
return next;
|
|
298
|
+
});
|
|
299
|
+
// ---- role CRUD -----------------------------------------------------------
|
|
300
|
+
const refreshRoles = async (selectId) => {
|
|
301
|
+
const rs = await loadRoles();
|
|
302
|
+
setRoles(rs);
|
|
303
|
+
if (selectId !== undefined)
|
|
304
|
+
setActiveRoleId(selectId);
|
|
305
|
+
else if (activeRoleId && !rs.some((r) => r.id === activeRoleId))
|
|
306
|
+
setActiveRoleId(rs[0]?.id ?? null);
|
|
307
|
+
return rs;
|
|
308
|
+
};
|
|
309
|
+
const handleRoleSubmit = async () => {
|
|
310
|
+
const label = roleDialog.label.trim();
|
|
311
|
+
if (!label)
|
|
312
|
+
return;
|
|
313
|
+
setRoleSaving(true);
|
|
314
|
+
try {
|
|
315
|
+
if (roleDialog.mode === 'create' && createRole) {
|
|
316
|
+
const created = await createRole({
|
|
317
|
+
name: slugify(label),
|
|
318
|
+
label,
|
|
319
|
+
color: roleDialog.color,
|
|
320
|
+
});
|
|
321
|
+
const rs = await loadRoles();
|
|
322
|
+
setRoles(rs);
|
|
323
|
+
const createdId = (created && 'id' in created && created.id) ||
|
|
324
|
+
rs.find((r) => r.name === slugify(label))?.id ||
|
|
325
|
+
null;
|
|
326
|
+
if (createdId)
|
|
327
|
+
setActiveRoleId(createdId);
|
|
328
|
+
toast.success('Rol creado');
|
|
329
|
+
}
|
|
330
|
+
else if (roleDialog.mode === 'edit' && updateRole && activeRole) {
|
|
331
|
+
await updateRole(activeRole.id, {
|
|
332
|
+
name: activeRole.name,
|
|
333
|
+
label,
|
|
334
|
+
color: roleDialog.color,
|
|
335
|
+
});
|
|
336
|
+
await refreshRoles(activeRole.id);
|
|
337
|
+
toast.success('Rol actualizado');
|
|
338
|
+
}
|
|
339
|
+
setRoleDialog((d) => ({ ...d, open: false }));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol');
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
setRoleSaving(false);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const handleDeleteRole = async () => {
|
|
349
|
+
if (!deleteRole || !activeRole)
|
|
350
|
+
return;
|
|
351
|
+
setDeleting(true);
|
|
352
|
+
try {
|
|
353
|
+
await deleteRole(activeRole.id);
|
|
354
|
+
const rs = await loadRoles();
|
|
355
|
+
setRoles(rs);
|
|
356
|
+
setActiveRoleId(rs[0]?.id ?? null);
|
|
357
|
+
toast.success('Rol eliminado');
|
|
358
|
+
setDeleteOpen(false);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
toast.error('No se pudo eliminar el rol');
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
setDeleting(false);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const openEditRole = () => {
|
|
368
|
+
if (!activeRole)
|
|
369
|
+
return;
|
|
370
|
+
setRoleDialog({
|
|
371
|
+
open: true,
|
|
372
|
+
mode: 'edit',
|
|
373
|
+
label: activeRole.label || activeRole.name,
|
|
374
|
+
color: activeRole.color || ROLE_COLORS[5],
|
|
375
|
+
});
|
|
376
|
+
};
|
|
377
|
+
// ---- derived for the right panel ----------------------------------------
|
|
378
|
+
const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0;
|
|
379
|
+
const moduleTotal = activeModule?.actions.length ?? 0;
|
|
380
|
+
const checksDisabled = !activeRole || !draft || loadingPerms || saving;
|
|
381
|
+
// ---- render --------------------------------------------------------------
|
|
382
|
+
if (loadError) {
|
|
383
|
+
return (_jsxs("div", { className: cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className), children: [_jsx(Shield, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: "No se pudo cargar el cat\u00E1logo de permisos." })] }));
|
|
384
|
+
}
|
|
385
|
+
if (loading) {
|
|
386
|
+
return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Skeleton, { className: "h-8 w-56" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Skeleton, { className: "h-9 w-28" }), _jsx(Skeleton, { className: "h-9 w-40" })] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Skeleton, { className: "h-40 w-full" }), _jsx(Skeleton, { className: "h-80 w-full" })] }), _jsx(Skeleton, { className: "h-96 w-full" })] })] }));
|
|
387
|
+
}
|
|
388
|
+
return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-bold tracking-tight", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Define qu\u00E9 puede hacer cada rol en cada m\u00F3dulo." })] }), _jsxs("div", { className: "flex items-center gap-2", children: [dirty && (_jsx(Badge, { variant: "outline", className: "border-amber-500/50 text-amber-600", children: "Cambios sin guardar" })), createRole && (_jsxs(Button, { onClick: () => setRoleDialog({
|
|
389
|
+
open: true,
|
|
390
|
+
mode: 'create',
|
|
391
|
+
label: '',
|
|
392
|
+
color: ROLE_COLORS[5],
|
|
393
|
+
}), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "min-w-0 flex-1 justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeRole && (_jsx("span", { className: "h-2.5 w-2.5 shrink-0 rounded-full", style: {
|
|
394
|
+
background: activeRole.color || '#6b7280',
|
|
395
|
+
}, "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: activeRole
|
|
396
|
+
? activeRole.label || activeRole.name
|
|
397
|
+
: 'Seleccionar rol…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[280px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar rol\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), _jsx(CommandGroup, { children: (roles ?? []).map((role) => (_jsxs(CommandItem, { value: `${role.label || ''} ${role.name}`, onSelect: () => {
|
|
398
|
+
requestRoleSwitch(role.id);
|
|
399
|
+
setRoleOpen(false);
|
|
400
|
+
}, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: {
|
|
401
|
+
background: role.color || '#6b7280',
|
|
402
|
+
}, "aria-hidden": "true" }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), updateRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0", "aria-label": "Editar rol", disabled: !activeRole, onClick: openEditRole, children: _jsx(Pencil, { className: "h-4 w-4" }) })), deleteRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", disabled: !activeRole, onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (catalog?.general.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: catalog.general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulos" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: moduleQuery, onChange: (e) => setModuleQuery(e.target.value), placeholder: "Buscar m\u00F3dulo\u2026", "aria-label": "Buscar m\u00F3dulo", className: "pl-8" })] }), _jsx("div", { role: "tree", "aria-label": "M\u00F3dulos", className: "-mx-1 max-h-[460px] overflow-y-auto px-1", children: visibleGroups.length === 0 ? (_jsx("p", { className: "px-2 py-6 text-center text-sm text-muted-foreground", children: "Sin m\u00F3dulos." })) : (visibleGroups.map((group) => {
|
|
403
|
+
// While searching, force every matching group open.
|
|
404
|
+
const open = searching || !collapsedGroups.has(group.label);
|
|
405
|
+
return (_jsxs(Collapsible, { open: open, onOpenChange: () => !searching && toggleGroup(group.label), children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40", children: [_jsx(ChevronRight, { className: cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-90') }), _jsx(Folder, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate normal-case", children: group.label }), _jsx("span", { className: "shrink-0 text-[10px] tabular-nums opacity-70", children: group.modules.length })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-1.5", children: group.modules.map((mod) => (_jsx(ModuleTreeItem, { module: mod, active: mod.key === activeModuleKey, granted: draft
|
|
406
|
+
? grantedCountForModule(draft, mod)
|
|
407
|
+
: 0, total: mod.actions.length, onSelect: () => setActiveModuleKey(mod.key) }, mod.key))) }) })] }, group.label));
|
|
408
|
+
})) })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [activeModule && (_jsx(DynamicIcon, { name: activeModule.icon || 'Square', className: "h-4 w-4 shrink-0 text-primary" })), _jsx("span", { className: "truncate", children: activeModule ? activeModule.label : 'Acciones permitidas' })] }), _jsx(CardDescription, { children: activeModule
|
|
409
|
+
? `${moduleGroupLabel(activeModule)} · configura las acciones permitidas`
|
|
410
|
+
: 'Configura los permisos del módulo seleccionado.' })] }), activeRole && activeModule && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Badge, { variant: "secondary", className: "tabular-nums", children: [moduleGranted, "/", moduleTotal] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === moduleTotal, onClick: () => setModuleAll(true), children: [_jsx(CheckCheck, { className: "mr-1.5 h-3.5 w-3.5" }), " Marcar todo"] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === 0, onClick: () => setModuleAll(false), children: [_jsx(Eraser, { className: "mr-1.5 h-3.5 w-3.5" }), " Limpiar"] })] }))] }) }), _jsx(CardContent, { children: !activeRole ? (_jsx(EmptyHint, { text: "Selecciona un rol para configurar sus permisos." })) : loadingPerms ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Skeleton, { className: "h-11 w-full" }, i))) })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo del \u00E1rbol para ver sus acciones." })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: activeModule.actions.map((action) => {
|
|
411
|
+
const cap = moduleActionCapability(activeModule.key, action.key);
|
|
412
|
+
return (_jsx(CapabilityCheck, { checked: draft?.has(cap) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(cap), icon: action.icon || defaultActionIcon(action.key, action.kind), label: action.label }, action.key));
|
|
413
|
+
}) })) })] })] }), _jsx(AlertDialog, { open: pendingRoleId !== null, onOpenChange: (open) => !open && setPendingRoleId(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Cambios sin guardar" }), _jsx(AlertDialogDescription, { children: "Tienes cambios sin guardar en este rol. Si cambias de rol se descartar\u00E1n." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancelar" }), _jsx(AlertDialogAction, { onClick: () => {
|
|
414
|
+
setActiveRoleId(pendingRoleId);
|
|
415
|
+
setPendingRoleId(null);
|
|
416
|
+
}, children: "Descartar y cambiar" })] })] }) }), _jsx(Dialog, { open: roleDialog.open, onOpenChange: (open) => setRoleDialog((d) => ({ ...d, open })), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol' }) }), _jsxs("div", { className: "flex flex-col gap-4 py-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "pm-role-name", children: "Nombre del rol" }), _jsx(Input, { id: "pm-role-name", value: roleDialog.label, placeholder: "Ej. Cajero", onChange: (e) => setRoleDialog((d) => ({ ...d, label: e.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Color" }), _jsx("div", { className: "flex flex-wrap gap-2", children: ROLE_COLORS.map((c) => (_jsx("button", { type: "button", "aria-label": `Color ${c}`, onClick: () => setRoleDialog((d) => ({ ...d, color: c })), className: cn('h-7 w-7 rounded-full border-2 transition-transform', roleDialog.color === c
|
|
417
|
+
? 'scale-110 border-foreground'
|
|
418
|
+
: 'border-transparent hover:scale-105'), style: { background: c } }, c))) })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRoleDialog((d) => ({ ...d, open: false })), disabled: roleSaving, children: "Cancelar" }), _jsx(Button, { onClick: handleRoleSubmit, disabled: roleSaving || !roleDialog.label.trim(), children: roleSaving
|
|
419
|
+
? 'Guardando…'
|
|
420
|
+
: roleDialog.mode === 'create'
|
|
421
|
+
? 'Crear rol'
|
|
422
|
+
: 'Guardar' })] })] }) }), _jsx(AlertDialog, { open: deleteOpen, onOpenChange: (open) => !deleting && setDeleteOpen(open), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "\u00BFEliminar el rol?" }), _jsxs(AlertDialogDescription, { children: ["Se eliminar\u00E1 el rol", ' ', _jsx("strong", { children: activeRole ? activeRole.label || activeRole.name : '' }), " y sus asignaciones de permisos. Esta acci\u00F3n no se puede deshacer."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: deleting, children: "Cancelar" }), _jsx(AlertDialogAction, { className: "bg-red-600 hover:bg-red-700", disabled: deleting, onClick: (e) => {
|
|
423
|
+
e.preventDefault();
|
|
424
|
+
handleDeleteRole();
|
|
425
|
+
}, children: deleting ? 'Eliminando…' : 'Eliminar' })] })] }) })] }));
|
|
426
|
+
}
|
|
427
|
+
function EmptyHint({ text }) {
|
|
428
|
+
return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground", children: [_jsx(Shield, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: text })] }));
|
|
429
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.15.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,18 +21,18 @@
|
|
|
21
21
|
"zod": "^4.3.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"react": ">=18",
|
|
25
|
-
"react-dom": ">=18",
|
|
26
24
|
"@tanstack/react-query": ">=5",
|
|
27
|
-
"@tanstack/react-table": ">=8",
|
|
28
25
|
"@tanstack/react-router": ">=1.100",
|
|
26
|
+
"@tanstack/react-table": ">=8",
|
|
27
|
+
"date-fns": ">=3",
|
|
29
28
|
"i18next": ">=23",
|
|
29
|
+
"lucide-react": ">=0.460",
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-day-picker": ">=8",
|
|
32
|
+
"react-dom": ">=18",
|
|
30
33
|
"react-i18next": ">=13",
|
|
31
34
|
"sonner": ">=1.7",
|
|
32
35
|
"zustand": ">=5",
|
|
33
|
-
"lucide-react": ">=0.460",
|
|
34
|
-
"date-fns": ">=3",
|
|
35
|
-
"react-day-picker": ">=8",
|
|
36
36
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
37
37
|
"@asteby/metacore-ui": "^2.5.1"
|
|
38
38
|
},
|
|
@@ -48,8 +48,10 @@
|
|
|
48
48
|
"@tanstack/react-query": "^5.95.0",
|
|
49
49
|
"@tanstack/react-router": "^1.168.0",
|
|
50
50
|
"@tanstack/react-table": "^8.20.0",
|
|
51
|
+
"@testing-library/react": "^16.3.2",
|
|
51
52
|
"@types/react": "^19.0.0",
|
|
52
53
|
"date-fns": "^4.1.0",
|
|
54
|
+
"happy-dom": "^20.10.2",
|
|
53
55
|
"i18next": "^26.0.0",
|
|
54
56
|
"lucide-react": "^1.0.0",
|
|
55
57
|
"react": "^19.2.4",
|
|
@@ -61,8 +63,8 @@
|
|
|
61
63
|
"typescript": "^6.0.0",
|
|
62
64
|
"vitest": "^4.0.0",
|
|
63
65
|
"zustand": "^5.0.0",
|
|
64
|
-
"@asteby/metacore-
|
|
65
|
-
"@asteby/metacore-
|
|
66
|
+
"@asteby/metacore-sdk": "3.2.0",
|
|
67
|
+
"@asteby/metacore-ui": "2.5.1"
|
|
66
68
|
},
|
|
67
69
|
"scripts": {
|
|
68
70
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Gating de permisos sobre las superficies CRUD dinámicas:
|
|
4
|
+
// 1. `gateTableMetadata` (puro) — export/import, row actions custom y el
|
|
5
|
+
// trío implícito View/Edit/Delete filtrados por capability.
|
|
6
|
+
// 2. <DynamicCRUDPage> — el botón Crear desaparece sin `model.create`
|
|
7
|
+
// cuando hay <PermissionsProvider>; sin provider todo sigue visible.
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
|
|
11
|
+
// DynamicTable usa useNavigate; el test no navega, así que basta un stub.
|
|
12
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
13
|
+
useNavigate: () => () => {},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { gateTableMetadata, makeCan, PermissionsProvider } from '../permissions-context'
|
|
17
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
18
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
19
|
+
import { DynamicCRUDPage } from '../dynamic-crud-page'
|
|
20
|
+
import type { TableMetadata, ActionDefinition } from '../types'
|
|
21
|
+
|
|
22
|
+
// Sin `globals: true` en vitest, RTL no auto-limpia entre tests.
|
|
23
|
+
afterEach(cleanup)
|
|
24
|
+
|
|
25
|
+
function baseMeta(over: Partial<TableMetadata> = {}): TableMetadata {
|
|
26
|
+
return {
|
|
27
|
+
title: 'Pedidos',
|
|
28
|
+
endpoint: '/data/pos_orders',
|
|
29
|
+
columns: [],
|
|
30
|
+
actions: [],
|
|
31
|
+
perPageOptions: [10],
|
|
32
|
+
defaultPerPage: 10,
|
|
33
|
+
searchPlaceholder: 'Buscar...',
|
|
34
|
+
enableCRUDActions: true,
|
|
35
|
+
hasActions: false,
|
|
36
|
+
canExport: true,
|
|
37
|
+
canImport: true,
|
|
38
|
+
...over,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const action = (key: string, placement?: ActionDefinition['placement']): ActionDefinition =>
|
|
43
|
+
({ key, name: key, label: key, icon: 'Zap', placement }) as ActionDefinition
|
|
44
|
+
|
|
45
|
+
describe('gateTableMetadata', () => {
|
|
46
|
+
it('apaga canExport/canImport sin capability', () => {
|
|
47
|
+
const can = makeCan(['pos_orders.export'], false)
|
|
48
|
+
const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
|
|
49
|
+
expect(gated.canExport).toBe(true)
|
|
50
|
+
expect(gated.canImport).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('filtra row actions custom por can(model.key)', () => {
|
|
54
|
+
const meta = baseMeta({
|
|
55
|
+
actions: [action('pagar'), action('cancelar')],
|
|
56
|
+
hasActions: true,
|
|
57
|
+
})
|
|
58
|
+
const can = makeCan(['pos_orders.pagar'], false)
|
|
59
|
+
const gated = gateTableMetadata(meta, 'pos_orders', can)
|
|
60
|
+
expect(gated.actions.map((a) => a.key)).toEqual(['pagar'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('mapea edit→update, delete→delete, view→index en actions explícitas', () => {
|
|
64
|
+
const meta = baseMeta({
|
|
65
|
+
actions: [action('view'), action('edit'), action('delete')],
|
|
66
|
+
hasActions: true,
|
|
67
|
+
})
|
|
68
|
+
const can = makeCan(['pos_orders.index', 'pos_orders.update'], false)
|
|
69
|
+
const gated = gateTableMetadata(meta, 'pos_orders', can)
|
|
70
|
+
expect(gated.actions.map((a) => a.key)).toEqual(['view', 'edit'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('materializa el trío implícito CRUD y filtra sus entradas', () => {
|
|
74
|
+
// Sin actions explícitas + enableCRUDActions → trío View/Edit/Delete.
|
|
75
|
+
const can = makeCan(['pos_orders.index', 'pos_orders.delete'], false)
|
|
76
|
+
const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
|
|
77
|
+
expect(gated.actions.map((a) => a.key)).toEqual(['view', 'delete'])
|
|
78
|
+
expect(gated.hasActions).toBe(true)
|
|
79
|
+
expect(gated.enableCRUDActions).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('todo denegado → sin actions y sin trío re-sintetizado aguas abajo', () => {
|
|
83
|
+
const can = makeCan([], false)
|
|
84
|
+
const gated = gateTableMetadata(baseMeta(), 'pos_orders', can)
|
|
85
|
+
expect(gated.actions).toEqual([])
|
|
86
|
+
expect(gated.hasActions).toBe(false)
|
|
87
|
+
expect(gated.enableCRUDActions).toBe(false)
|
|
88
|
+
expect(gated.canExport).toBe(false)
|
|
89
|
+
expect(gated.canImport).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('admin/wildcard → metadata intacta en lo visible', () => {
|
|
93
|
+
const meta = baseMeta({ actions: [action('pagar')], hasActions: true })
|
|
94
|
+
const gated = gateTableMetadata(meta, 'pos_orders', makeCan([], true))
|
|
95
|
+
expect(gated.actions.map((a) => a.key)).toEqual(['pagar'])
|
|
96
|
+
expect(gated.canExport).toBe(true)
|
|
97
|
+
expect(gated.canImport).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('DynamicCRUDPage — botón Crear', () => {
|
|
102
|
+
function fakeApi(meta: TableMetadata): ApiClient {
|
|
103
|
+
const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: 0 } } })
|
|
104
|
+
return {
|
|
105
|
+
get: vi.fn(async (url: string) => {
|
|
106
|
+
if (url.startsWith('/metadata/table/')) return ok(meta)
|
|
107
|
+
return ok([])
|
|
108
|
+
}),
|
|
109
|
+
post: vi.fn(async () => ok(null)),
|
|
110
|
+
put: vi.fn(async () => ok(null)),
|
|
111
|
+
delete: vi.fn(async () => ok(null)),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mount(model: string, permissions: string[] | null) {
|
|
116
|
+
const meta = baseMeta({ title: 'Pedidos', canExport: false, canImport: false })
|
|
117
|
+
useMetadataCache.getState().setMetadata(model, meta)
|
|
118
|
+
const page = <DynamicCRUDPage model={model} />
|
|
119
|
+
return render(
|
|
120
|
+
<ApiProvider client={fakeApi(meta)}>
|
|
121
|
+
{permissions === null ? (
|
|
122
|
+
page
|
|
123
|
+
) : (
|
|
124
|
+
<PermissionsProvider permissions={permissions} isAdmin={false}>
|
|
125
|
+
{page}
|
|
126
|
+
</PermissionsProvider>
|
|
127
|
+
)}
|
|
128
|
+
</ApiProvider>,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it('sin provider → Crear visible (comportamiento actual)', async () => {
|
|
133
|
+
mount('orders_a', null)
|
|
134
|
+
expect(await screen.findByText(/New Pedido/)).toBeTruthy()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('con provider y sin model.create → Crear oculto', async () => {
|
|
138
|
+
mount('orders_b', ['orders_b.index'])
|
|
139
|
+
expect(await screen.findByText('Pedidos')).toBeTruthy()
|
|
140
|
+
expect(screen.queryByText(/New Pedido/)).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('con provider y model.create otorgado → Crear visible', async () => {
|
|
144
|
+
mount('orders_c', ['orders_c.index', 'orders_c.create'])
|
|
145
|
+
expect(await screen.findByText(/New Pedido/)).toBeTruthy()
|
|
146
|
+
})
|
|
147
|
+
})
|