@asteby/metacore-runtime-react 39.2.8 → 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 +9 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +46 -6
- 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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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 +16 -2
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +66 -20
- 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 +60 -6
- 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/index.ts +2 -0
- package/src/permissions-manager.tsx +58 -19
- package/src/query-batch.test.ts +78 -1
- package/src/query-batch.ts +66 -17
- package/src/use-options-resolver.ts +17 -2
- package/src/validation-catalog.ts +2 -0
|
@@ -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/index.ts
CHANGED
|
@@ -400,6 +400,7 @@ export {
|
|
|
400
400
|
optionsBatchToken,
|
|
401
401
|
optionsModelFromUrl,
|
|
402
402
|
rememberInRows,
|
|
403
|
+
invalidateQueryBatchData,
|
|
403
404
|
resetQueryBatchCache,
|
|
404
405
|
splitInToken,
|
|
405
406
|
tokenForGet,
|
|
@@ -409,6 +410,7 @@ export {
|
|
|
409
410
|
} from './query-batch'
|
|
410
411
|
export {
|
|
411
412
|
useOptionsResolver,
|
|
413
|
+
invalidateOptionsCache,
|
|
412
414
|
projectOption,
|
|
413
415
|
type ResolvedOption,
|
|
414
416
|
type OptionsMeta,
|
|
@@ -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
|
package/src/query-batch.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
invalidateQueryBatchData,
|
|
4
|
+
loadQueryPart,
|
|
3
5
|
nameBatchTokens,
|
|
4
6
|
narrowInToken,
|
|
5
7
|
optionsBatchToken,
|
|
@@ -64,4 +66,79 @@ describe('query batch tokens', () => {
|
|
|
64
66
|
expect(tokenForGet('/data/Stock/me')).toBeNull()
|
|
65
67
|
expect(tokenForGet('/options/Customer?field=name&q=lop')).toBe('o:Customer?field=name&q=lop')
|
|
66
68
|
})
|
|
69
|
+
|
|
70
|
+
it('keeps every row of an id and reads f_ filters by their column', () => {
|
|
71
|
+
resetQueryBatchCache()
|
|
72
|
+
const token = 't:Stock?f_product_id=in:a,b&per_page=200'
|
|
73
|
+
rememberInRows(token, ['a', 'b'], [
|
|
74
|
+
{ product_id: 'a', warehouse_id: 'w1' },
|
|
75
|
+
{ product_id: 'a', warehouse_id: 'w2' },
|
|
76
|
+
{ product_id: 'b', warehouse_id: 'w1' },
|
|
77
|
+
])
|
|
78
|
+
const done = narrowInToken(token)
|
|
79
|
+
expect(done?.wire).toBe('')
|
|
80
|
+
expect(done?.rows).toHaveLength(3)
|
|
81
|
+
resetQueryBatchCache()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not split a paginated list', () => {
|
|
85
|
+
expect(splitInToken('t:Stock?f_product_id=in:a,b&page=1&per_page=25')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('query batch freshness', () => {
|
|
90
|
+
function fakeApi(responses: unknown[]) {
|
|
91
|
+
const post = vi.fn(async (_url: string, body: { parts: string[]; inm: Record<string, string> }) => {
|
|
92
|
+
const next = responses.shift() as (b: typeof body) => unknown
|
|
93
|
+
return { data: next(body) }
|
|
94
|
+
})
|
|
95
|
+
return { post, get: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it('reads a list again instead of serving it from memory', async () => {
|
|
99
|
+
resetQueryBatchCache()
|
|
100
|
+
const api = fakeApi([
|
|
101
|
+
() => ({ success: true, parts: { q1: { success: true, data: [], etag: '"q-1-2"' } } }),
|
|
102
|
+
(body: { inm: Record<string, string> }) => {
|
|
103
|
+
// A length-based etag is not trusted: the stale copy is not offered.
|
|
104
|
+
expect(body.inm).toEqual({})
|
|
105
|
+
return { success: true, parts: { q1: { success: true, data: [{ id: 'x' }], etag: '"q-1-2"' } } }
|
|
106
|
+
},
|
|
107
|
+
])
|
|
108
|
+
const token = 't:QuoteItem?f_quote_id=eq%3Aq1&page=1&per_page=25'
|
|
109
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([])
|
|
110
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([{ id: 'x' }])
|
|
111
|
+
expect(api.post).toHaveBeenCalledTimes(2)
|
|
112
|
+
resetQueryBatchCache()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reuses a list only when the content etag says it did not change', async () => {
|
|
116
|
+
resetQueryBatchCache()
|
|
117
|
+
const api = fakeApi([
|
|
118
|
+
() => ({ success: true, parts: { q1: { success: true, data: [{ id: 'x' }], etag: '"qh-abc"' } } }),
|
|
119
|
+
(body: { inm: Record<string, string> }) => {
|
|
120
|
+
expect(body.inm).toEqual({ q1: '"qh-abc"' })
|
|
121
|
+
return { success: true, parts: { q1: { success: true, not_modified: true, etag: '"qh-abc"' } } }
|
|
122
|
+
},
|
|
123
|
+
])
|
|
124
|
+
const token = 't:QuoteItem?page=1'
|
|
125
|
+
await loadQueryPart(api as never, token)
|
|
126
|
+
expect((await loadQueryPart(api as never, token)).data).toEqual([{ id: 'x' }])
|
|
127
|
+
resetQueryBatchCache()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('forgets remembered rows after a mutation but keeps metadata', async () => {
|
|
131
|
+
resetQueryBatchCache()
|
|
132
|
+
const api = fakeApi([
|
|
133
|
+
() => ({ success: true, parts: { q1: { success: true, data: { columns: [] } } } }),
|
|
134
|
+
])
|
|
135
|
+
await loadQueryPart(api as never, 'm:Quote')
|
|
136
|
+
rememberInRows('t:Stock?product_id=in:a', ['a'], [{ product_id: 'a' }])
|
|
137
|
+
invalidateQueryBatchData()
|
|
138
|
+
expect(narrowInToken('t:Stock?product_id=in:a')).toBeNull()
|
|
139
|
+
// Metadata is still served from memory: no second request.
|
|
140
|
+
await loadQueryPart(api as never, 'm:Quote')
|
|
141
|
+
expect(api.post).toHaveBeenCalledTimes(1)
|
|
142
|
+
resetQueryBatchCache()
|
|
143
|
+
})
|
|
67
144
|
})
|
package/src/query-batch.ts
CHANGED
|
@@ -31,7 +31,26 @@ type Waiter = {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type CachedPart = { part: QueryPart; at: number }
|
|
34
|
-
type CachedEntity = {
|
|
34
|
+
type CachedEntity = { rows: Record<string, unknown>[]; at: number }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Only metadata and i18n are served from memory without asking the server.
|
|
38
|
+
* Rows (`t:` lists, `r:` records) and options change with every save: they
|
|
39
|
+
* always travel, and an unchanged payload comes back as `not_modified` when
|
|
40
|
+
* the server's etag hashes the content.
|
|
41
|
+
*/
|
|
42
|
+
function servedFromMemory(token: string): boolean {
|
|
43
|
+
return token.startsWith('m:') || token.startsWith('mm:') || token.startsWith('i:')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Etags the server derives from the payload itself (`"qh-…"`). The older
|
|
48
|
+
* `"q-<seed>-<len>"` ones only counted rows or bytes, so a value edited to one
|
|
49
|
+
* of the same length came back `not_modified` with the old data.
|
|
50
|
+
*/
|
|
51
|
+
function contentEtag(etag: string | undefined): boolean {
|
|
52
|
+
return !!etag && etag.startsWith('"qh-')
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
const partCache = new Map<string, CachedPart>()
|
|
37
56
|
const entityCache = new Map<string, CachedEntity>()
|
|
@@ -42,13 +61,18 @@ export interface InListToken {
|
|
|
42
61
|
shape: string
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
|
|
66
|
+
* yet. A paginated list (`page=`) is not split: serving part of it from memory
|
|
67
|
+
* would change which rows land on the page and the total.
|
|
68
|
+
*/
|
|
46
69
|
export function splitInToken(token: string): InListToken | null {
|
|
47
70
|
const q = token.indexOf('?')
|
|
48
71
|
if (q < 0 || !token.startsWith('t:')) return null
|
|
49
72
|
const model = token.slice(2, q)
|
|
50
73
|
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model)) return null
|
|
51
74
|
const params = new URLSearchParams(token.slice(q + 1))
|
|
75
|
+
if (params.has('page')) return null
|
|
52
76
|
let field = ''
|
|
53
77
|
let ids: string[] = []
|
|
54
78
|
for (const [key, value] of params.entries()) {
|
|
@@ -71,6 +95,11 @@ function entityKey(scope: string, shape: string, id: string): string {
|
|
|
71
95
|
return `${scope}\n${shape}\n${id}`
|
|
72
96
|
}
|
|
73
97
|
|
|
98
|
+
/** The row column an `in:` filter matches: `f_product_id` filters `product_id`. */
|
|
99
|
+
function rowField(param: string): string {
|
|
100
|
+
return param.startsWith('f_') ? param.slice(2) : param
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
function replaceInIds(token: string, field: string, ids: string[]): string {
|
|
75
104
|
const q = token.indexOf('?')
|
|
76
105
|
const params = new URLSearchParams(token.slice(q + 1))
|
|
@@ -99,7 +128,7 @@ export function narrowInToken(token: string, now = Date.now()): NarrowedIn | nul
|
|
|
99
128
|
missingIds.push(id)
|
|
100
129
|
continue
|
|
101
130
|
}
|
|
102
|
-
|
|
131
|
+
rows.push(...hit.rows)
|
|
103
132
|
}
|
|
104
133
|
if (missingIds.length === parsed.ids.length) return null
|
|
105
134
|
return {
|
|
@@ -111,7 +140,11 @@ export function narrowInToken(token: string, now = Date.now()): NarrowedIn | nul
|
|
|
111
140
|
}
|
|
112
141
|
}
|
|
113
142
|
|
|
114
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Stores every row of each requested id (one id can match many rows: the
|
|
145
|
+
* stock of a product in several warehouses). An id with no row is remembered
|
|
146
|
+
* as empty.
|
|
147
|
+
*/
|
|
115
148
|
export function rememberInRows(
|
|
116
149
|
token: string,
|
|
117
150
|
ids: string[],
|
|
@@ -121,22 +154,19 @@ export function rememberInRows(
|
|
|
121
154
|
const parsed = splitInToken(token)
|
|
122
155
|
if (!parsed) return
|
|
123
156
|
const scope = cacheScope()
|
|
124
|
-
const
|
|
157
|
+
const field = rowField(parsed.field)
|
|
158
|
+
const byId = new Map<string, Record<string, unknown>[]>()
|
|
159
|
+
for (const id of ids) byId.set(id, [])
|
|
125
160
|
if (Array.isArray(rows)) {
|
|
126
161
|
for (const row of rows) {
|
|
127
162
|
if (!row || typeof row !== 'object') continue
|
|
128
|
-
const id = String((row as Record<string, unknown>)[
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
entityCache.set(entityKey(scope, parsed.shape, id), {
|
|
132
|
-
row: row as Record<string, unknown>,
|
|
133
|
-
at: now,
|
|
134
|
-
})
|
|
163
|
+
const id = String((row as Record<string, unknown>)[field] ?? '').trim()
|
|
164
|
+
const bucket = byId.get(id)
|
|
165
|
+
if (bucket) bucket.push(row as Record<string, unknown>)
|
|
135
166
|
}
|
|
136
167
|
}
|
|
137
|
-
for (const id of
|
|
138
|
-
|
|
139
|
-
entityCache.set(entityKey(scope, parsed.shape, id), { row: null, at: now })
|
|
168
|
+
for (const [id, bucket] of byId) {
|
|
169
|
+
entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now })
|
|
140
170
|
}
|
|
141
171
|
}
|
|
142
172
|
|
|
@@ -147,6 +177,19 @@ export function resetQueryBatchCache(): void {
|
|
|
147
177
|
if (timer != null) clearTimeout(timer)
|
|
148
178
|
timer = null
|
|
149
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Forgets every row, record and option read. useApi calls it after each
|
|
183
|
+
* mutation so the list that just gained, changed or lost a row reads it
|
|
184
|
+
* again instead of the copy from before the save. Metadata and i18n stay.
|
|
185
|
+
*/
|
|
186
|
+
export function invalidateQueryBatchData(): void {
|
|
187
|
+
for (const key of [...partCache.keys()]) {
|
|
188
|
+
const token = key.slice(key.indexOf('\n') + 1)
|
|
189
|
+
if (!servedFromMemory(token)) partCache.delete(key)
|
|
190
|
+
}
|
|
191
|
+
entityCache.clear()
|
|
192
|
+
}
|
|
150
193
|
let waiters: Waiter[] = []
|
|
151
194
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
152
195
|
let transport: ApiClient | null = null
|
|
@@ -205,7 +248,7 @@ export function nameBatchTokens(tokens: string[]): { name: string; token: string
|
|
|
205
248
|
|
|
206
249
|
export function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart> {
|
|
207
250
|
const cached = partCache.get(`${cacheScope()}\n${token}`)
|
|
208
|
-
if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
251
|
+
if (cached && servedFromMemory(token) && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
209
252
|
return Promise.resolve(cached.part)
|
|
210
253
|
}
|
|
211
254
|
const narrowed = narrowInToken(token)
|
|
@@ -239,7 +282,8 @@ async function flushQueryBatch() {
|
|
|
239
282
|
const scope = cacheScope()
|
|
240
283
|
for (const part of named) {
|
|
241
284
|
const cached = partCache.get(`${scope}\n${part.token}`)
|
|
242
|
-
|
|
285
|
+
const etag = cached?.part.etag
|
|
286
|
+
if (etag && (servedFromMemory(part.token) || contentEtag(etag))) inm[part.name] = etag
|
|
243
287
|
}
|
|
244
288
|
try {
|
|
245
289
|
const res = await api.post('/q', { parts: named.map((part) => part.plan), inm })
|
|
@@ -257,9 +301,14 @@ async function flushQueryBatch() {
|
|
|
257
301
|
if (part.not_modified) {
|
|
258
302
|
const cached = partCache.get(`${scope}\n${waiter.token}`)
|
|
259
303
|
if (cached) {
|
|
304
|
+
cached.at = Date.now()
|
|
260
305
|
waiter.resolve(cached.part)
|
|
261
306
|
continue
|
|
262
307
|
}
|
|
308
|
+
// Invalidated while the request was in flight: nothing to
|
|
309
|
+
// reuse, so batchGet falls back to the plain GET.
|
|
310
|
+
waiter.reject(new Error('batch part not modified but not cached'))
|
|
311
|
+
continue
|
|
263
312
|
}
|
|
264
313
|
const merged = mergeCachedRows(waiter.cachedRows, part)
|
|
265
314
|
if (waiter.missingIds && waiter.field) {
|
|
@@ -113,6 +113,20 @@ type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
|
|
|
113
113
|
const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
|
|
114
114
|
const optionsInflight = new Map<string, Promise<OptionsPayload>>()
|
|
115
115
|
const optionsEpoch = new Map<string, number>()
|
|
116
|
+
// Bumped by invalidateOptionsCache: a lookup that started before a write must
|
|
117
|
+
// not store what it read.
|
|
118
|
+
let optionsGeneration = 0
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Forgets every cached and in-flight options lookup. Called after any write
|
|
122
|
+
* (create, edit, delete), so a picker opened right after creating a record
|
|
123
|
+
* lists it instead of serving the previous 30 s snapshot.
|
|
124
|
+
*/
|
|
125
|
+
export function invalidateOptionsCache(): void {
|
|
126
|
+
optionsGeneration++
|
|
127
|
+
optionsCache.clear()
|
|
128
|
+
optionsInflight.clear()
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
export function optionsRequestKey(
|
|
118
132
|
scope: string,
|
|
@@ -220,6 +234,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
220
234
|
optionsInflight.delete(key)
|
|
221
235
|
}
|
|
222
236
|
const epoch = optionsEpoch.get(key) ?? 0
|
|
237
|
+
const generation = optionsGeneration
|
|
223
238
|
const cached = optionsCache.get(key)
|
|
224
239
|
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
225
240
|
setOptions(cached.payload.options)
|
|
@@ -254,7 +269,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
254
269
|
data: part.data,
|
|
255
270
|
meta: part.meta,
|
|
256
271
|
})
|
|
257
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
272
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
258
273
|
optionsCache.set(key, { payload, at: Date.now() })
|
|
259
274
|
}
|
|
260
275
|
return payload
|
|
@@ -270,7 +285,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
270
285
|
throw new Error(body?.message || 'options resolver: unsuccessful response')
|
|
271
286
|
}
|
|
272
287
|
const payload = readOptionsEnvelope(body)
|
|
273
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
288
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
274
289
|
optionsCache.set(key, { payload, at: Date.now() })
|
|
275
290
|
}
|
|
276
291
|
return payload
|
|
@@ -22,6 +22,7 @@ export const VALIDATION_CATALOGS: Record<string, Record<string, string>> = {
|
|
|
22
22
|
integer: '{{label}} debe ser un entero',
|
|
23
23
|
custom: '{{label}} no es válido',
|
|
24
24
|
line_items_required: '{{label}} requiere al menos un renglón',
|
|
25
|
+
protected_field: 'El campo {{label}} lo administra el sistema y no se puede modificar',
|
|
25
26
|
fallback: '{{label}}: valor inválido',
|
|
26
27
|
},
|
|
27
28
|
en: {
|
|
@@ -43,6 +44,7 @@ export const VALIDATION_CATALOGS: Record<string, Record<string, string>> = {
|
|
|
43
44
|
integer: 'The {{label}} must be an integer',
|
|
44
45
|
custom: 'The {{label}} is invalid',
|
|
45
46
|
line_items_required: '{{label}} requires at least one row',
|
|
47
|
+
protected_field: 'The {{label}} field is managed by the system and cannot be changed',
|
|
46
48
|
fallback: '{{label}}: invalid value',
|
|
47
49
|
},
|
|
48
50
|
}
|