@asteby/metacore-runtime-react 39.2.7 → 39.2.9
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 +15 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +50 -2
- package/dist/calendar-date.d.ts +4 -0
- package/dist/calendar-date.d.ts.map +1 -0
- package/dist/calendar-date.js +18 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +6 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-date-field.d.ts.map +1 -1
- package/dist/dynamic-date-field.js +2 -12
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +8 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/list-include.d.ts +9 -0
- package/dist/list-include.d.ts.map +1 -0
- package/dist/list-include.js +36 -0
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +40 -14
- package/dist/query-batch.d.ts +48 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +251 -8
- package/dist/use-options-resolver.d.ts +6 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +16 -2
- package/dist/validation-catalog.d.ts.map +1 -1
- package/dist/validation-catalog.js +2 -0
- package/package.json +3 -3
- package/src/__tests__/calendar-date.test.ts +22 -0
- package/src/__tests__/options-cache-invalidation.test.tsx +126 -0
- package/src/__tests__/permissions-manager.test.tsx +73 -0
- package/src/api-context.tsx +64 -2
- package/src/calendar-date.ts +16 -0
- package/src/dialogs/dynamic-record.tsx +6 -1
- package/src/dynamic-columns.tsx +3 -1
- package/src/dynamic-date-field.tsx +2 -10
- package/src/dynamic-table.tsx +10 -2
- package/src/index.ts +10 -0
- package/src/list-include.test.ts +33 -0
- package/src/list-include.ts +34 -0
- package/src/permissions-manager.tsx +58 -19
- package/src/query-batch.test.ts +122 -2
- package/src/query-batch.ts +274 -7
- package/src/use-options-resolver.ts +17 -2
- package/src/validation-catalog.ts +2 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +29 -0
|
@@ -400,6 +400,79 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
|
|
|
400
400
|
})
|
|
401
401
|
})
|
|
402
402
|
|
|
403
|
+
describe('PermissionsManager (entradas que comparten modelo)', () => {
|
|
404
|
+
// Two sidebar entries over the same model (QA PIT-001: "Ventas POS" and
|
|
405
|
+
// "Por cobrar" both over sales_orders): picking the second must show the
|
|
406
|
+
// second, not the first entry with that key.
|
|
407
|
+
const shared: GroupedPermissionsCatalog = {
|
|
408
|
+
groups: [
|
|
409
|
+
{
|
|
410
|
+
title: 'Caja',
|
|
411
|
+
modules: [
|
|
412
|
+
{
|
|
413
|
+
key: 'sales_orders',
|
|
414
|
+
label: 'Por cobrar',
|
|
415
|
+
kind: 'model',
|
|
416
|
+
actions: [{ key: 'index', label: 'Listar', kind: 'crud' }],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
title: 'Ventas',
|
|
422
|
+
modules: [
|
|
423
|
+
{
|
|
424
|
+
key: 'sales_orders',
|
|
425
|
+
label: 'Ventas POS',
|
|
426
|
+
kind: 'model',
|
|
427
|
+
actions: [{ key: 'index', label: 'Listar', kind: 'crud' }],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
}
|
|
433
|
+
const moduleTrigger = () => {
|
|
434
|
+
const triggers = screen.getAllByRole('combobox')
|
|
435
|
+
return triggers[triggers.length - 1]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
it('seleccionar la segunda entrada muestra la segunda', async () => {
|
|
439
|
+
render(<PermissionsManager {...makeProps(shared)} />)
|
|
440
|
+
await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Por cobrar/))
|
|
441
|
+
fireEvent.click(moduleTrigger())
|
|
442
|
+
fireEvent.click(await screen.findByRole('option', { name: /Ventas POS/ }))
|
|
443
|
+
await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Ventas POS/))
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
describe('PermissionsManager (error al crear rol)', () => {
|
|
448
|
+
it('muestra dentro del diálogo el motivo del servidor y lo deja abierto', async () => {
|
|
449
|
+
const dup = Object.assign(new Error('Request failed with status code 422'), {
|
|
450
|
+
response: {
|
|
451
|
+
data: {
|
|
452
|
+
success: false,
|
|
453
|
+
code: 'duplicate',
|
|
454
|
+
message: 'Ya existe un registro con esos datos',
|
|
455
|
+
error: { code: 'duplicate', message: 'Ya existe un registro con esos datos' },
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
const props = makeProps(grouped, {
|
|
460
|
+
createRole: vi.fn(async () => {
|
|
461
|
+
throw dup
|
|
462
|
+
}),
|
|
463
|
+
})
|
|
464
|
+
render(<PermissionsManager {...props} />)
|
|
465
|
+
fireEvent.click(await screen.findByRole('button', { name: /Nuevo rol/ }))
|
|
466
|
+
fireEvent.change(await screen.findByLabelText('Nombre del rol'), {
|
|
467
|
+
target: { value: 'Admin' },
|
|
468
|
+
})
|
|
469
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear rol' }))
|
|
470
|
+
const alert = await screen.findByRole('alert')
|
|
471
|
+
expect(alert.textContent).toBe('Ya existe un registro con esos datos')
|
|
472
|
+
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
403
476
|
describe('PermissionsManager (retrocompat shape viejo {modules})', () => {
|
|
404
477
|
it('renderiza el shape flat legacy sin romper, agrupado por addon', async () => {
|
|
405
478
|
const props = makeProps(legacy)
|
package/src/api-context.tsx
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// runtime-react components (DynamicTable, dialogs, action dispatcher) can
|
|
3
3
|
// talk to the backend without a bundler alias to `@/lib/api`. Hosts wrap
|
|
4
4
|
// their app in <ApiProvider value={axiosInstance}> once at the root.
|
|
5
|
-
import React, { createContext, useContext } from 'react'
|
|
5
|
+
import React, { createContext, useContext, useEffect, useMemo } from 'react'
|
|
6
|
+
import { batchGet, invalidateQueryBatchData } from './query-batch'
|
|
7
|
+
import { invalidateOptionsCache } from './use-options-resolver'
|
|
6
8
|
|
|
7
9
|
/** Minimal axios-compatible client shape consumed by runtime-react. */
|
|
8
10
|
export interface ApiClient {
|
|
@@ -20,16 +22,76 @@ export interface ApiProviderProps {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
25
|
+
// Hosts also write through their own client (native dialogs, settings
|
|
26
|
+
// screens) without going through useApi(). When the client is axios,
|
|
27
|
+
// every non-GET response drops what the runtime remembered as well.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const interceptors = (client as AxiosLike).interceptors?.response
|
|
30
|
+
if (!interceptors?.use) return
|
|
31
|
+
const onSettled = (config: { method?: string; url?: string } | undefined) => {
|
|
32
|
+
if (isWrite(config?.method, config?.url)) forgetRemembered()
|
|
33
|
+
}
|
|
34
|
+
const id = interceptors.use(
|
|
35
|
+
(res: any) => {
|
|
36
|
+
onSettled(res?.config)
|
|
37
|
+
return res
|
|
38
|
+
},
|
|
39
|
+
(err: any) => {
|
|
40
|
+
onSettled(err?.config)
|
|
41
|
+
return Promise.reject(err)
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
return () => interceptors.eject?.(id)
|
|
45
|
+
}, [client])
|
|
23
46
|
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
|
24
47
|
}
|
|
25
48
|
|
|
49
|
+
interface AxiosLike {
|
|
50
|
+
interceptors?: {
|
|
51
|
+
response?: {
|
|
52
|
+
use?: (onFulfilled: (res: any) => any, onRejected: (err: any) => any) => number
|
|
53
|
+
eject?: (id: number) => void
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The read batch is a POST to /q; it reads, so it must not count as a write.
|
|
59
|
+
const BATCH_READ_URL = /(^|\/)q\/?(\?|$)/
|
|
60
|
+
|
|
61
|
+
function isWrite(method: string | undefined, url: string | undefined): boolean {
|
|
62
|
+
const m = (method ?? '').toLowerCase()
|
|
63
|
+
if (m === '' || m === 'get' || m === 'head' || m === 'options') return false
|
|
64
|
+
return !BATCH_READ_URL.test(url ?? '')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drops the batch rows and the picker options the runtime remembered. */
|
|
68
|
+
function forgetRemembered(): void {
|
|
69
|
+
invalidateQueryBatchData()
|
|
70
|
+
invalidateOptionsCache()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mutating<T>(request: Promise<T>): Promise<T> {
|
|
74
|
+
forgetRemembered()
|
|
75
|
+
return request.finally(forgetRemembered)
|
|
76
|
+
}
|
|
77
|
+
|
|
26
78
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
27
79
|
export function useApi(): ApiClient {
|
|
28
80
|
const ctx = useContext(ApiContext)
|
|
29
81
|
if (!ctx) {
|
|
30
82
|
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
|
|
31
83
|
}
|
|
32
|
-
|
|
84
|
+
// List, metadata and options GETs share one POST /api/q. Mutations stay
|
|
85
|
+
// on the host client and, settled either way, drop the rows the batch
|
|
86
|
+
// remembered, so the refresh after a save reads the saved row. The
|
|
87
|
+
// wrapper is stable while the host client is.
|
|
88
|
+
return useMemo<ApiClient>(() => ({
|
|
89
|
+
get: (url, config) => batchGet(ctx, url, config),
|
|
90
|
+
post: (url, body, config) =>
|
|
91
|
+
isWrite('post', url) ? mutating(ctx.post(url, body, config)) : ctx.post(url, body, config),
|
|
92
|
+
put: (url, body, config) => mutating(ctx.put(url, body, config)),
|
|
93
|
+
delete: (url, config) => mutating(ctx.delete(url, config)),
|
|
94
|
+
}), [ctx])
|
|
33
95
|
}
|
|
34
96
|
|
|
35
97
|
/** Optional branch context — hosts that support tenant branches can supply
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A `date` column is a calendar day, not an instant. The backend often serves
|
|
2
|
+
// it as UTC midnight ("2026-09-27T00:00:00Z"), which a plain Date parse turns
|
|
3
|
+
// into the previous evening west of UTC (QA Pitsline PIT-025: picked the 27th,
|
|
4
|
+
// the form showed the 26th). Read the YYYY-MM-DD prefix as a LOCAL day instead.
|
|
5
|
+
|
|
6
|
+
/** Parse a calendar day ("YYYY-MM-DD…" or any Date-parseable value) into a
|
|
7
|
+
* local Date at noon, or undefined when empty/invalid. */
|
|
8
|
+
export function parseCalendarDate(v: unknown): Date | undefined {
|
|
9
|
+
if (v === null || v === undefined || v === '') return undefined
|
|
10
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? undefined : v
|
|
11
|
+
const s = String(v)
|
|
12
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
|
13
|
+
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
|
|
14
|
+
const d = new Date(s)
|
|
15
|
+
return isNaN(d.getTime()) ? undefined : d
|
|
16
|
+
}
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
50
50
|
import { toast } from 'sonner'
|
|
51
51
|
import { format, parseISO } from 'date-fns'
|
|
52
|
+
import { parseCalendarDate } from '../calendar-date'
|
|
52
53
|
import { es } from 'date-fns/locale'
|
|
53
54
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
|
|
54
55
|
import { BarcodeScanner } from '../barcode-scanner'
|
|
@@ -2125,7 +2126,11 @@ export function EditField({ field, value, onChange, record, invalid }: {
|
|
|
2125
2126
|
}
|
|
2126
2127
|
|
|
2127
2128
|
if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp' || field.type === 'timestamptz') {
|
|
2128
|
-
|
|
2129
|
+
// A pure `date` is a calendar day: read its YYYY-MM-DD as a local day so
|
|
2130
|
+
// UTC midnight does not render as the day before (PIT-025).
|
|
2131
|
+
const dateValue = field.type === 'date'
|
|
2132
|
+
? parseCalendarDate(value)
|
|
2133
|
+
: value ? (typeof value === 'string' ? parseISO(value) : new Date(value)) : undefined
|
|
2129
2134
|
// Treat the Go zero-time (0001-01-01) as empty so an unset date shows the
|
|
2130
2135
|
// placeholder instead of "31 de diciembre de 1".
|
|
2131
2136
|
const validDate =
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import * as React from 'react'
|
|
16
16
|
import { ColumnDef } from '@tanstack/react-table'
|
|
17
17
|
import { format, type Locale } from 'date-fns'
|
|
18
|
+
import { parseCalendarDate } from './calendar-date'
|
|
18
19
|
import { es, enUS } from 'date-fns/locale'
|
|
19
20
|
import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
|
|
20
21
|
import {
|
|
@@ -507,7 +508,8 @@ export function formatDateCell(
|
|
|
507
508
|
title: format(date, 'PPpp', { locale }),
|
|
508
509
|
}
|
|
509
510
|
}
|
|
510
|
-
|
|
511
|
+
// Calendar day without an org zone: read YYYY-MM-DD as a local day.
|
|
512
|
+
return { display: format(parseCalendarDate(value) ?? date, 'PPP', { locale }) }
|
|
511
513
|
}
|
|
512
514
|
|
|
513
515
|
/**
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '@asteby/metacore-ui/primitives'
|
|
22
22
|
import { CalendarIcon } from 'lucide-react'
|
|
23
23
|
import type { ActionFieldDef } from './types'
|
|
24
|
+
import { parseCalendarDate } from './calendar-date'
|
|
24
25
|
|
|
25
26
|
export interface DynamicDateFieldProps {
|
|
26
27
|
field: ActionFieldDef
|
|
@@ -28,16 +29,7 @@ export interface DynamicDateFieldProps {
|
|
|
28
29
|
onChange: (v: any) => void
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
// undefined when empty/invalid. Uses noon to dodge timezone day-shift.
|
|
33
|
-
function toDate(v: any): Date | undefined {
|
|
34
|
-
if (!v) return undefined
|
|
35
|
-
const s = String(v)
|
|
36
|
-
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
|
37
|
-
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
|
|
38
|
-
const d = new Date(s)
|
|
39
|
-
return isNaN(d.getTime()) ? undefined : d
|
|
40
|
-
}
|
|
32
|
+
const toDate = parseCalendarDate
|
|
41
33
|
|
|
42
34
|
// Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
|
|
43
35
|
function toISO(d: Date): string {
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
import { OptionsContext } from './options-context'
|
|
79
79
|
import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
|
|
80
80
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
81
|
+
import { visibleRelationInclude } from './list-include'
|
|
81
82
|
import { useDebouncedValue } from './use-debounced-value'
|
|
82
83
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
|
|
83
84
|
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
@@ -808,6 +809,11 @@ export function DynamicTable({
|
|
|
808
809
|
return Array.from(set)
|
|
809
810
|
}, [hiddenColumns, scopeHiddenColumns])
|
|
810
811
|
|
|
812
|
+
const relationInclude = useMemo(
|
|
813
|
+
() => visibleRelationInclude(metadata?.columns, effectiveHiddenColumns),
|
|
814
|
+
[metadata, effectiveHiddenColumns],
|
|
815
|
+
)
|
|
816
|
+
|
|
811
817
|
const buildFilterParams = useCallback(() => {
|
|
812
818
|
const params: Record<string, any> = {}
|
|
813
819
|
if (sorting.length > 0) {
|
|
@@ -868,6 +874,7 @@ export function DynamicTable({
|
|
|
868
874
|
per_page: pagination.pageSize,
|
|
869
875
|
...buildFilterParams(),
|
|
870
876
|
}
|
|
877
|
+
if (relationInclude) params.include = relationInclude
|
|
871
878
|
const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
|
|
872
879
|
setForbidden(false)
|
|
873
880
|
if (res.data.success) {
|
|
@@ -895,7 +902,7 @@ export function DynamicTable({
|
|
|
895
902
|
} finally {
|
|
896
903
|
setLoadingData(false)
|
|
897
904
|
}
|
|
898
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
|
|
905
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
|
|
899
906
|
|
|
900
907
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
901
908
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -936,6 +943,7 @@ export function DynamicTable({
|
|
|
936
943
|
per_page: infPageSize,
|
|
937
944
|
...buildFilterParams(),
|
|
938
945
|
}
|
|
946
|
+
if (relationInclude) params.include = relationInclude
|
|
939
947
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
940
948
|
params,
|
|
941
949
|
})) as { data: ApiResponse<any[]> }
|
|
@@ -974,7 +982,7 @@ export function DynamicTable({
|
|
|
974
982
|
else setLoadingData(false)
|
|
975
983
|
}
|
|
976
984
|
},
|
|
977
|
-
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
|
|
985
|
+
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync, relationInclude],
|
|
978
986
|
)
|
|
979
987
|
|
|
980
988
|
// Signature of everything that must reset the incremental list to page 1:
|
package/src/index.ts
CHANGED
|
@@ -393,14 +393,24 @@ export {
|
|
|
393
393
|
getSearchableColumnKeys,
|
|
394
394
|
} from './column-visibility'
|
|
395
395
|
export {
|
|
396
|
+
batchGet,
|
|
396
397
|
loadQueryPart,
|
|
397
398
|
nameBatchTokens,
|
|
399
|
+
narrowInToken,
|
|
398
400
|
optionsBatchToken,
|
|
399
401
|
optionsModelFromUrl,
|
|
402
|
+
rememberInRows,
|
|
403
|
+
invalidateQueryBatchData,
|
|
404
|
+
resetQueryBatchCache,
|
|
405
|
+
splitInToken,
|
|
406
|
+
tokenForGet,
|
|
407
|
+
type InListToken,
|
|
408
|
+
type NarrowedIn,
|
|
400
409
|
type QueryPart,
|
|
401
410
|
} from './query-batch'
|
|
402
411
|
export {
|
|
403
412
|
useOptionsResolver,
|
|
413
|
+
invalidateOptionsCache,
|
|
404
414
|
projectOption,
|
|
405
415
|
type ResolvedOption,
|
|
406
416
|
type OptionsMeta,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { visibleRelationInclude } from './list-include'
|
|
3
|
+
import type { ColumnDefinition } from './types'
|
|
4
|
+
|
|
5
|
+
function col(partial: Partial<ColumnDefinition> & { key: string }): ColumnDefinition {
|
|
6
|
+
return { label: partial.key, type: 'text', sortable: false, filterable: false, ...partial }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('visibleRelationInclude', () => {
|
|
10
|
+
it('omits the parameter when the table has no relations', () => {
|
|
11
|
+
expect(visibleRelationInclude([col({ key: 'name' })])).toBeUndefined()
|
|
12
|
+
expect(visibleRelationInclude(undefined)).toBeUndefined()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('lists only the visible relation columns', () => {
|
|
16
|
+
const include = visibleRelationInclude([
|
|
17
|
+
col({ key: 'name' }),
|
|
18
|
+
col({ key: 'customer_id', type: 'relation' }),
|
|
19
|
+
col({ key: 'warehouse_id', ref: 'inventory.Warehouse', hidden: true }),
|
|
20
|
+
col({ key: 'created_by', type: 'creator' }),
|
|
21
|
+
col({ key: 'items', item_fields: [{ key: 'product_id', label: 'Producto' }] }),
|
|
22
|
+
])
|
|
23
|
+
expect(include).toBe('customer_id,created_by,items')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('drops a column the view hid', () => {
|
|
27
|
+
const include = visibleRelationInclude(
|
|
28
|
+
[col({ key: 'customer_id', type: 'relation' }), col({ key: 'user_id', type: 'user' })],
|
|
29
|
+
['user_id'],
|
|
30
|
+
)
|
|
31
|
+
expect(include).toBe('customer_id')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ColumnDefinition } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Columns whose labels come from a relation lookup. The list asks the server
|
|
5
|
+
* to resolve only these, so hidden foreign keys do not each become a query.
|
|
6
|
+
* Returns undefined when nothing relational is visible: the server then keeps
|
|
7
|
+
* resolving everything, which is what hosts do before they send `include=`.
|
|
8
|
+
*/
|
|
9
|
+
export function visibleRelationInclude(
|
|
10
|
+
columns: ColumnDefinition[] | undefined,
|
|
11
|
+
hidden: string[] = [],
|
|
12
|
+
): string | undefined {
|
|
13
|
+
if (!columns?.length) return undefined
|
|
14
|
+
const hide = new Set(hidden)
|
|
15
|
+
const keys: string[] = []
|
|
16
|
+
for (const col of columns) {
|
|
17
|
+
if (!col?.key || col.hidden || hide.has(col.key)) continue
|
|
18
|
+
if (col.visibility === 'modal' || col.visibility === 'list') continue
|
|
19
|
+
if (!columnNeedsRelation(col)) continue
|
|
20
|
+
keys.push(col.key)
|
|
21
|
+
}
|
|
22
|
+
if (keys.length === 0) return undefined
|
|
23
|
+
return keys.join(',')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function columnNeedsRelation(col: ColumnDefinition): boolean {
|
|
27
|
+
if (col.type === 'relation' || col.type === 'relation-badge-list' || col.type === 'creator' || col.type === 'user') {
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
if (col.cellStyle === 'relation' || col.cellStyle === 'reference') return true
|
|
31
|
+
if (col.ref) return true
|
|
32
|
+
if (col.itemFields?.length || col.item_fields?.length) return true
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
Trash2,
|
|
39
39
|
} from 'lucide-react'
|
|
40
40
|
import { toast } from 'sonner'
|
|
41
|
+
import { extractServerError } from './server-error'
|
|
41
42
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
42
43
|
import {
|
|
43
44
|
AlertDialog,
|
|
@@ -397,6 +398,29 @@ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup
|
|
|
397
398
|
}
|
|
398
399
|
|
|
399
400
|
/** Flat list of every module across groups, in render order. */
|
|
401
|
+
/** Identity of one picker entry: its group index and its index in the group. */
|
|
402
|
+
export function moduleEntryId(groupIndex: number, moduleIndex: number): string {
|
|
403
|
+
return `${groupIndex}:${moduleIndex}`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The first entry of the catalog, or null when it has no modules. */
|
|
407
|
+
export function firstModuleEntryId(groups: ModuleGroup[]): string | null {
|
|
408
|
+
const gi = groups.findIndex((g) => g.modules.length > 0)
|
|
409
|
+
return gi < 0 ? null : moduleEntryId(gi, 0)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Resolve an entry id back to its module and group. */
|
|
413
|
+
export function moduleAtEntry(
|
|
414
|
+
groups: ModuleGroup[] | null | undefined,
|
|
415
|
+
id: string | null,
|
|
416
|
+
): { module: PermissionModuleDef; group: ModuleGroup } | null {
|
|
417
|
+
if (!groups || !id) return null
|
|
418
|
+
const [gi, mi] = id.split(':').map(Number)
|
|
419
|
+
const group = groups[gi!]
|
|
420
|
+
const module = group?.modules[mi!]
|
|
421
|
+
return group && module ? { module, group } : null
|
|
422
|
+
}
|
|
423
|
+
|
|
400
424
|
export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
|
|
401
425
|
return groups.flatMap((g) => g.modules)
|
|
402
426
|
}
|
|
@@ -546,6 +570,10 @@ export function PermissionsManager({
|
|
|
546
570
|
const [loadError, setLoadError] = React.useState(false)
|
|
547
571
|
|
|
548
572
|
const [activeRoleId, setActiveRoleId] = React.useState<string | null>(null)
|
|
573
|
+
// The picker selection is the ENTRY (group index : module index), not the
|
|
574
|
+
// module key: two sidebar entries may govern the same model (e.g. "Ventas
|
|
575
|
+
// POS" and "Por cobrar" both over sales_orders) and a key-based lookup
|
|
576
|
+
// always landed on the first of them.
|
|
549
577
|
const [activeModuleKey, setActiveModuleKey] = React.useState<string | null>(null)
|
|
550
578
|
|
|
551
579
|
// baseline = capabilities as persisted; draft = baseline + local edits.
|
|
@@ -567,6 +595,8 @@ export function PermissionsManager({
|
|
|
567
595
|
color: string
|
|
568
596
|
icon: string
|
|
569
597
|
grantAll: boolean
|
|
598
|
+
/** Server rejection shown inside the dialog (e.g. duplicate name). */
|
|
599
|
+
error?: string
|
|
570
600
|
}>({
|
|
571
601
|
open: false,
|
|
572
602
|
mode: 'create',
|
|
@@ -594,9 +624,7 @@ export function PermissionsManager({
|
|
|
594
624
|
setGeneral(cat.general ?? [])
|
|
595
625
|
setRoles(rs)
|
|
596
626
|
setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
|
|
597
|
-
setActiveModuleKey(
|
|
598
|
-
(prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
|
|
599
|
-
)
|
|
627
|
+
setActiveModuleKey((prev) => prev ?? firstModuleEntryId(grouped))
|
|
600
628
|
})
|
|
601
629
|
.catch(() => {
|
|
602
630
|
if (!cancelled) setLoadError(true)
|
|
@@ -642,8 +670,8 @@ export function PermissionsManager({
|
|
|
642
670
|
[roles, activeRoleId],
|
|
643
671
|
)
|
|
644
672
|
const activeModule = React.useMemo(
|
|
645
|
-
() =>
|
|
646
|
-
[
|
|
673
|
+
() => moduleAtEntry(groups, activeModuleKey)?.module ?? null,
|
|
674
|
+
[groups, activeModuleKey],
|
|
647
675
|
)
|
|
648
676
|
|
|
649
677
|
const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
|
|
@@ -730,6 +758,7 @@ export function PermissionsManager({
|
|
|
730
758
|
const label = roleDialog.label.trim()
|
|
731
759
|
if (!label) return
|
|
732
760
|
setRoleSaving(true)
|
|
761
|
+
setRoleDialog((d) => ({ ...d, error: undefined }))
|
|
733
762
|
try {
|
|
734
763
|
if (roleDialog.mode === 'create' && createRole) {
|
|
735
764
|
const created = await createRole({
|
|
@@ -764,10 +793,15 @@ export function PermissionsManager({
|
|
|
764
793
|
toast.success('Rol actualizado')
|
|
765
794
|
}
|
|
766
795
|
setRoleDialog((d) => ({ ...d, open: false }))
|
|
767
|
-
} catch {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
796
|
+
} catch (err) {
|
|
797
|
+
// Keep the dialog open WITH the server's reason (422 duplicate name,
|
|
798
|
+
// 403 …): a toast alone can sit behind the modal overlay.
|
|
799
|
+
const fallback =
|
|
800
|
+
roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol'
|
|
801
|
+
const { title, description } = extractServerError(err, fallback)
|
|
802
|
+
const error = description ? `${title}: ${description}` : title
|
|
803
|
+
setRoleDialog((d) => ({ ...d, error }))
|
|
804
|
+
toast.error(fallback, { description: error })
|
|
771
805
|
} finally {
|
|
772
806
|
setRoleSaving(false)
|
|
773
807
|
}
|
|
@@ -806,11 +840,10 @@ export function PermissionsManager({
|
|
|
806
840
|
const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
|
|
807
841
|
const moduleTotal = activeModule?.actions.length ?? 0
|
|
808
842
|
const checksDisabled = !activeRole || !draft || loadingPerms || saving
|
|
809
|
-
const activeModuleGroupTitle = React.useMemo(
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}, [activeModule, groups])
|
|
843
|
+
const activeModuleGroupTitle = React.useMemo(
|
|
844
|
+
() => moduleAtEntry(groups, activeModuleKey)?.group.title ?? '',
|
|
845
|
+
[groups, activeModuleKey],
|
|
846
|
+
)
|
|
814
847
|
|
|
815
848
|
// ---- render --------------------------------------------------------------
|
|
816
849
|
if (loadError) {
|
|
@@ -1094,12 +1127,12 @@ export function PermissionsManager({
|
|
|
1094
1127
|
key={group.title || `__untitled_${gi}`}
|
|
1095
1128
|
heading={group.title || undefined}
|
|
1096
1129
|
>
|
|
1097
|
-
{group.modules.map((mod) => (
|
|
1130
|
+
{group.modules.map((mod, mi) => (
|
|
1098
1131
|
<CommandItem
|
|
1099
|
-
key={
|
|
1100
|
-
value={`${group.title} ${mod.label} ${mod.key}`}
|
|
1132
|
+
key={moduleEntryId(gi, mi)}
|
|
1133
|
+
value={`${group.title} ${mod.label} ${mod.key} ${moduleEntryId(gi, mi)}`}
|
|
1101
1134
|
onSelect={() => {
|
|
1102
|
-
setActiveModuleKey(
|
|
1135
|
+
setActiveModuleKey(moduleEntryId(gi, mi))
|
|
1103
1136
|
setModuleOpen(false)
|
|
1104
1137
|
}}
|
|
1105
1138
|
>
|
|
@@ -1129,7 +1162,7 @@ export function PermissionsManager({
|
|
|
1129
1162
|
/{mod.actions.length}
|
|
1130
1163
|
</Badge>
|
|
1131
1164
|
)}
|
|
1132
|
-
{
|
|
1165
|
+
{moduleEntryId(gi, mi) === activeModuleKey && (
|
|
1133
1166
|
<Check className="ml-2 h-4 w-4 shrink-0" />
|
|
1134
1167
|
)}
|
|
1135
1168
|
</CommandItem>
|
|
@@ -1270,6 +1303,7 @@ export function PermissionsManager({
|
|
|
1270
1303
|
setRoleDialog((d) => ({
|
|
1271
1304
|
...d,
|
|
1272
1305
|
label,
|
|
1306
|
+
error: undefined,
|
|
1273
1307
|
icon:
|
|
1274
1308
|
d.icon === suggestRoleIcon(d.label) ||
|
|
1275
1309
|
d.icon === 'Shield' ||
|
|
@@ -1330,6 +1364,11 @@ export function PermissionsManager({
|
|
|
1330
1364
|
</span>
|
|
1331
1365
|
</label>
|
|
1332
1366
|
)}
|
|
1367
|
+
{roleDialog.error && (
|
|
1368
|
+
<p role="alert" className="text-sm text-destructive">
|
|
1369
|
+
{roleDialog.error}
|
|
1370
|
+
</p>
|
|
1371
|
+
)}
|
|
1333
1372
|
</div>
|
|
1334
1373
|
<DialogFooter>
|
|
1335
1374
|
<Button
|