@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
package/dist/query-batch.d.ts
CHANGED
|
@@ -14,6 +14,39 @@ export interface QueryPart {
|
|
|
14
14
|
etag?: string;
|
|
15
15
|
not_modified?: boolean;
|
|
16
16
|
}
|
|
17
|
+
export interface InListToken {
|
|
18
|
+
field: string;
|
|
19
|
+
ids: string[];
|
|
20
|
+
shape: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A concrete `t:` `in:` list. `@` references stay whole: the ids are not known
|
|
24
|
+
* yet. A paginated list (`page=`) is not split: serving part of it from memory
|
|
25
|
+
* would change which rows land on the page and the total.
|
|
26
|
+
*/
|
|
27
|
+
export declare function splitInToken(token: string): InListToken | null;
|
|
28
|
+
export interface NarrowedIn {
|
|
29
|
+
wire: string;
|
|
30
|
+
rows: Record<string, unknown>[];
|
|
31
|
+
missingIds: string[];
|
|
32
|
+
field: string;
|
|
33
|
+
shape: string;
|
|
34
|
+
}
|
|
35
|
+
/** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
|
|
36
|
+
export declare function narrowInToken(token: string, now?: number): NarrowedIn | null;
|
|
37
|
+
/**
|
|
38
|
+
* Stores every row of each requested id (one id can match many rows: the
|
|
39
|
+
* stock of a product in several warehouses). An id with no row is remembered
|
|
40
|
+
* as empty.
|
|
41
|
+
*/
|
|
42
|
+
export declare function rememberInRows(token: string, ids: string[], rows: unknown, now?: number): void;
|
|
43
|
+
export declare function resetQueryBatchCache(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Forgets every row, record and option read. useApi calls it after each
|
|
46
|
+
* mutation so the list that just gained, changed or lost a row reads it
|
|
47
|
+
* again instead of the copy from before the save. Metadata and i18n stay.
|
|
48
|
+
*/
|
|
49
|
+
export declare function invalidateQueryBatchData(): void;
|
|
17
50
|
export declare function optionsModelFromUrl(url: string): string | null;
|
|
18
51
|
export declare function optionsBatchToken(model: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
|
|
19
52
|
/** Names each distinct token so the server can key the response. */
|
|
@@ -23,4 +56,19 @@ export declare function nameBatchTokens(tokens: string[]): {
|
|
|
23
56
|
plan: string;
|
|
24
57
|
}[];
|
|
25
58
|
export declare function loadQueryPart(api: ApiClient, token: string): Promise<QueryPart>;
|
|
59
|
+
/**
|
|
60
|
+
* Token for a host GET that the batch can serve. Custom endpoints, aggregates
|
|
61
|
+
* and `/me` stay on the legacy GET.
|
|
62
|
+
*/
|
|
63
|
+
export declare function tokenForGet(url: string, params?: Record<string, unknown>): string | null;
|
|
64
|
+
/**
|
|
65
|
+
* GET through the batch when the URL is a list, table metadata, modal metadata
|
|
66
|
+
* or options read. Anything the batch refuses falls back to the original GET.
|
|
67
|
+
*/
|
|
68
|
+
export declare function batchGet(api: ApiClient, url: string, config?: {
|
|
69
|
+
params?: Record<string, unknown>;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
data: any;
|
|
72
|
+
headers?: any;
|
|
73
|
+
}>;
|
|
26
74
|
//# sourceMappingURL=query-batch.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;
|
|
1
|
+
{"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAM3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAOR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAsB/E;AAmED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;;;GAwBxG"}
|
package/dist/query-batch.js
CHANGED
|
@@ -1,9 +1,157 @@
|
|
|
1
1
|
const WINDOW_MS = 8;
|
|
2
2
|
const PART_TTL_MS = 30_000;
|
|
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
|
+
}
|
|
3
21
|
const partCache = new Map();
|
|
22
|
+
const entityCache = new Map();
|
|
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
|
+
*/
|
|
28
|
+
export function splitInToken(token) {
|
|
29
|
+
const q = token.indexOf('?');
|
|
30
|
+
if (q < 0 || !token.startsWith('t:'))
|
|
31
|
+
return null;
|
|
32
|
+
const model = token.slice(2, q);
|
|
33
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(model))
|
|
34
|
+
return null;
|
|
35
|
+
const params = new URLSearchParams(token.slice(q + 1));
|
|
36
|
+
if (params.has('page'))
|
|
37
|
+
return null;
|
|
38
|
+
let field = '';
|
|
39
|
+
let ids = [];
|
|
40
|
+
for (const [key, value] of params.entries()) {
|
|
41
|
+
if (!value.startsWith('in:'))
|
|
42
|
+
continue;
|
|
43
|
+
if (field || value.includes('@'))
|
|
44
|
+
return null;
|
|
45
|
+
field = key;
|
|
46
|
+
ids = value
|
|
47
|
+
.slice(3)
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((id) => id.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
if (!field || ids.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const keys = [...new Set(params.keys())].sort();
|
|
55
|
+
const bits = keys.map((key) => `${key}=${key === field ? 'in:$1' : (params.get(key) ?? '')}`);
|
|
56
|
+
return { field, ids, shape: `t:${model}?${bits.join('&')}` };
|
|
57
|
+
}
|
|
58
|
+
function entityKey(scope, shape, id) {
|
|
59
|
+
return `${scope}\n${shape}\n${id}`;
|
|
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
|
+
}
|
|
65
|
+
function replaceInIds(token, field, ids) {
|
|
66
|
+
const q = token.indexOf('?');
|
|
67
|
+
const params = new URLSearchParams(token.slice(q + 1));
|
|
68
|
+
params.set(field, `in:${ids.join(',')}`);
|
|
69
|
+
return `${token.slice(0, q)}?${params.toString()}`;
|
|
70
|
+
}
|
|
71
|
+
/** Drops ids already cached for this shape. An empty wire means nothing has to travel. */
|
|
72
|
+
export function narrowInToken(token, now = Date.now()) {
|
|
73
|
+
const parsed = splitInToken(token);
|
|
74
|
+
if (!parsed)
|
|
75
|
+
return null;
|
|
76
|
+
const scope = cacheScope();
|
|
77
|
+
const rows = [];
|
|
78
|
+
const missingIds = [];
|
|
79
|
+
for (const id of parsed.ids) {
|
|
80
|
+
const hit = entityCache.get(entityKey(scope, parsed.shape, id));
|
|
81
|
+
if (!hit || now - hit.at >= ENTITY_TTL_MS) {
|
|
82
|
+
missingIds.push(id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
rows.push(...hit.rows);
|
|
86
|
+
}
|
|
87
|
+
if (missingIds.length === parsed.ids.length)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
wire: missingIds.length === 0 ? '' : replaceInIds(token, parsed.field, missingIds),
|
|
91
|
+
rows,
|
|
92
|
+
missingIds,
|
|
93
|
+
field: parsed.field,
|
|
94
|
+
shape: parsed.shape,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
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
|
+
*/
|
|
102
|
+
export function rememberInRows(token, ids, rows, now = Date.now()) {
|
|
103
|
+
const parsed = splitInToken(token);
|
|
104
|
+
if (!parsed)
|
|
105
|
+
return;
|
|
106
|
+
const scope = cacheScope();
|
|
107
|
+
const field = rowField(parsed.field);
|
|
108
|
+
const byId = new Map();
|
|
109
|
+
for (const id of ids)
|
|
110
|
+
byId.set(id, []);
|
|
111
|
+
if (Array.isArray(rows)) {
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
if (!row || typeof row !== 'object')
|
|
114
|
+
continue;
|
|
115
|
+
const id = String(row[field] ?? '').trim();
|
|
116
|
+
const bucket = byId.get(id);
|
|
117
|
+
if (bucket)
|
|
118
|
+
bucket.push(row);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [id, bucket] of byId) {
|
|
122
|
+
entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function resetQueryBatchCache() {
|
|
126
|
+
partCache.clear();
|
|
127
|
+
entityCache.clear();
|
|
128
|
+
waiters = [];
|
|
129
|
+
if (timer != null)
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
timer = null;
|
|
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
|
+
}
|
|
4
146
|
let waiters = [];
|
|
5
147
|
let timer = null;
|
|
6
148
|
let transport = null;
|
|
149
|
+
function mergeCachedRows(cached, part) {
|
|
150
|
+
if (!cached?.length)
|
|
151
|
+
return part;
|
|
152
|
+
const fresh = Array.isArray(part.data) ? part.data : [];
|
|
153
|
+
return { ...part, data: [...cached, ...fresh] };
|
|
154
|
+
}
|
|
7
155
|
function cacheScope() {
|
|
8
156
|
if (typeof localStorage === 'undefined')
|
|
9
157
|
return '';
|
|
@@ -49,12 +197,24 @@ export function nameBatchTokens(tokens) {
|
|
|
49
197
|
}
|
|
50
198
|
export function loadQueryPart(api, token) {
|
|
51
199
|
const cached = partCache.get(`${cacheScope()}\n${token}`);
|
|
52
|
-
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) {
|
|
53
201
|
return Promise.resolve(cached.part);
|
|
54
202
|
}
|
|
203
|
+
const narrowed = narrowInToken(token);
|
|
204
|
+
if (narrowed && narrowed.wire === '') {
|
|
205
|
+
return Promise.resolve({ success: true, data: narrowed.rows, meta: { total: narrowed.rows.length } });
|
|
206
|
+
}
|
|
55
207
|
return new Promise((resolve, reject) => {
|
|
56
208
|
transport = api;
|
|
57
|
-
waiters.push({
|
|
209
|
+
waiters.push({
|
|
210
|
+
token,
|
|
211
|
+
wire: narrowed?.wire ?? token,
|
|
212
|
+
resolve,
|
|
213
|
+
reject,
|
|
214
|
+
cachedRows: narrowed?.rows,
|
|
215
|
+
missingIds: narrowed?.missingIds,
|
|
216
|
+
field: narrowed?.field,
|
|
217
|
+
});
|
|
58
218
|
if (timer == null)
|
|
59
219
|
timer = setTimeout(() => void flushQueryBatch(), WINDOW_MS);
|
|
60
220
|
});
|
|
@@ -66,14 +226,15 @@ async function flushQueryBatch() {
|
|
|
66
226
|
const api = transport;
|
|
67
227
|
if (!api || batch.length === 0)
|
|
68
228
|
return;
|
|
69
|
-
const named = nameBatchTokens(batch.map((waiter) => waiter.
|
|
229
|
+
const named = nameBatchTokens(batch.map((waiter) => waiter.wire));
|
|
70
230
|
const byToken = new Map(named.map((part) => [part.token, part.name]));
|
|
71
231
|
const inm = {};
|
|
72
232
|
const scope = cacheScope();
|
|
73
233
|
for (const part of named) {
|
|
74
234
|
const cached = partCache.get(`${scope}\n${part.token}`);
|
|
75
|
-
|
|
76
|
-
|
|
235
|
+
const etag = cached?.part.etag;
|
|
236
|
+
if (etag && (servedFromMemory(part.token) || contentEtag(etag)))
|
|
237
|
+
inm[part.name] = etag;
|
|
77
238
|
}
|
|
78
239
|
try {
|
|
79
240
|
const res = await api.post('/q', { parts: named.map((part) => part.plan), inm });
|
|
@@ -82,7 +243,7 @@ async function flushQueryBatch() {
|
|
|
82
243
|
throw new Error('batch unavailable');
|
|
83
244
|
}
|
|
84
245
|
for (const waiter of batch) {
|
|
85
|
-
const name = byToken.get(waiter.
|
|
246
|
+
const name = byToken.get(waiter.wire);
|
|
86
247
|
const part = name ? body.parts[name] : undefined;
|
|
87
248
|
if (!part) {
|
|
88
249
|
waiter.reject(new Error('batch part missing'));
|
|
@@ -91,14 +252,23 @@ async function flushQueryBatch() {
|
|
|
91
252
|
if (part.not_modified) {
|
|
92
253
|
const cached = partCache.get(`${scope}\n${waiter.token}`);
|
|
93
254
|
if (cached) {
|
|
255
|
+
cached.at = Date.now();
|
|
94
256
|
waiter.resolve(cached.part);
|
|
95
257
|
continue;
|
|
96
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;
|
|
263
|
+
}
|
|
264
|
+
const merged = mergeCachedRows(waiter.cachedRows, part);
|
|
265
|
+
if (waiter.missingIds && waiter.field) {
|
|
266
|
+
rememberInRows(waiter.token, waiter.missingIds, part.data);
|
|
97
267
|
}
|
|
98
268
|
if (part.success) {
|
|
99
|
-
partCache.set(`${scope}\n${waiter.token}`, { part, at: Date.now() });
|
|
269
|
+
partCache.set(`${scope}\n${waiter.token}`, { part: merged, at: Date.now() });
|
|
100
270
|
}
|
|
101
|
-
waiter.resolve(
|
|
271
|
+
waiter.resolve(merged);
|
|
102
272
|
}
|
|
103
273
|
}
|
|
104
274
|
catch (err) {
|
|
@@ -107,3 +277,76 @@ async function flushQueryBatch() {
|
|
|
107
277
|
waiter.reject(error);
|
|
108
278
|
}
|
|
109
279
|
}
|
|
280
|
+
function canonicalParams(params) {
|
|
281
|
+
if (!params)
|
|
282
|
+
return '';
|
|
283
|
+
const keys = Object.keys(params)
|
|
284
|
+
.filter((key) => params[key] != null && params[key] !== '')
|
|
285
|
+
.sort();
|
|
286
|
+
const search = new URLSearchParams();
|
|
287
|
+
for (const key of keys)
|
|
288
|
+
search.set(key, String(params[key]));
|
|
289
|
+
return search.toString();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Token for a host GET that the batch can serve. Custom endpoints, aggregates
|
|
293
|
+
* and `/me` stay on the legacy GET.
|
|
294
|
+
*/
|
|
295
|
+
export function tokenForGet(url, params) {
|
|
296
|
+
const [path, query = ''] = url.split('?');
|
|
297
|
+
const table = path.match(/^\/metadata\/table\/([A-Za-z][A-Za-z0-9_]*)$/);
|
|
298
|
+
if (table)
|
|
299
|
+
return `m:${table[1]}`;
|
|
300
|
+
const modal = path.match(/^\/metadata\/modal\/([A-Za-z][A-Za-z0-9_]*)$/);
|
|
301
|
+
if (modal)
|
|
302
|
+
return `mm:${modal[1]}`;
|
|
303
|
+
const list = path.match(/^\/data\/([A-Za-z][A-Za-z0-9_]*)$/);
|
|
304
|
+
if (list) {
|
|
305
|
+
const q = canonicalParams(params);
|
|
306
|
+
return q ? `t:${list[1]}?${q}` : `t:${list[1]}`;
|
|
307
|
+
}
|
|
308
|
+
const opt = path.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/);
|
|
309
|
+
if (!opt)
|
|
310
|
+
return null;
|
|
311
|
+
const search = new URLSearchParams(query);
|
|
312
|
+
if (params) {
|
|
313
|
+
for (const [key, value] of Object.entries(params)) {
|
|
314
|
+
if (value != null && value !== '')
|
|
315
|
+
search.set(key, String(value));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const limit = Number(search.get('limit'));
|
|
319
|
+
return optionsBatchToken(opt[1], search.get('field') || 'id', search.get('q') || search.get('search') || '', Number.isFinite(limit) && limit > 0 ? limit : undefined, search.get('filter_value') || undefined);
|
|
320
|
+
}
|
|
321
|
+
function batchForbidden(message) {
|
|
322
|
+
return /forbidden|permiso/i.test(message || '');
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* GET through the batch when the URL is a list, table metadata, modal metadata
|
|
326
|
+
* or options read. Anything the batch refuses falls back to the original GET.
|
|
327
|
+
*/
|
|
328
|
+
export async function batchGet(api, url, config) {
|
|
329
|
+
const token = tokenForGet(url, config?.params);
|
|
330
|
+
if (!token)
|
|
331
|
+
return api.get(url, config);
|
|
332
|
+
try {
|
|
333
|
+
const part = await loadQueryPart(api, token);
|
|
334
|
+
if (!part.success) {
|
|
335
|
+
if (batchForbidden(part.message)) {
|
|
336
|
+
const err = new Error(part.message || 'forbidden');
|
|
337
|
+
err.status = 403;
|
|
338
|
+
err.response = { status: 403 };
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
return api.get(url, config);
|
|
342
|
+
}
|
|
343
|
+
return { data: { success: true, data: part.data, meta: part.meta } };
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
const status = err?.status
|
|
347
|
+
?? err?.response?.status;
|
|
348
|
+
if (status === 403)
|
|
349
|
+
throw err;
|
|
350
|
+
return api.get(url, config);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -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
|
+
})
|