@asteby/metacore-runtime-react 39.2.8 → 39.2.10

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +50 -6
  4. package/dist/calendar-date.d.ts +4 -0
  5. package/dist/calendar-date.d.ts.map +1 -0
  6. package/dist/calendar-date.js +18 -0
  7. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  8. package/dist/dialogs/dynamic-record.js +6 -1
  9. package/dist/dynamic-columns.d.ts.map +1 -1
  10. package/dist/dynamic-columns.js +3 -1
  11. package/dist/dynamic-date-field.d.ts.map +1 -1
  12. package/dist/dynamic-date-field.js +2 -12
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/permissions-manager.d.ts +9 -0
  17. package/dist/permissions-manager.d.ts.map +1 -1
  18. package/dist/permissions-manager.js +40 -14
  19. package/dist/query-batch.d.ts +16 -2
  20. package/dist/query-batch.d.ts.map +1 -1
  21. package/dist/query-batch.js +66 -20
  22. package/dist/use-options-resolver.d.ts +6 -0
  23. package/dist/use-options-resolver.d.ts.map +1 -1
  24. package/dist/use-options-resolver.js +16 -2
  25. package/dist/validation-catalog.d.ts.map +1 -1
  26. package/dist/validation-catalog.js +2 -0
  27. package/package.json +3 -3
  28. package/src/__tests__/calendar-date.test.ts +22 -0
  29. package/src/__tests__/options-cache-invalidation.test.tsx +127 -0
  30. package/src/__tests__/permissions-manager.test.tsx +73 -0
  31. package/src/api-context.tsx +64 -6
  32. package/src/calendar-date.ts +16 -0
  33. package/src/dialogs/dynamic-record.tsx +6 -1
  34. package/src/dynamic-columns.tsx +3 -1
  35. package/src/dynamic-date-field.tsx +2 -10
  36. package/src/index.ts +2 -0
  37. package/src/permissions-manager.tsx +58 -19
  38. package/src/query-batch.test.ts +78 -1
  39. package/src/query-batch.ts +66 -17
  40. package/src/use-options-resolver.ts +17 -2
  41. package/src/validation-catalog.ts +2 -0
@@ -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
- /** A concrete `t:` `in:` list. `@` references stay whole: the ids are not known yet. */
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
- if (hit.row)
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
- /** Stores one row per requested id. An id with no row is remembered as empty. */
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 seen = new Set();
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[parsed.field] ?? '').trim();
83
- if (!id)
84
- continue;
85
- seen.add(id);
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 ids) {
93
- if (seen.has(id))
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
- if (cached?.part.etag)
196
- inm[part.name] = cached.part.etag;
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;AAoBD,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,CA4JzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
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,CA2CtE,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"}
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.8",
3
+ "version": "39.2.10",
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.2",
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.2",
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,127 @@
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, the /q read batch nor a toast in the bell 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
+ await hostWrite('post', '/notifications/me')
88
+ })
89
+ view.unmount()
90
+ render(
91
+ <ApiProvider client={client}>
92
+ <Picker />
93
+ </ApiProvider>,
94
+ )
95
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
96
+ expect(client.get.mock.calls.length).toBe(calls)
97
+ })
98
+
99
+ it('a write through useApi() drops the cached options', async () => {
100
+ const brands = ['A']
101
+ const { client } = hostClient(brands)
102
+ let write: (() => Promise<unknown>) | null = null
103
+ function Writer() {
104
+ const api = useApi()
105
+ write = () => api.post('/data/brands', { name: 'B' })
106
+ return null
107
+ }
108
+ const view = render(
109
+ <ApiProvider client={{ ...client, interceptors: undefined } as never}>
110
+ <Picker />
111
+ <Writer />
112
+ </ApiProvider>,
113
+ )
114
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A'))
115
+ brands.push('B')
116
+ await act(async () => {
117
+ await write!()
118
+ })
119
+ view.unmount()
120
+ render(
121
+ <ApiProvider client={{ ...client, interceptors: undefined } as never}>
122
+ <Picker />
123
+ </ApiProvider>,
124
+ )
125
+ await waitFor(() => expect(screen.getByTestId('opts').textContent).toBe('A,B'))
126
+ })
127
+ })
@@ -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)
@@ -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,63 @@ 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
+ // POSTs that do not change any record a list or picker shows: the read batch
59
+ // (/q) and the bell inbox the host writes on every control toast
60
+ // (/notifications/me). Counting them as writes dropped the caches on each read
61
+ // or toast.
62
+ const NOT_A_RECORD_WRITE = [/(^|\/)q\/?(\?|$)/, /(^|\/)notifications\/me\/?(\?|$)/]
63
+
64
+ function isWrite(method: string | undefined, url: string | undefined): boolean {
65
+ const m = (method ?? '').toLowerCase()
66
+ if (m === '' || m === 'get' || m === 'head' || m === 'options') return false
67
+ const u = url ?? ''
68
+ return !NOT_A_RECORD_WRITE.some((re) => re.test(u))
69
+ }
70
+
71
+ /** Drops the batch rows and the picker options the runtime remembered. */
72
+ function forgetRemembered(): void {
73
+ invalidateQueryBatchData()
74
+ invalidateOptionsCache()
75
+ }
76
+
77
+ function mutating<T>(request: Promise<T>): Promise<T> {
78
+ forgetRemembered()
79
+ return request.finally(forgetRemembered)
80
+ }
81
+
27
82
  /** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
28
83
  export function useApi(): ApiClient {
29
84
  const ctx = useContext(ApiContext)
@@ -31,12 +86,15 @@ export function useApi(): ApiClient {
31
86
  throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
32
87
  }
33
88
  // List, metadata and options GETs share one POST /api/q. Mutations stay
34
- // on the host client. The wrapper is stable while the host client is.
89
+ // on the host client and, settled either way, drop the rows the batch
90
+ // remembered, so the refresh after a save reads the saved row. The
91
+ // wrapper is stable while the host client is.
35
92
  return useMemo<ApiClient>(() => ({
36
93
  get: (url, config) => batchGet(ctx, url, config),
37
- post: (url, body, config) => ctx.post(url, body, config),
38
- put: (url, body, config) => ctx.put(url, body, config),
39
- delete: (url, config) => ctx.delete(url, config),
94
+ post: (url, body, config) =>
95
+ isWrite('post', url) ? mutating(ctx.post(url, body, config)) : ctx.post(url, body, config),
96
+ put: (url, body, config) => mutating(ctx.put(url, body, config)),
97
+ delete: (url, config) => mutating(ctx.delete(url, config)),
40
98
  }), [ctx])
41
99
  }
42
100
 
@@ -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
- const dateValue = value ? (typeof value === 'string' ? parseISO(value) : new Date(value)) : undefined
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 =
@@ -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
- return { display: format(date, 'PPP', { locale }) }
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
  /**