@asteby/metacore-runtime-react 18.14.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,36 +10,94 @@ import {
10
10
  moduleCapabilities,
11
11
  grantedCountForModule,
12
12
  capabilitySetsEqual,
13
+ normalizeCatalogGroups,
14
+ flattenGroups,
15
+ filterModuleGroups,
16
+ defaultActionIcon,
13
17
  type PermissionsCatalog,
18
+ type GroupedPermissionsCatalog,
19
+ type FlatPermissionsCatalog,
14
20
  type RoleDef,
15
21
  } from '../permissions-manager'
16
22
 
17
- 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 = {
18
73
  modules: [
19
74
  {
20
75
  key: 'pos_orders',
21
76
  label: 'Pedidos POS',
77
+ icon: 'ShoppingCart',
22
78
  addon_key: 'pos',
23
79
  addon_label: 'Punto de venta',
24
80
  actions: [
25
81
  { key: 'index', label: 'Listar', icon: 'List', kind: 'crud' },
26
- { key: 'create', label: 'Crear', icon: 'Plus', kind: 'crud' },
27
82
  { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
28
83
  ],
29
84
  },
30
- ],
31
- general: [
32
85
  {
33
- key: 'general.work_after_hours',
34
- label: 'Trabajar fuera de horario',
35
- description: 'Permite operar fuera del horario configurado.',
86
+ key: 'users',
87
+ label: 'Usuarios',
88
+ icon: 'Users',
89
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
36
90
  },
37
91
  ],
92
+ general: [],
38
93
  }
39
94
 
40
95
  const roles: RoleDef[] = [{ id: 'r1', name: 'cashier', label: 'Cajero', color: '#22c55e' }]
41
96
 
42
- function makeProps(overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {}) {
97
+ function makeProps(
98
+ catalog: PermissionsCatalog = grouped,
99
+ overrides: Partial<Parameters<typeof PermissionsManager>[0]> = {},
100
+ ) {
43
101
  return {
44
102
  loadModules: vi.fn(async () => catalog),
45
103
  loadRoles: vi.fn(async () => roles),
@@ -54,8 +112,14 @@ describe('helpers puros', () => {
54
112
  expect(moduleActionCapability('Pos_Orders', 'pagar')).toBe('pos_orders.pagar')
55
113
  })
56
114
 
115
+ it('screen capability = screen.<navKey>.access', () => {
116
+ expect(moduleActionCapability('screen.pos_terminal', 'access')).toBe(
117
+ 'screen.pos_terminal.access',
118
+ )
119
+ })
120
+
57
121
  it('moduleCapabilities y grantedCountForModule', () => {
58
- const mod = catalog.modules[0]
122
+ const mod = grouped.groups[1].modules[0] // pos_orders
59
123
  expect(moduleCapabilities(mod)).toEqual([
60
124
  'pos_orders.index',
61
125
  'pos_orders.create',
@@ -68,65 +132,155 @@ describe('helpers puros', () => {
68
132
  expect(capabilitySetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
69
133
  expect(capabilitySetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
70
134
  })
135
+
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')
140
+ })
141
+
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
173
+ // Por nombre de módulo.
174
+ const byTerminal = filterModuleGroups(groups, 'terminal')
175
+ expect(byTerminal).toHaveLength(1)
176
+ expect(byTerminal[0].modules.map((m) => m.key)).toEqual(['screen.pos_terminal'])
177
+ // Por nombre de grupo trae todos sus módulos.
178
+ const byGroup = filterModuleGroups(groups, 'venta')
179
+ expect(byGroup[0].modules).toHaveLength(2)
180
+ // Query vacía = pasa todo.
181
+ expect(filterModuleGroups(groups, ' ')).toEqual(groups)
182
+ // Sin match = vacío.
183
+ expect(filterModuleGroups(groups, 'zzz')).toEqual([])
184
+ })
71
185
  })
72
186
 
73
- describe('PermissionsManager', () => {
74
- 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 () => {
75
189
  const props = makeProps()
76
190
  render(<PermissionsManager {...props} />)
77
191
 
78
- // Auto-selección: primer rol + primer módulo, grid con las acciones.
79
- expect(await screen.findByText('Acciones permitidas')).toBeTruthy()
80
- expect(await screen.findByText('Pagar')).toBeTruthy()
81
- expect(screen.getByText('1/3')).toBeTruthy()
192
+ // Primer módulo = "Usuarios" (grupo sin título, va primero).
193
+ expect(await screen.findAllByText('Usuarios')).toBeTruthy()
82
194
  expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
83
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
+
84
202
  // Generales presentes con descripción.
85
203
  expect(screen.getByText('Permisos Generales')).toBeTruthy()
86
204
  expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
87
205
  })
88
206
 
89
- 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 () => {
90
208
  const props = makeProps()
91
209
  render(<PermissionsManager {...props} />)
92
- 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
+ })
93
219
 
94
- fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
95
- 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')
96
224
 
97
- // Dirty visible y guardar habilitado.
98
- expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
99
- 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()
100
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/ }))
101
244
  await waitFor(() =>
102
245
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
103
- 'general.work_after_hours',
104
246
  'pos_orders.index',
105
- 'pos_orders.pagar',
247
+ 'screen.pos_terminal.access',
106
248
  ]),
107
249
  )
108
- // Tras guardar, baseline = draft → dirty desaparece.
109
- await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
110
250
  })
111
251
 
112
- 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 () => {
113
253
  const props = makeProps()
114
254
  render(<PermissionsManager {...props} />)
255
+ await screen.findAllByText('Usuarios')
256
+
257
+ fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
115
258
  await screen.findByText('Pagar')
259
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
260
+ fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
116
261
 
117
- fireEvent.click(screen.getByRole('checkbox', { name: /Listar/ }))
262
+ expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
118
263
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
119
- 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())
120
273
  })
121
274
 
122
275
  it('marcar todo / limpiar operan sobre el módulo activo', async () => {
123
276
  const props = makeProps()
124
277
  render(<PermissionsManager {...props} />)
278
+ await screen.findAllByText('Usuarios')
279
+ fireEvent.click(screen.getByRole('button', { name: /Pedidos POS/ }))
125
280
  await screen.findByText('Pagar')
126
281
 
127
282
  fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
128
- expect(screen.getByText('3/3')).toBeTruthy()
129
-
283
+ expect(screen.getAllByText('3/3').length).toBeGreaterThan(0)
130
284
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
131
285
  await waitFor(() =>
132
286
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
@@ -143,30 +297,86 @@ describe('PermissionsManager', () => {
143
297
  it('guardar deshabilitado sin cambios', async () => {
144
298
  const props = makeProps()
145
299
  render(<PermissionsManager {...props} />)
146
- await screen.findByText('Pagar')
300
+ await screen.findAllByText('Usuarios')
147
301
  const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
148
302
  expect(save.disabled).toBe(true)
149
303
  })
150
304
 
305
+ it('la búsqueda filtra las filas de la lista plana', async () => {
306
+ const props = makeProps()
307
+ render(<PermissionsManager {...props} />)
308
+ await screen.findAllByText('Usuarios')
309
+
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()
317
+ })
318
+
151
319
  it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
152
320
  const props = makeProps()
153
321
  render(<PermissionsManager {...props} />)
154
- await screen.findByText('Pagar')
322
+ await screen.findAllByText('Usuarios')
155
323
  expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
156
324
  expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
157
325
  expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
158
326
  })
159
327
 
328
+ it('selector de rol limpio: edit/delete inline, sin chip removible', async () => {
329
+ const props = makeProps(grouped, {
330
+ updateRole: vi.fn(async () => {}),
331
+ deleteRole: vi.fn(async () => {}),
332
+ })
333
+ render(<PermissionsManager {...props} />)
334
+ await screen.findAllByText('Usuarios')
335
+ expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
336
+ expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
337
+ expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
338
+ })
339
+
160
340
  it('muestra los CRUD de rol cuando los mutators existen', async () => {
161
- const props = makeProps({
341
+ const props = makeProps(grouped, {
162
342
  createRole: vi.fn(async () => {}),
163
343
  updateRole: vi.fn(async () => {}),
164
344
  deleteRole: vi.fn(async () => {}),
165
345
  })
166
346
  render(<PermissionsManager {...props} />)
167
- await screen.findByText('Pagar')
347
+ await screen.findAllByText('Usuarios')
168
348
  expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
169
349
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
170
350
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
171
351
  })
172
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,