@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
package/dist/query-batch.js
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
const WINDOW_MS = 8;
|
|
2
2
|
const PART_TTL_MS = 30_000;
|
|
3
3
|
const ENTITY_TTL_MS = 30_000;
|
|
4
|
+
/**
|
|
5
|
+
* Only metadata and i18n are served from memory without asking the server.
|
|
6
|
+
* Rows (`t:` lists, `r:` records) and options change with every save: they
|
|
7
|
+
* always travel, and an unchanged payload comes back as `not_modified` when
|
|
8
|
+
* the server's etag hashes the content.
|
|
9
|
+
*/
|
|
10
|
+
function servedFromMemory(token) {
|
|
11
|
+
return token.startsWith('m:') || token.startsWith('mm:') || token.startsWith('i:');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Etags the server derives from the payload itself (`"qh-…"`). The older
|
|
15
|
+
* `"q-<seed>-<len>"` ones only counted rows or bytes, so a value edited to one
|
|
16
|
+
* of the same length came back `not_modified` with the old data.
|
|
17
|
+
*/
|
|
18
|
+
function contentEtag(etag) {
|
|
19
|
+
return !!etag && etag.startsWith('"qh-');
|
|
20
|
+
}
|
|
4
21
|
const partCache = new Map();
|
|
5
22
|
const entityCache = new Map();
|
|
6
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
|
|
25
|
+
* yet. A paginated list (`page=`) is not split: serving part of it from memory
|
|
26
|
+
* would change which rows land on the page and the total.
|
|
27
|
+
*/
|
|
7
28
|
export function splitInToken(token) {
|
|
8
29
|
const q = token.indexOf('?');
|
|
9
30
|
if (q < 0 || !token.startsWith('t:'))
|
|
@@ -12,6 +33,8 @@ export function splitInToken(token) {
|
|
|
12
33
|
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model))
|
|
13
34
|
return null;
|
|
14
35
|
const params = new URLSearchParams(token.slice(q + 1));
|
|
36
|
+
if (params.has('page'))
|
|
37
|
+
return null;
|
|
15
38
|
let field = '';
|
|
16
39
|
let ids = [];
|
|
17
40
|
for (const [key, value] of params.entries()) {
|
|
@@ -35,6 +58,10 @@ export function splitInToken(token) {
|
|
|
35
58
|
function entityKey(scope, shape, id) {
|
|
36
59
|
return `${scope}\n${shape}\n${id}`;
|
|
37
60
|
}
|
|
61
|
+
/** The row column an `in:` filter matches: `f_product_id` filters `product_id`. */
|
|
62
|
+
function rowField(param) {
|
|
63
|
+
return param.startsWith('f_') ? param.slice(2) : param;
|
|
64
|
+
}
|
|
38
65
|
function replaceInIds(token, field, ids) {
|
|
39
66
|
const q = token.indexOf('?');
|
|
40
67
|
const params = new URLSearchParams(token.slice(q + 1));
|
|
@@ -55,8 +82,7 @@ export function narrowInToken(token, now = Date.now()) {
|
|
|
55
82
|
missingIds.push(id);
|
|
56
83
|
continue;
|
|
57
84
|
}
|
|
58
|
-
|
|
59
|
-
rows.push(hit.row);
|
|
85
|
+
rows.push(...hit.rows);
|
|
60
86
|
}
|
|
61
87
|
if (missingIds.length === parsed.ids.length)
|
|
62
88
|
return null;
|
|
@@ -68,31 +94,32 @@ export function narrowInToken(token, now = Date.now()) {
|
|
|
68
94
|
shape: parsed.shape,
|
|
69
95
|
};
|
|
70
96
|
}
|
|
71
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Stores every row of each requested id (one id can match many rows: the
|
|
99
|
+
* stock of a product in several warehouses). An id with no row is remembered
|
|
100
|
+
* as empty.
|
|
101
|
+
*/
|
|
72
102
|
export function rememberInRows(token, ids, rows, now = Date.now()) {
|
|
73
103
|
const parsed = splitInToken(token);
|
|
74
104
|
if (!parsed)
|
|
75
105
|
return;
|
|
76
106
|
const scope = cacheScope();
|
|
77
|
-
const
|
|
107
|
+
const field = rowField(parsed.field);
|
|
108
|
+
const byId = new Map();
|
|
109
|
+
for (const id of ids)
|
|
110
|
+
byId.set(id, []);
|
|
78
111
|
if (Array.isArray(rows)) {
|
|
79
112
|
for (const row of rows) {
|
|
80
113
|
if (!row || typeof row !== 'object')
|
|
81
114
|
continue;
|
|
82
|
-
const id = String(row[
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
entityCache.set(entityKey(scope, parsed.shape, id), {
|
|
87
|
-
row: row,
|
|
88
|
-
at: now,
|
|
89
|
-
});
|
|
115
|
+
const id = String(row[field] ?? '').trim();
|
|
116
|
+
const bucket = byId.get(id);
|
|
117
|
+
if (bucket)
|
|
118
|
+
bucket.push(row);
|
|
90
119
|
}
|
|
91
120
|
}
|
|
92
|
-
for (const id of
|
|
93
|
-
|
|
94
|
-
continue;
|
|
95
|
-
entityCache.set(entityKey(scope, parsed.shape, id), { row: null, at: now });
|
|
121
|
+
for (const [id, bucket] of byId) {
|
|
122
|
+
entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now });
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
export function resetQueryBatchCache() {
|
|
@@ -103,6 +130,19 @@ export function resetQueryBatchCache() {
|
|
|
103
130
|
clearTimeout(timer);
|
|
104
131
|
timer = null;
|
|
105
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Forgets every row, record and option read. useApi calls it after each
|
|
135
|
+
* mutation so the list that just gained, changed or lost a row reads it
|
|
136
|
+
* again instead of the copy from before the save. Metadata and i18n stay.
|
|
137
|
+
*/
|
|
138
|
+
export function invalidateQueryBatchData() {
|
|
139
|
+
for (const key of [...partCache.keys()]) {
|
|
140
|
+
const token = key.slice(key.indexOf('\n') + 1);
|
|
141
|
+
if (!servedFromMemory(token))
|
|
142
|
+
partCache.delete(key);
|
|
143
|
+
}
|
|
144
|
+
entityCache.clear();
|
|
145
|
+
}
|
|
106
146
|
let waiters = [];
|
|
107
147
|
let timer = null;
|
|
108
148
|
let transport = null;
|
|
@@ -157,7 +197,7 @@ export function nameBatchTokens(tokens) {
|
|
|
157
197
|
}
|
|
158
198
|
export function loadQueryPart(api, token) {
|
|
159
199
|
const cached = partCache.get(`${cacheScope()}\n${token}`);
|
|
160
|
-
if (cached && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
200
|
+
if (cached && servedFromMemory(token) && Date.now() - cached.at < PART_TTL_MS && cached.part.success) {
|
|
161
201
|
return Promise.resolve(cached.part);
|
|
162
202
|
}
|
|
163
203
|
const narrowed = narrowInToken(token);
|
|
@@ -192,8 +232,9 @@ async function flushQueryBatch() {
|
|
|
192
232
|
const scope = cacheScope();
|
|
193
233
|
for (const part of named) {
|
|
194
234
|
const cached = partCache.get(`${scope}\n${part.token}`);
|
|
195
|
-
|
|
196
|
-
|
|
235
|
+
const etag = cached?.part.etag;
|
|
236
|
+
if (etag && (servedFromMemory(part.token) || contentEtag(etag)))
|
|
237
|
+
inm[part.name] = etag;
|
|
197
238
|
}
|
|
198
239
|
try {
|
|
199
240
|
const res = await api.post('/q', { parts: named.map((part) => part.plan), inm });
|
|
@@ -211,9 +252,14 @@ async function flushQueryBatch() {
|
|
|
211
252
|
if (part.not_modified) {
|
|
212
253
|
const cached = partCache.get(`${scope}\n${waiter.token}`);
|
|
213
254
|
if (cached) {
|
|
255
|
+
cached.at = Date.now();
|
|
214
256
|
waiter.resolve(cached.part);
|
|
215
257
|
continue;
|
|
216
258
|
}
|
|
259
|
+
// Invalidated while the request was in flight: nothing to
|
|
260
|
+
// reuse, so batchGet falls back to the plain GET.
|
|
261
|
+
waiter.reject(new Error('batch part not modified but not cached'));
|
|
262
|
+
continue;
|
|
217
263
|
}
|
|
218
264
|
const merged = mergeCachedRows(waiter.cachedRows, part);
|
|
219
265
|
if (waiter.missingIds && waiter.field) {
|
|
@@ -76,6 +76,12 @@ export interface UseOptionsResolverResult {
|
|
|
76
76
|
/** Forces a refetch. Useful after a parent record updates. */
|
|
77
77
|
refetch: () => void;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Forgets every cached and in-flight options lookup. Called after any write
|
|
81
|
+
* (create, edit, delete), so a picker opened right after creating a record
|
|
82
|
+
* lists it instead of serving the previous 30 s snapshot.
|
|
83
|
+
*/
|
|
84
|
+
export declare function invalidateOptionsCache(): void;
|
|
79
85
|
export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
|
|
80
86
|
export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
|
|
81
87
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;
|
|
1
|
+
{"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAuBD;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAI7C;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAER;AA8BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CA6JzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
|
|
@@ -27,6 +27,19 @@ const OPTIONS_TTL_MS = 30_000;
|
|
|
27
27
|
const optionsCache = new Map();
|
|
28
28
|
const optionsInflight = new Map();
|
|
29
29
|
const optionsEpoch = new Map();
|
|
30
|
+
// Bumped by invalidateOptionsCache: a lookup that started before a write must
|
|
31
|
+
// not store what it read.
|
|
32
|
+
let optionsGeneration = 0;
|
|
33
|
+
/**
|
|
34
|
+
* Forgets every cached and in-flight options lookup. Called after any write
|
|
35
|
+
* (create, edit, delete), so a picker opened right after creating a record
|
|
36
|
+
* lists it instead of serving the previous 30 s snapshot.
|
|
37
|
+
*/
|
|
38
|
+
export function invalidateOptionsCache() {
|
|
39
|
+
optionsGeneration++;
|
|
40
|
+
optionsCache.clear();
|
|
41
|
+
optionsInflight.clear();
|
|
42
|
+
}
|
|
30
43
|
export function optionsRequestKey(scope, url, field, query, limit, filter) {
|
|
31
44
|
return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n');
|
|
32
45
|
}
|
|
@@ -108,6 +121,7 @@ export function useOptionsResolver(args) {
|
|
|
108
121
|
optionsInflight.delete(key);
|
|
109
122
|
}
|
|
110
123
|
const epoch = optionsEpoch.get(key) ?? 0;
|
|
124
|
+
const generation = optionsGeneration;
|
|
111
125
|
const cached = optionsCache.get(key);
|
|
112
126
|
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
113
127
|
setOptions(cached.payload.options);
|
|
@@ -140,7 +154,7 @@ export function useOptionsResolver(args) {
|
|
|
140
154
|
data: part.data,
|
|
141
155
|
meta: part.meta,
|
|
142
156
|
});
|
|
143
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
157
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
144
158
|
optionsCache.set(key, { payload, at: Date.now() });
|
|
145
159
|
}
|
|
146
160
|
return payload;
|
|
@@ -158,7 +172,7 @@ export function useOptionsResolver(args) {
|
|
|
158
172
|
throw new Error(body?.message || 'options resolver: unsuccessful response');
|
|
159
173
|
}
|
|
160
174
|
const payload = readOptionsEnvelope(body);
|
|
161
|
-
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
175
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch && optionsGeneration === generation) {
|
|
162
176
|
optionsCache.set(key, { payload, at: Date.now() });
|
|
163
177
|
}
|
|
164
178
|
return payload;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation-catalog.d.ts","sourceRoot":"","sources":["../src/validation-catalog.ts"],"names":[],"mappings":"AAAA;;yEAEyE;AAEzE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"validation-catalog.d.ts","sourceRoot":"","sources":["../src/validation-catalog.ts"],"names":[],"mappings":"AAAA;;yEAEyE;AAEzE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA6CtE,CAAA;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAG3E;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAI3F"}
|
|
@@ -21,6 +21,7 @@ export const VALIDATION_CATALOGS = {
|
|
|
21
21
|
integer: '{{label}} debe ser un entero',
|
|
22
22
|
custom: '{{label}} no es válido',
|
|
23
23
|
line_items_required: '{{label}} requiere al menos un renglón',
|
|
24
|
+
protected_field: 'El campo {{label}} lo administra el sistema y no se puede modificar',
|
|
24
25
|
fallback: '{{label}}: valor inválido',
|
|
25
26
|
},
|
|
26
27
|
en: {
|
|
@@ -42,6 +43,7 @@ export const VALIDATION_CATALOGS = {
|
|
|
42
43
|
integer: 'The {{label}} must be an integer',
|
|
43
44
|
custom: 'The {{label}} is invalid',
|
|
44
45
|
line_items_required: '{{label}} requires at least one row',
|
|
46
|
+
protected_field: 'The {{label}} field is managed by the system and cannot be changed',
|
|
45
47
|
fallback: '{{label}}: invalid value',
|
|
46
48
|
},
|
|
47
49
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "39.2.
|
|
3
|
+
"version": "39.2.9",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@asteby/metacore-sdk": "^3.8.0",
|
|
30
|
-
"@asteby/metacore-ui": "^2.18.
|
|
30
|
+
"@asteby/metacore-ui": "^2.18.3",
|
|
31
31
|
"@tanstack/react-query": ">=5",
|
|
32
32
|
"@tanstack/react-router": ">=1.100",
|
|
33
33
|
"@tanstack/react-table": ">=8",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@asteby/metacore-sdk": "3.8.0",
|
|
54
|
-
"@asteby/metacore-ui": "2.18.
|
|
54
|
+
"@asteby/metacore-ui": "2.18.3",
|
|
55
55
|
"@tanstack/react-query": "^5.95.0",
|
|
56
56
|
"@tanstack/react-router": "^1.168.0",
|
|
57
57
|
"@tanstack/react-table": "^8.20.0",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { es } from 'date-fns/locale'
|
|
3
|
+
import { parseCalendarDate } from '../calendar-date'
|
|
4
|
+
import { formatDateCell } from '../dynamic-columns'
|
|
5
|
+
|
|
6
|
+
describe('parseCalendarDate', () => {
|
|
7
|
+
it('lee UTC medianoche como el mismo día local (PIT-025)', () => {
|
|
8
|
+
const d = parseCalendarDate('2026-09-27T00:00:00Z')!
|
|
9
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([2026, 8, 27])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('acepta YYYY-MM-DD y vacíos', () => {
|
|
13
|
+
expect(parseCalendarDate('2026-01-05')!.getDate()).toBe(5)
|
|
14
|
+
expect(parseCalendarDate('')).toBeUndefined()
|
|
15
|
+
expect(parseCalendarDate(null)).toBeUndefined()
|
|
16
|
+
expect(parseCalendarDate('no es fecha')).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('la celda date sin zona de la org no retrocede un día', () => {
|
|
20
|
+
expect(formatDateCell('2026-09-27T00:00:00Z', 'date', es)?.display).toContain('27')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
import { ApiProvider, useApi } from '../api-context'
|
|
6
|
+
import { useOptionsResolver, invalidateOptionsCache } from '../use-options-resolver'
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup()
|
|
10
|
+
invalidateOptionsCache()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// A picker opened right after creating a record must list it: the 30 s
|
|
14
|
+
// options cache is dropped by every write, whether it goes through useApi()
|
|
15
|
+
// or straight through the host's axios instance.
|
|
16
|
+
|
|
17
|
+
function Picker() {
|
|
18
|
+
const { options } = useOptionsResolver({ modelKey: '', ref: 'Brand', fieldKey: 'id' })
|
|
19
|
+
return <span data-testid="opts">{options.map((o) => o.label).join(',')}</span>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hostClient(brands: string[]) {
|
|
23
|
+
const handlers: Array<(res: any) => any> = []
|
|
24
|
+
const client = {
|
|
25
|
+
get: vi.fn(async () => ({ data: { success: true, data: brands.map((b) => ({ id: b, label: b })) } })),
|
|
26
|
+
// The batch path is unavailable on this fake host: /api/q 404s.
|
|
27
|
+
post: vi.fn(async (url: string) => {
|
|
28
|
+
if (url === '/q') return { data: { success: false } }
|
|
29
|
+
return { data: { success: true } }
|
|
30
|
+
}),
|
|
31
|
+
put: vi.fn(async () => ({ data: { success: true } })),
|
|
32
|
+
delete: vi.fn(async () => ({ data: { success: true } })),
|
|
33
|
+
interceptors: {
|
|
34
|
+
response: {
|
|
35
|
+
use: (ok: (res: any) => any) => {
|
|
36
|
+
handlers.push(ok)
|
|
37
|
+
return handlers.length - 1
|
|
38
|
+
},
|
|
39
|
+
eject: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
const hostWrite = async (method: string, url = '/data/brands') => {
|
|
44
|
+
for (const h of handlers) h({ config: { method, url } })
|
|
45
|
+
}
|
|
46
|
+
return { client, hostWrite }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('options cache invalidation', () => {
|
|
50
|
+
it('a host write through its own client drops the cached options', async () => {
|
|
51
|
+
const brands = ['A']
|
|
52
|
+
const { client, hostWrite } = hostClient(brands)
|
|
53
|
+
const view = render(
|
|
54
|
+
<ApiProvider client={client}>
|
|
55
|
+
<Picker />
|
|
56
|
+
</ApiProvider>,
|
|
57
|
+
)
|
|
58
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
|
|
59
|
+
|
|
60
|
+
brands.push('B')
|
|
61
|
+
await act(async () => {
|
|
62
|
+
await hostWrite('post')
|
|
63
|
+
})
|
|
64
|
+
view.unmount()
|
|
65
|
+
render(
|
|
66
|
+
<ApiProvider client={client}>
|
|
67
|
+
<Picker />
|
|
68
|
+
</ApiProvider>,
|
|
69
|
+
)
|
|
70
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A,B'))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('neither a GET nor the /q read batch drops the cache', async () => {
|
|
74
|
+
const brands = ['A']
|
|
75
|
+
const { client, hostWrite } = hostClient(brands)
|
|
76
|
+
const view = render(
|
|
77
|
+
<ApiProvider client={client}>
|
|
78
|
+
<Picker />
|
|
79
|
+
</ApiProvider>,
|
|
80
|
+
)
|
|
81
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
|
|
82
|
+
const calls = client.get.mock.calls.length
|
|
83
|
+
brands.push('B')
|
|
84
|
+
await act(async () => {
|
|
85
|
+
await hostWrite('get')
|
|
86
|
+
await hostWrite('post', '/q')
|
|
87
|
+
})
|
|
88
|
+
view.unmount()
|
|
89
|
+
render(
|
|
90
|
+
<ApiProvider client={client}>
|
|
91
|
+
<Picker />
|
|
92
|
+
</ApiProvider>,
|
|
93
|
+
)
|
|
94
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
|
|
95
|
+
expect(client.get.mock.calls.length).toBe(calls)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('a write through useApi() drops the cached options', async () => {
|
|
99
|
+
const brands = ['A']
|
|
100
|
+
const { client } = hostClient(brands)
|
|
101
|
+
let write: (() => Promise<unknown>) | null = null
|
|
102
|
+
function Writer() {
|
|
103
|
+
const api = useApi()
|
|
104
|
+
write = () => api.post('/data/brands', { name: 'B' })
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const view = render(
|
|
108
|
+
<ApiProvider client={{ ...client, interceptors: undefined } as never}>
|
|
109
|
+
<Picker />
|
|
110
|
+
<Writer />
|
|
111
|
+
</ApiProvider>,
|
|
112
|
+
)
|
|
113
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
|
|
114
|
+
brands.push('B')
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await write!()
|
|
117
|
+
})
|
|
118
|
+
view.unmount()
|
|
119
|
+
render(
|
|
120
|
+
<ApiProvider client={{ ...client, interceptors: undefined } as never}>
|
|
121
|
+
<Picker />
|
|
122
|
+
</ApiProvider>,
|
|
123
|
+
)
|
|
124
|
+
await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A,B'))
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -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,8 +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, useMemo } from 'react'
|
|
6
|
-
import { batchGet } from './query-batch'
|
|
5
|
+
import React, { createContext, useContext, useEffect, useMemo } from 'react'
|
|
6
|
+
import { batchGet, invalidateQueryBatchData } from './query-batch'
|
|
7
|
+
import { invalidateOptionsCache } from './use-options-resolver'
|
|
7
8
|
|
|
8
9
|
/** Minimal axios-compatible client shape consumed by runtime-react. */
|
|
9
10
|
export interface ApiClient {
|
|
@@ -21,9 +22,59 @@ export interface ApiProviderProps {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
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])
|
|
24
46
|
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
|
25
47
|
}
|
|
26
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
|
+
|
|
27
78
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
28
79
|
export function useApi(): ApiClient {
|
|
29
80
|
const ctx = useContext(ApiContext)
|
|
@@ -31,12 +82,15 @@ export function useApi(): ApiClient {
|
|
|
31
82
|
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
|
|
32
83
|
}
|
|
33
84
|
// List, metadata and options GETs share one POST /api/q. Mutations stay
|
|
34
|
-
// on the host client
|
|
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.
|
|
35
88
|
return useMemo<ApiClient>(() => ({
|
|
36
89
|
get: (url, config) => batchGet(ctx, url, config),
|
|
37
|
-
post: (url, body, config) =>
|
|
38
|
-
|
|
39
|
-
|
|
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)),
|
|
40
94
|
}), [ctx])
|
|
41
95
|
}
|
|
42
96
|
|
|
@@ -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
|
/**
|