@asteby/metacore-runtime-react 18.15.0 → 18.16.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.
@@ -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,112 @@ 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 (módulo como combobox agrupado)', () => {
188
+ // The module picker is a grouped combobox (same pattern as the role
189
+ // selector): open it, then click the module's option.
190
+ const moduleTrigger = () => {
191
+ // Two role="combobox" triggers: [0] role selector, [1] module selector.
192
+ const triggers = screen.getAllByRole('combobox')
193
+ return triggers[triggers.length - 1]
194
+ }
195
+ const selectModule = async (name: RegExp) => {
196
+ fireEvent.click(moduleTrigger())
197
+ fireEvent.click(await screen.findByRole('option', { name }))
198
+ }
199
+
200
+ it('renderiza catálogo, auto-selecciona rol y primer módulo, contador N/M', async () => {
114
201
  const props = makeProps()
115
202
  render(<PermissionsManager {...props} />)
116
203
 
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)
204
+ // Primer módulo = "Usuarios" (auto-seleccionado) su nombre en el trigger.
205
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
123
206
  expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
124
207
 
208
+ // Al abrir el combobox: grupos (CommandGroup heading) + opciones.
209
+ fireEvent.click(moduleTrigger())
210
+ expect(await screen.findByText('Punto de venta')).toBeTruthy()
211
+ expect(screen.getByRole('option', { name: /Pedidos POS/ })).toBeTruthy()
212
+ expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy()
213
+
125
214
  // Generales presentes con descripción.
126
215
  expect(screen.getByText('Permisos Generales')).toBeTruthy()
127
216
  expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
128
217
  })
129
218
 
130
- it('marcar una acción + un general y guardar llama sync con el set completo correcto', async () => {
219
+ it('CERO acordeones: el módulo es un combobox, no un folder colapsable', async () => {
131
220
  const props = makeProps()
132
221
  render(<PermissionsManager {...props} />)
133
- await screen.findByText('Pagar')
222
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
223
+ // El header de grupo "Punto de venta" vive DENTRO del popover (no en la
224
+ // columna), así que no existe hasta abrir el combobox — nada de folders.
225
+ expect(screen.queryByText('Punto de venta')).toBeNull()
226
+ expect(screen.queryByRole('button', { name: 'Punto de venta' })).toBeNull()
227
+ // El trigger del módulo es un combobox accesible.
228
+ expect(moduleTrigger().getAttribute('role')).toBe('combobox')
229
+ })
134
230
 
135
- fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
136
- fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
231
+ it('seleccionar un módulo en el combobox muestra su grid', async () => {
232
+ const props = makeProps()
233
+ render(<PermissionsManager {...props} />)
234
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
137
235
 
138
- // Dirty visible y guardar habilitado.
139
- expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
140
- fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
236
+ await selectModule(/Pedidos POS/)
237
+ expect(await screen.findByText('Pagar')).toBeTruthy()
141
238
 
239
+ await selectModule(/Terminal/)
240
+ expect(await screen.findByText('Acceder')).toBeTruthy()
241
+ await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
242
+ })
243
+
244
+ it('marcar el screen "Acceder" produce capability screen.<navKey>.access', async () => {
245
+ const props = makeProps()
246
+ render(<PermissionsManager {...props} />)
247
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
248
+
249
+ await selectModule(/Terminal/)
250
+ await screen.findByText('Acceder')
251
+ fireEvent.click(screen.getByRole('checkbox', { name: /Acceder/ }))
252
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
142
253
  await waitFor(() =>
143
254
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
144
- 'general.work_after_hours',
145
255
  'pos_orders.index',
146
- 'pos_orders.pagar',
256
+ 'screen.pos_terminal.access',
147
257
  ]),
148
258
  )
149
- // Tras guardar, baseline = draft → dirty desaparece.
150
- await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
151
259
  })
152
260
 
153
- it('desmarcar una otorgada también entra al delta', async () => {
261
+ it('marcar una acción + un general y guardar llama sync con el set completo', async () => {
154
262
  const props = makeProps()
155
263
  render(<PermissionsManager {...props} />)
264
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
265
+
266
+ await selectModule(/Pedidos POS/)
156
267
  await screen.findByText('Pagar')
268
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
269
+ fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
157
270
 
158
- fireEvent.click(screen.getByRole('checkbox', { name: /Listar/ }))
271
+ expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
159
272
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
160
- await waitFor(() => expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', []))
273
+
274
+ await waitFor(() =>
275
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
276
+ 'general.work_after_hours',
277
+ 'pos_orders.index',
278
+ 'pos_orders.pagar',
279
+ ]),
280
+ )
281
+ await waitFor(() => expect(screen.queryByText('Cambios sin guardar')).toBeNull())
161
282
  })
162
283
 
163
284
  it('marcar todo / limpiar operan sobre el módulo activo', async () => {
164
285
  const props = makeProps()
165
286
  render(<PermissionsManager {...props} />)
287
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
288
+ await selectModule(/Pedidos POS/)
166
289
  await screen.findByText('Pagar')
167
290
 
168
291
  fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
169
- // 3/3 aparece en el panel y en el badge del árbol.
170
292
  expect(screen.getAllByText('3/3').length).toBeGreaterThan(0)
171
-
172
293
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
173
294
  await waitFor(() =>
174
295
  expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
@@ -185,74 +306,90 @@ describe('PermissionsManager', () => {
185
306
  it('guardar deshabilitado sin cambios', async () => {
186
307
  const props = makeProps()
187
308
  render(<PermissionsManager {...props} />)
188
- await screen.findByText('Pagar')
309
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
189
310
  const save = screen.getByRole('button', { name: /Guardar permisos/ }) as HTMLButtonElement
190
311
  expect(save.disabled).toBe(true)
191
312
  })
192
313
 
193
- it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
314
+ it('el combobox de módulo filtra por búsqueda', async () => {
194
315
  const props = makeProps()
195
316
  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
- })
317
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
201
318
 
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')
206
-
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)
319
+ fireEvent.click(moduleTrigger())
320
+ fireEvent.change(await screen.findByPlaceholderText('Buscar módulo…'), {
321
+ target: { value: 'terminal' },
322
+ })
323
+ await waitFor(() =>
324
+ expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy(),
325
+ )
326
+ expect(screen.queryByRole('option', { name: /Pedidos POS/ })).toBeNull()
327
+ expect(screen.queryByRole('option', { name: /Usuarios/ })).toBeNull()
217
328
  })
218
329
 
219
- it('la búsqueda filtra el árbol de módulos', async () => {
330
+ it('oculta Nuevo rol / Editar / Eliminar cuando no hay mutators de rol', async () => {
220
331
  const props = makeProps()
221
332
  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()
333
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
334
+ expect(screen.queryByRole('button', { name: /Nuevo rol/ })).toBeNull()
335
+ expect(screen.queryByRole('button', { name: 'Editar rol' })).toBeNull()
336
+ expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
230
337
  })
231
338
 
232
339
  it('selector de rol limpio: edit/delete inline, sin chip removible', async () => {
233
- const props = makeProps({
340
+ const props = makeProps(grouped, {
234
341
  updateRole: vi.fn(async () => {}),
235
342
  deleteRole: vi.fn(async () => {}),
236
343
  })
237
344
  render(<PermissionsManager {...props} />)
238
- await screen.findByText('Pagar')
239
- // No existe el botón de quitar rol del chip antiguo.
345
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
240
346
  expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
241
- // Iconos inline presentes.
242
347
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
243
348
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
244
349
  })
245
350
 
246
351
  it('muestra los CRUD de rol cuando los mutators existen', async () => {
247
- const props = makeProps({
352
+ const props = makeProps(grouped, {
248
353
  createRole: vi.fn(async () => {}),
249
354
  updateRole: vi.fn(async () => {}),
250
355
  deleteRole: vi.fn(async () => {}),
251
356
  })
252
357
  render(<PermissionsManager {...props} />)
253
- await screen.findByText('Pagar')
358
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
254
359
  expect(screen.getByRole('button', { name: /Nuevo rol/ })).toBeTruthy()
255
360
  expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
256
361
  expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
257
362
  })
258
363
  })
364
+
365
+ describe('PermissionsManager (retrocompat shape viejo {modules})', () => {
366
+ it('renderiza el shape flat legacy sin romper, agrupado por addon', async () => {
367
+ const props = makeProps(legacy)
368
+ render(<PermissionsManager {...props} />)
369
+
370
+ // Auto-selección del primer módulo legacy (pos_orders → grupo "Punto de venta").
371
+ expect(await screen.findByText('Pagar')).toBeTruthy()
372
+ // Los grupos derivados del addon viven dentro del combobox de módulo.
373
+ const triggers = screen.getAllByRole('combobox')
374
+ fireEvent.click(triggers[triggers.length - 1])
375
+ expect(await screen.findByText('Punto de venta')).toBeTruthy()
376
+ // El grupo Sistema (users sin addon) también.
377
+ expect(screen.getByText('Sistema')).toBeTruthy()
378
+ expect(screen.getByRole('option', { name: /Usuarios/ })).toBeTruthy()
379
+ })
380
+
381
+ it('legacy: click + guardar produce capabilities correctas', async () => {
382
+ const props = makeProps(legacy)
383
+ render(<PermissionsManager {...props} />)
384
+ await screen.findByText('Pagar')
385
+
386
+ fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
387
+ fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
388
+ await waitFor(() =>
389
+ expect(props.syncRolePermissions).toHaveBeenCalledWith('r1', [
390
+ 'pos_orders.index',
391
+ 'pos_orders.pagar',
392
+ ]),
393
+ )
394
+ })
395
+ })
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,