@asteby/metacore-runtime-react 18.15.0 → 18.16.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.
@@ -10,13 +10,66 @@ import {
10
10
  moduleCapabilities,
11
11
  grantedCountForModule,
12
12
  capabilitySetsEqual,
13
- groupModules,
13
+ normalizeCatalogGroups,
14
+ flattenGroups,
14
15
  filterModuleGroups,
16
+ defaultActionIcon,
15
17
  type PermissionsCatalog,
18
+ type GroupedPermissionsCatalog,
19
+ type FlatPermissionsCatalog,
16
20
  type RoleDef,
17
21
  } from '../permissions-manager'
18
22
 
19
- const catalog: PermissionsCatalog = {
23
+ // New (preferred) shape: pre-grouped flat list, mirrors the host sidebar.
24
+ const grouped: GroupedPermissionsCatalog = {
25
+ groups: [
26
+ {
27
+ title: '', // core/infra — no header
28
+ modules: [
29
+ {
30
+ key: 'users',
31
+ label: 'Usuarios',
32
+ icon: 'Users',
33
+ kind: 'model',
34
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
35
+ },
36
+ ],
37
+ },
38
+ {
39
+ title: 'Punto de venta',
40
+ modules: [
41
+ {
42
+ key: 'pos_orders',
43
+ label: 'Pedidos POS',
44
+ icon: 'ShoppingCart',
45
+ kind: 'model',
46
+ actions: [
47
+ { key: 'index', label: 'Listar', icon: 'List', kind: 'crud' },
48
+ { key: 'create', label: 'Crear', icon: 'Plus', kind: 'crud' },
49
+ { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
50
+ ],
51
+ },
52
+ {
53
+ key: 'screen.pos_terminal',
54
+ label: 'Terminal',
55
+ icon: 'Monitor',
56
+ kind: 'screen',
57
+ actions: [{ key: 'access', label: 'Acceder', icon: 'Eye', kind: 'screen' }],
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ general: [
63
+ {
64
+ key: 'general.work_after_hours',
65
+ label: 'Trabajar fuera de horario',
66
+ description: 'Permite operar fuera del horario configurado.',
67
+ },
68
+ ],
69
+ }
70
+
71
+ // Legacy flat shape (retrocompat): no `groups`, modules without `kind`.
72
+ const legacy: FlatPermissionsCatalog = {
20
73
  modules: [
21
74
  {
22
75
  key: 'pos_orders',
@@ -26,18 +79,9 @@ const catalog: PermissionsCatalog = {
26
79
  addon_label: 'Punto de venta',
27
80
  actions: [
28
81
  { key: 'index', label: 'Listar', icon: 'List', kind: 'crud' },
29
- { key: 'create', label: 'Crear', icon: 'Plus', kind: 'crud' },
30
82
  { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
31
83
  ],
32
84
  },
33
- {
34
- key: 'pos_sessions',
35
- label: 'Sesiones POS',
36
- icon: 'Clock',
37
- addon_key: 'pos',
38
- addon_label: 'Punto de venta',
39
- actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
40
- },
41
85
  {
42
86
  key: 'users',
43
87
  label: 'Usuarios',
@@ -45,18 +89,15 @@ const catalog: PermissionsCatalog = {
45
89
  actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
46
90
  },
47
91
  ],
48
- general: [
49
- {
50
- key: 'general.work_after_hours',
51
- label: 'Trabajar fuera de horario',
52
- description: 'Permite operar fuera del horario configurado.',
53
- },
54
- ],
92
+ general: [],
55
93
  }
56
94
 
57
95
  const roles: RoleDef[] = [{ id: 'r1', name: 'cashier', label: 'Cajero', color: '#22c55e' }]
58
96
 
59
- function makeProps(overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {}) {
97
+ function makeProps(
98
+ catalog: PermissionsCatalog = grouped,
99
+ overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {},
100
+ ) {
60
101
  return {
61
102
  loadModules: vi.fn(async () => catalog),
62
103
  loadRoles: vi.fn(async () => roles),
@@ -71,8 +112,14 @@ describe('helpers puros', () => {
71
112
  expect(moduleActionCapability('Pos_Orders', 'pagar')).toBe('pos_orders.pagar')
72
113
  })
73
114
 
115
+ it('screen capability = screen.<navKey>.access', () => {
116
+ expect(moduleActionCapability('screen.pos_terminal', 'access')).toBe(
117
+ 'screen.pos_terminal.access',
118
+ )
119
+ })
120
+
74
121
  it('moduleCapabilities y grantedCountForModule', () => {
75
- const mod = catalog.modules[0]
122
+ const mod = grouped.groups[1].modules[0] // pos_orders
76
123
  expect(moduleCapabilities(mod)).toEqual([
77
124
  'pos_orders.index',
78
125
  'pos_orders.create',
@@ -86,19 +133,47 @@ describe('helpers puros', () => {
86
133
  expect(capabilitySetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
87
134
  })
88
135
 
89
- it('groupModules agrupa por addon_label y manda los sin addon a "Sistema"', () => {
90
- const groups = groupModules(catalog.modules)
91
- expect(groups.map((g) => g.label)).toEqual(['Punto de venta', 'Sistema'])
92
- expect(groups[0].modules.map((m) => m.key)).toEqual(['pos_orders', 'pos_sessions'])
93
- expect(groups[1].modules.map((m) => m.key)).toEqual(['users'])
136
+ it('defaultActionIcon mapea access→Eye y screens', () => {
137
+ expect(defaultActionIcon('access')).toBe('Eye')
138
+ expect(defaultActionIcon('algo', 'screen')).toBe('Eye')
139
+ expect(defaultActionIcon('index')).toBe('List')
94
140
  })
95
141
 
96
- it('filterModuleGroups busca por módulo (accent/case-insensitive) o por grupo', () => {
97
- const groups = groupModules(catalog.modules)
142
+ describe('normalizeCatalogGroups', () => {
143
+ it('pasa el shape nuevo {groups} tal cual, default kind:model', () => {
144
+ const out = normalizeCatalogGroups(grouped)
145
+ expect(out.map((g) => g.title)).toEqual(['', 'Punto de venta'])
146
+ expect(out[1].modules.map((m) => m.key)).toEqual(['pos_orders', 'screen.pos_terminal'])
147
+ // El screen conserva su kind; el modelo conserva model.
148
+ expect(out[1].modules[0].kind).toBe('model')
149
+ expect(out[1].modules[1].kind).toBe('screen')
150
+ })
151
+
152
+ it('retrocompat: envuelve el shape viejo {modules} y agrupa por addon, kind:model', () => {
153
+ const out = normalizeCatalogGroups(legacy)
154
+ // Agrupa por addon_label / "Sistema" para los sin addon.
155
+ expect(out.map((g) => g.title)).toEqual(['Punto de venta', 'Sistema'])
156
+ expect(out[0].modules.map((m) => m.key)).toEqual(['pos_orders'])
157
+ expect(out[1].modules.map((m) => m.key)).toEqual(['users'])
158
+ // Todos los módulos legacy quedan como model.
159
+ expect(flattenGroups(out).every((m) => m.kind === 'model')).toBe(true)
160
+ })
161
+ })
162
+
163
+ it('flattenGroups recorre los grupos en orden', () => {
164
+ expect(flattenGroups(grouped.groups).map((m) => m.key)).toEqual([
165
+ 'users',
166
+ 'pos_orders',
167
+ 'screen.pos_terminal',
168
+ ])
169
+ })
170
+
171
+ it('filterModuleGroups busca por módulo (accent/case-insensitive) o por título de grupo', () => {
172
+ const groups = grouped.groups
98
173
  // Por nombre de módulo.
99
- const bySession = filterModuleGroups(groups, 'sesiones')
100
- expect(bySession).toHaveLength(1)
101
- expect(bySession[0].modules.map((m) => m.key)).toEqual(['pos_sessions'])
174
+ const byTerminal = filterModuleGroups(groups, 'terminal')
175
+ expect(byTerminal).toHaveLength(1)
176
+ expect(byTerminal[0].modules.map((m) => m.key)).toEqual(['screen.pos_terminal'])
102
177
  // Por nombre de grupo trae todos sus módulos.
103
178
  const byGroup = filterModuleGroups(groups, 'venta')
104
179
  expect(byGroup[0].modules).toHaveLength(2)
@@ -109,66 +184,103 @@ describe('helpers puros', () => {
109
184
  })
110
185
  })
111
186
 
112
- describe('PermissionsManager', () => {
113
- it('renderiza catálogo con mocks, auto-selecciona rol y módulo, contador N/M', async () => {
187
+ describe('PermissionsManager (lista plana, shape nuevo)', () => {
188
+ it('renderiza catálogo, auto-selecciona rol y primer módulo, contador N/M', async () => {
114
189
  const props = makeProps()
115
190
  render(<PermissionsManager {...props} />)
116
191
 
117
- // Auto-selección: primer rol + primer módulo, grid con las acciones.
118
- // El panel derecho titula con el módulo activo ("Pedidos POS").
119
- expect(await screen.findAllByText('Pedidos POS')).toBeTruthy()
120
- expect(await screen.findByText('Pagar')).toBeTruthy()
121
- // El contador N/M del panel está presente (también lo refleja el árbol).
122
- expect(screen.getAllByText('1/3').length).toBeGreaterThan(0)
192
+ // Primer módulo = "Usuarios" (grupo sin título, va primero).
193
+ expect(await screen.findAllByText('Usuarios')).toBeTruthy()
123
194
  expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
124
195
 
196
+ // Headers de grupo grises (no colapsables): el del grupo con título.
197
+ expect(screen.getByText('Punto de venta')).toBeTruthy()
198
+ // Filas de módulo de la lista plana.
199
+ expect(screen.getByRole('button', { name: /Pedidos POS/ })).toBeTruthy()
200
+ expect(screen.getByRole('button', { name: /Terminal/ })).toBeTruthy()
201
+
125
202
  // Generales presentes con descripción.
126
203
  expect(screen.getByText('Permisos Generales')).toBeTruthy()
127
204
  expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
128
205
  })
129
206
 
130
- it('marcar una acción + un general y guardar llama sync con el set completo correcto', async () => {
207
+ it('CERO acordeones: el header de grupo es un heading, no un botón colapsable', async () => {
131
208
  const props = makeProps()
132
209
  render(<PermissionsManager {...props} />)
133
- await screen.findByText('Pagar')
210
+ await screen.findAllByText('Usuarios')
211
+ // El header gris "Punto de venta" es un heading, NO un button (sin folder/acordeón).
212
+ const header = screen.getByText('Punto de venta')
213
+ expect(header.closest('button')).toBeNull()
214
+ expect(header.getAttribute('role')).toBe('heading')
215
+ // No existe ningún botón cuyo accesible name sea el título del grupo
216
+ // (lo que delataría un CollapsibleTrigger).
217
+ expect(screen.queryByRole('button', { name: 'Punto de venta' })).toBeNull()
218
+ })
134
219
 
135
- fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
136
- fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
220
+ it('click directo en una fila selecciona el módulo y muestra su grid', async () => {
221
+ const props = makeProps()
222
+ render(<PermissionsManager {...props} />)
223
+ await screen.findAllByText('Usuarios')
137
224
 
138
- // Dirty visible y guardar habilitado.
139
- expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
140
- fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
225
+ // Selecciono "Pedidos POS" su grid aparece a la derecha.
226
+ fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
227
+ expect(await screen.findByText('Pagar')).toBeTruthy()
141
228
 
229
+ // Selecciono el screen "Terminal" → acción "Acceder".
230
+ fireEvent.click(screen.getByRole('button', { name: /Terminal/ }))
231
+ expect(await screen.findByText('Acceder')).toBeTruthy()
232
+ await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
233
+ })
234
+
235
+ it('marcar el screen "Acceder" produce capability screen.<navKey>.access', async () => {
236
+ const props = makeProps()
237
+ render(<PermissionsManager {...props} />)
238
+ await screen.findAllByText('Usuarios')
239
+
240
+ fireEvent.click(screen.getByRole('button', { name: /Terminal/ }))
241
+ await screen.findByText('Acceder')
242
+ fireEvent.click(screen.getByRole('checkbox', { name: /Acceder/ }))
243
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
142
244
  await waitFor(() =>
143
245
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
144
- 'general.work_after_hours',
145
246
  'pos_orders.index',
146
- 'pos_orders.pagar',
247
+ 'screen.pos_terminal.access',
147
248
  ]),
148
249
  )
149
- // Tras guardar, baseline = draft → dirty desaparece.
150
- await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
151
250
  })
152
251
 
153
- it('desmarcar una otorgada también entra al delta', async () => {
252
+ it('marcar una acción + un general y guardar llama sync con el set completo', async () => {
154
253
  const props = makeProps()
155
254
  render(<PermissionsManager {...props} />)
255
+ await screen.findAllByText('Usuarios')
256
+
257
+ fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
156
258
  await screen.findByText('Pagar')
259
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
260
+ fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
157
261
 
158
- fireEvent.click(screen.getByRole('checkbox', { name: /Listar/ }))
262
+ expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
159
263
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
160
- await waitFor(() => expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', []))
264
+
265
+ await waitFor(() =>
266
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
267
+ 'general.work_after_hours',
268
+ 'pos_orders.index',
269
+ 'pos_orders.pagar',
270
+ ]),
271
+ )
272
+ await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
161
273
  })
162
274
 
163
275
  it('marcar todo / limpiar operan sobre el módulo activo', async () => {
164
276
  const props = makeProps()
165
277
  render(<PermissionsManager {...props} />)
278
+ await screen.findAllByText('Usuarios')
279
+ fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
166
280
  await screen.findByText('Pagar')
167
281
 
168
282
  fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
169
- // 3/3 aparece en el panel y en el badge del árbol.
170
283
  expect(screen.getAllByText('3/3').length).toBeGreaterThan(0)
171
-
172
284
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
173
285
  await waitFor(() =>
174
286
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
@@ -185,74 +297,86 @@ describe('PermissionsManager', () => {
185
297
  it('guardar deshabilitado sin cambios', async () => {
186
298
  const props = makeProps()
187
299
  render(<PermissionsManager {...props} />)
188
- await screen.findByText('Pagar')
300
+ await screen.findAllByText('Usuarios')
189
301
  const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
190
302
  expect(save.disabled).toBe(true)
191
303
  })
192
304
 
193
- it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
305
+ it('la búsqueda filtra las filas de la lista plana', async () => {
194
306
  const props = makeProps()
195
307
  render(<PermissionsManager {...props} />)
196
- await screen.findByText('Pagar')
197
- expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
198
- expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
199
- expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
200
- })
201
-
202
- it('renderiza el árbol agrupado y permite seleccionar un módulo de otro grupo', async () => {
203
- const props = makeProps()
204
- render(<PermissionsManager {...props} />)
205
- await screen.findByText('Pagar')
308
+ await screen.findAllByText('Usuarios')
206
309
 
207
- // Grupos visibles (encabezados del árbol).
208
- expect(screen.getByText('Punto de venta')).toBeTruthy()
209
- expect(screen.getByText('Sistema')).toBeTruthy()
210
-
211
- // Click en "Usuarios" (grupo Sistema) cambia el grid de acciones.
212
- fireEvent.click(screen.getByRole('button', { name: /Usuarios/ }))
213
- // El grid ahora muestra solo la acción de users; "Pagar" (de pos_orders) ya no.
214
- await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
215
- // "Usuarios" titula el panel derecho además del árbol.
216
- expect(screen.getAllByText('Usuarios').length).toBeGreaterThan(0)
310
+ fireEvent.change(screen.getByLabelText('Buscar módulo'), {
311
+ target: { value: 'terminal' },
312
+ })
313
+ // Solo el módulo con match permanece como fila; otros se ocultan.
314
+ expect(screen.getByRole('button', { name: /Terminal/ })).toBeTruthy()
315
+ expect(screen.queryByRole('button', { name: /Pedidos POS/ })).toBeNull()
316
+ expect(screen.queryByRole('button', { name: /Usuarios/ })).toBeNull()
217
317
  })
218
318
 
219
- it('la búsqueda filtra el árbol de módulos', async () => {
319
+ it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
220
320
  const props = makeProps()
221
321
  render(<PermissionsManager {...props} />)
222
- await screen.findByText('Pagar')
223
-
224
- fireEvent.change(screen.getByLabelText('Buscar módulo'), {
225
- target: { value: 'sesiones' },
226
- })
227
- // Solo el grupo con match permanece.
228
- expect(screen.getByText('Sesiones POS')).toBeTruthy()
229
- expect(screen.queryByText('Usuarios')).toBeNull()
322
+ await screen.findAllByText('Usuarios')
323
+ expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
324
+ expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
325
+ expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
230
326
  })
231
327
 
232
328
  it('selector de rol limpio: edit/delete inline, sin chip removible', async () => {
233
- const props = makeProps({
329
+ const props = makeProps(grouped, {
234
330
  updateRole: vi.fn(async () => {}),
235
331
  deleteRole: vi.fn(async () => {}),
236
332
  })
237
333
  render(<PermissionsManager {...props} />)
238
- await screen.findByText('Pagar')
239
- // No existe el botón de quitar rol del chip antiguo.
334
+ await screen.findAllByText('Usuarios')
240
335
  expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
241
- // Iconos inline presentes.
242
336
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
243
337
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
244
338
  })
245
339
 
246
340
  it('muestra los CRUD de rol cuando los mutators existen', async () => {
247
- const props = makeProps({
341
+ const props = makeProps(grouped, {
248
342
  createRole: vi.fn(async () => {}),
249
343
  updateRole: vi.fn(async () => {}),
250
344
  deleteRole: vi.fn(async () => {}),
251
345
  })
252
346
  render(<PermissionsManager {...props} />)
253
- await screen.findByText('Pagar')
347
+ await screen.findAllByText('Usuarios')
254
348
  expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
255
349
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
256
350
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
257
351
  })
258
352
  })
353
+
354
+ describe('PermissionsManager (retrocompat shape viejo {modules})', () => {
355
+ it('renderiza el shape flat legacy sin romper, agrupado por addon', async () => {
356
+ const props = makeProps(legacy)
357
+ render(<PermissionsManager {...props} />)
358
+
359
+ // Auto-selección del primer módulo legacy (pos_orders → grupo "Punto de venta").
360
+ expect(await screen.findByText('Pagar')).toBeTruthy()
361
+ // Header gris derivado del addon.
362
+ expect(screen.getByText('Punto de venta')).toBeTruthy()
363
+ // El grupo Sistema (users sin addon) también.
364
+ expect(screen.getByText('Sistema')).toBeTruthy()
365
+ expect(screen.getByRole('button', { name: /Usuarios/ })).toBeTruthy()
366
+ })
367
+
368
+ it('legacy: click + guardar produce capabilities correctas', async () => {
369
+ const props = makeProps(legacy)
370
+ render(<PermissionsManager {...props} />)
371
+ await screen.findByText('Pagar')
372
+
373
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
374
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
375
+ await waitFor(() =>
376
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
377
+ 'pos_orders.index',
378
+ 'pos_orders.pagar',
379
+ ]),
380
+ )
381
+ })
382
+ })
package/src/index.ts CHANGED
@@ -46,8 +46,14 @@ export {
46
46
  grantedCountForModule,
47
47
  capabilitySetsEqual,
48
48
  defaultActionIcon,
49
+ normalizeCatalogGroups,
50
+ flattenGroups,
51
+ filterModuleGroups,
49
52
  type PermissionsManagerProps,
50
53
  type PermissionsCatalog,
54
+ type GroupedPermissionsCatalog,
55
+ type FlatPermissionsCatalog,
56
+ type ModuleGroup,
51
57
  type PermissionModuleDef,
52
58
  type PermissionActionDef,
53
59
  type GeneralPermissionDef,