@asteby/metacore-runtime-react 29.0.0 → 29.0.2
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 +27 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +43 -4
- package/dist/manifest-hotswap-subscriber.d.ts +3 -1
- package/dist/manifest-hotswap-subscriber.d.ts.map +1 -1
- package/dist/manifest-hotswap-subscriber.js +19 -3
- package/package.json +1 -1
- package/src/__tests__/manifest-hotswap-subscriber.test.ts +30 -0
- package/src/dynamic-relation.tsx +44 -0
- package/src/manifest-hotswap-subscriber.ts +22 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 29.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c0c8cb2: fix(hotswap): al recibir `ADDON_MANIFEST_CHANGED`, si la invalidación scopeada
|
|
8
|
+
no elimina ninguna entrada (el caso real: la metadata se cachea por nombre de
|
|
9
|
+
TABLA — `purchase_order_items`, `suppliers` — que no lleva el prefijo del
|
|
10
|
+
addon), se limpia TODO el cache de metadata como fallback. Antes, actualizar un
|
|
11
|
+
addon dejaba el label/ref viejo en el store persistido y el usuario tenía que
|
|
12
|
+
borrar datos del sitio a mano para ver la metadata fresca. Un cambio de manifest
|
|
13
|
+
es infrecuente (install/upgrade), así que el refetch perezoso en el próximo
|
|
14
|
+
montaje es barato y correcto.
|
|
15
|
+
|
|
16
|
+
## 29.0.1
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- be357ff: fix(runtime-react): las sub-tablas de relación resuelven las columnas `ref` al
|
|
21
|
+
nombre del registro en vez del uuid crudo.
|
|
22
|
+
|
|
23
|
+
`OneToManyRelation` construía sus columnas sin precargar las opciones de las
|
|
24
|
+
columnas `ref` (a diferencia de `<DynamicTable>`, que las mete en `OptionsContext`),
|
|
25
|
+
así que una celda `ref` (p.ej. el Producto de un renglón de orden de compra)
|
|
26
|
+
mostraba el uuid. Ahora la sub-tabla precarga `/api/options/<Model>` de sus
|
|
27
|
+
columnas `useOptions && searchEndpoint` y las provee vía `OptionsContext.Provider`,
|
|
28
|
+
igual que la tabla principal — el chip muestra "Nombre / SKU".
|
|
29
|
+
|
|
3
30
|
## 29.0.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-relation.d.ts","sourceRoot":"","sources":["../src/dynamic-relation.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dynamic-relation.d.ts","sourceRoot":"","sources":["../src/dynamic-relation.tsx"],"names":[],"mappings":"AAmEA,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AACrE,OAAO,EACH,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,aAAa,EACb,wBAAwB,EACxB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACjB,MAAM,4BAA4B,CAAA;AAEnC,MAAM,WAAW,sBAAsB;IACnC,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,wBAAwB,EAAE,MAAM,CAAA;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,CAAA;IACzB,uBAAuB,EAAE,MAAM,CAAA;IAC/B,WAAW,EAAE,MAAM,CAAA;IACnB,oDAAoD;IACpD,iBAAiB,EAAE,MAAM,CAAA;IACzB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAA;CACrB;AAwBD,UAAU,WAAW;IACjB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;IACzB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;;;;;;;OAUG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,uCAAuC;IACvC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,2BAA2B;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAA;IACzC,yBAAyB;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,6BAA8B,SAAQ,WAAW;IAC9D,IAAI,EAAE,aAAa,CAAA;IACnB,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAA;IACb,kDAAkD;IAClD,UAAU,EAAE,MAAM,CAAA;IAClB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,8BAA+B,SAAQ,WAAW;IAC/D,IAAI,EAAE,cAAc,CAAA;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAA;IAClB,6BAA6B;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,MAAM,oBAAoB,GAC1B,6BAA6B,GAC7B,8BAA8B,CAAA;AAEpC,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,+BAK1D"}
|
package/dist/dynamic-relation.js
CHANGED
|
@@ -12,6 +12,7 @@ import { Button, Skeleton, AlertDialog, AlertDialogAction, AlertDialogCancel, Al
|
|
|
12
12
|
import { Plus, Trash2, Pencil, Search } from 'lucide-react';
|
|
13
13
|
import { useApi } from './api-context';
|
|
14
14
|
import { useMetadataCache } from './metadata-cache';
|
|
15
|
+
import { OptionsContext } from './options-context';
|
|
15
16
|
import { DynamicForm } from './dynamic-form';
|
|
16
17
|
import { useImageUrl } from './image-url-context';
|
|
17
18
|
import { useTimeZone, useCurrency } from './org-runtime-context';
|
|
@@ -65,6 +66,11 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
65
66
|
const cachedMeta = getMetadata(model);
|
|
66
67
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) };
|
|
67
68
|
const [metadata, setMetadata] = useState(cachedMeta || null);
|
|
69
|
+
// Options for the ref/relation columns so their cells render the record's
|
|
70
|
+
// NAME (+ SKU subtitle) instead of the raw uuid. The main <DynamicTable>
|
|
71
|
+
// preloads these into OptionsContext; a sub-table skipped it, so a `ref`
|
|
72
|
+
// column (e.g. a purchase order line's Product) showed the bare uuid.
|
|
73
|
+
const [optionsMap, setOptionsMap] = useState(new Map());
|
|
68
74
|
const [rows, setRows] = useState([]);
|
|
69
75
|
const [loading, setLoading] = useState(true);
|
|
70
76
|
// Paginación server-side + scroll infinito, igual que <DynamicTable>.
|
|
@@ -94,6 +100,39 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
94
100
|
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
|
95
101
|
return () => clearTimeout(id);
|
|
96
102
|
}, [search]);
|
|
103
|
+
// Preload the options of the ref/relation columns (same shape <DynamicTable>
|
|
104
|
+
// uses: `useOptions && searchEndpoint`) so their cells resolve the referenced
|
|
105
|
+
// record's name via OptionsContext instead of printing the raw uuid.
|
|
106
|
+
const refEndpointsKey = useMemo(() => {
|
|
107
|
+
const eps = (metadata?.columns || [])
|
|
108
|
+
.filter((c) => c.useOptions && c.searchEndpoint)
|
|
109
|
+
.map((c) => c.searchEndpoint);
|
|
110
|
+
return Array.from(new Set(eps)).sort().join('|');
|
|
111
|
+
}, [metadata]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!refEndpointsKey) {
|
|
114
|
+
setOptionsMap((prev) => (prev.size ? new Map() : prev));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const endpoints = refEndpointsKey.split('|');
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
void Promise.all(endpoints.map(async (ep) => {
|
|
120
|
+
try {
|
|
121
|
+
const res = (await api.get(ep));
|
|
122
|
+
return [ep, res.data?.success ? res.data.data || [] : []];
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return [ep, []];
|
|
126
|
+
}
|
|
127
|
+
})).then((entries) => {
|
|
128
|
+
if (!cancelled)
|
|
129
|
+
setOptionsMap(new Map(entries));
|
|
130
|
+
});
|
|
131
|
+
return () => {
|
|
132
|
+
cancelled = true;
|
|
133
|
+
};
|
|
134
|
+
}, [api, refEndpointsKey]);
|
|
135
|
+
const optionsCtx = useMemo(() => ({ optionsMap }), [optionsMap]);
|
|
97
136
|
// fetchPage REEMPLAZA (página 1) o ANEXA (scroll infinito). Antes esta lista
|
|
98
137
|
// pedía el modelo hijo entero sin page/per_page.
|
|
99
138
|
const fetchPage = useCallback(async (nextPage, append) => {
|
|
@@ -261,10 +300,10 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
261
300
|
setSubmitting(false);
|
|
262
301
|
}
|
|
263
302
|
}, [api, dataEndpoint, fetchAll, onChange, rowToDelete]);
|
|
264
|
-
return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-model": model, children: [(labels.title || canCreate || showSearch) && (_jsxs("div", { className: "flex items-center justify-between gap-2 pb-3", children: [labels.title ? _jsx("h3", { className: "text-sm font-medium", children: labels.title }) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [showSearch && (_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "absolute start-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: search, onChange: (e) => setSearch(e.target.value), placeholder: labels.searchPlaceholder, className: "h-8 w-40 ps-7 sm:w-56" })] })), canCreate && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => { setEditingRow(null); setFormOpen(true); }, children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), labels.addLabel] }))] })] })), loading ? (_jsx("div", { className: "space-y-2", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Skeleton, { className: "h-10 w-full" }, `rel-skeleton-${i}`))) })) : rows.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsxs("div", { ref: rootRef, className: "overflow-auto max-h-[60vh] border rounded-md bg-card", children: [_jsxs(Table, { noWrapper: true, className: "w-full", children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: "border-b-0 hover:bg-transparent", children: headerGroup.headers.map((header) => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
303
|
+
return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-model": model, children: [(labels.title || canCreate || showSearch) && (_jsxs("div", { className: "flex items-center justify-between gap-2 pb-3", children: [labels.title ? _jsx("h3", { className: "text-sm font-medium", children: labels.title }) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [showSearch && (_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "absolute start-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: search, onChange: (e) => setSearch(e.target.value), placeholder: labels.searchPlaceholder, className: "h-8 w-40 ps-7 sm:w-56" })] })), canCreate && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => { setEditingRow(null); setFormOpen(true); }, children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), labels.addLabel] }))] })] })), loading ? (_jsx("div", { className: "space-y-2", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Skeleton, { className: "h-10 w-full" }, `rel-skeleton-${i}`))) })) : rows.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsx(OptionsContext.Provider, { value: optionsCtx, children: _jsxs("div", { ref: rootRef, className: "overflow-auto max-h-[60vh] border rounded-md bg-card", children: [_jsxs(Table, { noWrapper: true, className: "w-full", children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: "border-b-0 hover:bg-transparent", children: headerGroup.headers.map((header) => {
|
|
304
|
+
const isActions = header.id === 'actions';
|
|
305
|
+
return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActions && 'text-right'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
|
|
306
|
+
}) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row, idx) => (_jsx(TableRow, { children: row.getVisibleCells().map((cell) => (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: "py-2", children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, relationRowKey(row.original, idx, foreignKey)))) })] }), loadingMore && (_jsx("div", { className: "p-2", children: _jsx(Skeleton, { className: "h-8 w-full" }) })), _jsx("div", { ref: sentinelRef, "aria-hidden": true, className: "h-px w-full" })] }) })), !loading && rows.length > 0 && total > rows.length && (_jsx("div", { className: "pt-2 text-end text-xs text-muted-foreground", children: labels.countLabel
|
|
268
307
|
.replace('{{loaded}}', String(rows.length))
|
|
269
308
|
.replace('{{total}}', String(total)) })), _jsx(Dialog, { open: formOpen, onOpenChange: (open) => { setFormOpen(open); if (!open)
|
|
270
309
|
setEditingRow(null); }, children: _jsxs(DialogContent, { children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: editingRow ? labels.editLabel : labels.addLabel }) }), _jsx(DynamicForm, { fields: formFields, initialValues: editingRow || undefined, onSubmit: handleSubmit, onCancel: () => { setFormOpen(false); setEditingRow(null); }, submitLabel: labels.saveLabel, cancelLabel: labels.cancelLabel, disabled: submitting })] }) }), _jsx(AlertDialog, { open: !!rowToDelete, onOpenChange: (open) => !open && setRowToDelete(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: labels.confirmRemoveTitle }), _jsx(AlertDialogDescription, { children: labels.confirmRemoveDescription })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: submitting, children: labels.cancelLabel }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); handleDelete(); }, className: "bg-red-600 hover:bg-red-700", disabled: submitting, children: labels.removeLabel })] })] }) })] }));
|
|
@@ -45,7 +45,9 @@ export interface WireHotSwapInvalidationOptions {
|
|
|
45
45
|
* Useful for hosts that want to log/observe hot-swaps or trigger a
|
|
46
46
|
* `window.location.reload()` when the running addon's bundle hash
|
|
47
47
|
* changes (see the module-level comment above for the trade-off).
|
|
48
|
-
* `removed` is the number of cache entries flushed for this addon
|
|
48
|
+
* `removed` is the number of cache entries flushed for this addon, or
|
|
49
|
+
* `-1` when the scoped matcher found nothing and the whole metadata
|
|
50
|
+
* cache was cleared as a fallback (see `wireHotSwapInvalidation`).
|
|
49
51
|
*/
|
|
50
52
|
onSwap?: (msg: AddonManifestChangedMessage, removed: number) => void;
|
|
51
53
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-hotswap-subscriber.d.ts","sourceRoot":"","sources":["../src/manifest-hotswap-subscriber.ts"],"names":[],"mappings":"AA6CA,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEzE;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,EAAG,wBAAiC,CAAA;AAE5E;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IACxC,IAAI,EAAE,OAAO,2BAA2B,CAAA;IACxC,OAAO,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,MAAM,CAAA;KACrB,CAAA;CACJ;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IAClC,SAAS,EAAE,CACP,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,CAAC,OAAO,EAAE,2BAA2B,KAAK,IAAI,KACtD,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC3C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB
|
|
1
|
+
{"version":3,"file":"manifest-hotswap-subscriber.d.ts","sourceRoot":"","sources":["../src/manifest-hotswap-subscriber.ts"],"names":[],"mappings":"AA6CA,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEzE;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,EAAG,wBAAiC,CAAA;AAE5E;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IACxC,IAAI,EAAE,OAAO,2BAA2B,CAAA;IACxC,OAAO,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,MAAM,CAAA;KACrB,CAAA;CACJ;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IAClC,SAAS,EAAE,CACP,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,CAAC,OAAO,EAAE,2BAA2B,KAAK,IAAI,KACtD,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC3C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,2BAA2B,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACvE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACnC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,OAAO,GAAE,8BAAmC,GAC7C,MAAM,IAAI,CA+BZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CACxC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,OAAO,GAAE,8BAAmC,GAC7C,IAAI,CAON"}
|
|
@@ -72,9 +72,25 @@ export function wireHotSwapInvalidation(client, options = {}) {
|
|
|
72
72
|
const addonKey = message?.payload?.addonKey;
|
|
73
73
|
if (!addonKey)
|
|
74
74
|
return;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
const store = useMetadataCache.getState();
|
|
76
|
+
let removed = store.invalidateAddon(addonKey, matcher);
|
|
77
|
+
// Matcher-miss fallback. `invalidateAddon`'s default matcher only
|
|
78
|
+
// recognises cache keys that carry the addon prefix
|
|
79
|
+
// (`purchases`, `purchases.Model`, `purchases:Model`). But most
|
|
80
|
+
// hosts cache metadata under the model's TABLE name
|
|
81
|
+
// (`purchase_order_items`, `suppliers`) — which has no `purchases.`
|
|
82
|
+
// prefix — so a scoped invalidation removes 0 entries and the
|
|
83
|
+
// stale label/ref survives (the "why did I have to clear site
|
|
84
|
+
// data" bug). A manifest change is rare (addon install/upgrade),
|
|
85
|
+
// so when scoping matched nothing we drop the WHOLE metadata cache:
|
|
86
|
+
// correctness over a micro-optimisation. Entries refetch lazily on
|
|
87
|
+
// the next DynamicTable/relation mount, picking up the new
|
|
88
|
+
// fields/refs/labels. Hosts that DO namespace their keys keep the
|
|
89
|
+
// cheap scoped path (removed > 0).
|
|
90
|
+
if (removed === 0) {
|
|
91
|
+
store.clearAll();
|
|
92
|
+
removed = -1; // sentinel: "cleared all" (distinct from 0 = no-op)
|
|
93
|
+
}
|
|
78
94
|
onSwap?.(message, removed);
|
|
79
95
|
});
|
|
80
96
|
return unsubscribe;
|
package/package.json
CHANGED
|
@@ -118,6 +118,36 @@ describe('manifest-hotswap-subscriber', () => {
|
|
|
118
118
|
expect(onSwap).toHaveBeenCalledWith(message, 2)
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
it('clears the WHOLE cache when the scoped matcher finds nothing (table-named keys)', () => {
|
|
122
|
+
// Real-world case: metadata is cached under TABLE names, which carry no
|
|
123
|
+
// addon prefix. `invalidateAddon('purchases')` matches none of them, so
|
|
124
|
+
// without the fallback the stale label/ref would survive an upgrade.
|
|
125
|
+
const cache = useMetadataCache.getState()
|
|
126
|
+
cache.setMetadata('purchase_order_items', fakeMeta('items-old'))
|
|
127
|
+
cache.setMetadata('suppliers', fakeMeta('suppliers-old'))
|
|
128
|
+
cache.setModalMetadata('purchase_orders', fakeMeta('po-old'))
|
|
129
|
+
|
|
130
|
+
const onSwap = vi.fn()
|
|
131
|
+
const client = makeFakeClient()
|
|
132
|
+
wireHotSwapInvalidation(client, { onSwap })
|
|
133
|
+
|
|
134
|
+
const message: AddonManifestChangedMessage = {
|
|
135
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
136
|
+
payload: { addonKey: 'purchases', newHash: 'sha256:new' },
|
|
137
|
+
}
|
|
138
|
+
client.emit(message)
|
|
139
|
+
|
|
140
|
+
const after = useMetadataCache.getState()
|
|
141
|
+
// Everything dropped — next mount refetches fresh metadata.
|
|
142
|
+
expect(after.cache).toEqual({})
|
|
143
|
+
expect(after.modalCache).toEqual({})
|
|
144
|
+
expect(after.prefetched).toBe(false)
|
|
145
|
+
// metadataVersion reset so prefetchAll re-seeds from the server.
|
|
146
|
+
expect(after.metadataVersion).toBe('')
|
|
147
|
+
// Sentinel -1 tells the host "cleared all" (vs 0 = genuine no-op).
|
|
148
|
+
expect(onSwap).toHaveBeenCalledWith(message, -1)
|
|
149
|
+
})
|
|
150
|
+
|
|
121
151
|
it('honours a custom matcher when the host uses a different key convention', () => {
|
|
122
152
|
const cache = useMetadataCache.getState()
|
|
123
153
|
cache.setMetadata('addons/pos/cart', fakeMeta('cart'))
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
import { Plus, Trash2, Pencil, Search } from 'lucide-react'
|
|
44
44
|
import { useApi } from './api-context'
|
|
45
45
|
import { useMetadataCache } from './metadata-cache'
|
|
46
|
+
import { OptionsContext } from './options-context'
|
|
46
47
|
import { DynamicForm } from './dynamic-form'
|
|
47
48
|
import { useImageUrl } from './image-url-context'
|
|
48
49
|
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
@@ -244,6 +245,11 @@ function OneToManyRelation({
|
|
|
244
245
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
|
|
245
246
|
|
|
246
247
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
248
|
+
// Options for the ref/relation columns so their cells render the record's
|
|
249
|
+
// NAME (+ SKU subtitle) instead of the raw uuid. The main <DynamicTable>
|
|
250
|
+
// preloads these into OptionsContext; a sub-table skipped it, so a `ref`
|
|
251
|
+
// column (e.g. a purchase order line's Product) showed the bare uuid.
|
|
252
|
+
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
247
253
|
const [rows, setRows] = useState<any[]>([])
|
|
248
254
|
const [loading, setLoading] = useState(true)
|
|
249
255
|
// Paginación server-side + scroll infinito, igual que <DynamicTable>.
|
|
@@ -277,6 +283,42 @@ function OneToManyRelation({
|
|
|
277
283
|
return () => clearTimeout(id)
|
|
278
284
|
}, [search])
|
|
279
285
|
|
|
286
|
+
// Preload the options of the ref/relation columns (same shape <DynamicTable>
|
|
287
|
+
// uses: `useOptions && searchEndpoint`) so their cells resolve the referenced
|
|
288
|
+
// record's name via OptionsContext instead of printing the raw uuid.
|
|
289
|
+
const refEndpointsKey = useMemo(() => {
|
|
290
|
+
const eps = (metadata?.columns || [])
|
|
291
|
+
.filter((c) => (c as any).useOptions && (c as any).searchEndpoint)
|
|
292
|
+
.map((c) => (c as any).searchEndpoint as string)
|
|
293
|
+
return Array.from(new Set(eps)).sort().join('|')
|
|
294
|
+
}, [metadata])
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (!refEndpointsKey) {
|
|
298
|
+
setOptionsMap((prev) => (prev.size ? new Map() : prev))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const endpoints = refEndpointsKey.split('|')
|
|
302
|
+
let cancelled = false
|
|
303
|
+
void Promise.all(
|
|
304
|
+
endpoints.map(async (ep) => {
|
|
305
|
+
try {
|
|
306
|
+
const res = (await api.get(ep)) as { data: ApiResponse<any[]> }
|
|
307
|
+
return [ep, res.data?.success ? res.data.data || [] : []] as const
|
|
308
|
+
} catch {
|
|
309
|
+
return [ep, [] as any[]] as const
|
|
310
|
+
}
|
|
311
|
+
}),
|
|
312
|
+
).then((entries) => {
|
|
313
|
+
if (!cancelled) setOptionsMap(new Map(entries))
|
|
314
|
+
})
|
|
315
|
+
return () => {
|
|
316
|
+
cancelled = true
|
|
317
|
+
}
|
|
318
|
+
}, [api, refEndpointsKey])
|
|
319
|
+
|
|
320
|
+
const optionsCtx = useMemo(() => ({ optionsMap }), [optionsMap])
|
|
321
|
+
|
|
280
322
|
// fetchPage REEMPLAZA (página 1) o ANEXA (scroll infinito). Antes esta lista
|
|
281
323
|
// pedía el modelo hijo entero sin page/per_page.
|
|
282
324
|
const fetchPage = useCallback(async (nextPage: number, append: boolean) => {
|
|
@@ -522,6 +564,7 @@ function OneToManyRelation({
|
|
|
522
564
|
// `max-h` + scroll propio: la sub-tabla no puede estirar el modal
|
|
523
565
|
// del padre a lo alto de cientos de filas. El sentinel del fondo
|
|
524
566
|
// pide la página siguiente al entrar en vista.
|
|
567
|
+
<OptionsContext.Provider value={optionsCtx}>
|
|
525
568
|
<div ref={rootRef} className="overflow-auto max-h-[60vh] border rounded-md bg-card">
|
|
526
569
|
<Table noWrapper className="w-full">
|
|
527
570
|
<TableHeader>
|
|
@@ -566,6 +609,7 @@ function OneToManyRelation({
|
|
|
566
609
|
)}
|
|
567
610
|
<div ref={sentinelRef} aria-hidden className="h-px w-full" />
|
|
568
611
|
</div>
|
|
612
|
+
</OptionsContext.Provider>
|
|
569
613
|
)}
|
|
570
614
|
|
|
571
615
|
{/* Cuánto de la relación se ve. Sin esto una sub-tabla paginada
|
|
@@ -97,7 +97,9 @@ export interface WireHotSwapInvalidationOptions {
|
|
|
97
97
|
* Useful for hosts that want to log/observe hot-swaps or trigger a
|
|
98
98
|
* `window.location.reload()` when the running addon's bundle hash
|
|
99
99
|
* changes (see the module-level comment above for the trade-off).
|
|
100
|
-
* `removed` is the number of cache entries flushed for this addon
|
|
100
|
+
* `removed` is the number of cache entries flushed for this addon, or
|
|
101
|
+
* `-1` when the scoped matcher found nothing and the whole metadata
|
|
102
|
+
* cache was cleared as a fallback (see `wireHotSwapInvalidation`).
|
|
101
103
|
*/
|
|
102
104
|
onSwap?: (msg: AddonManifestChangedMessage, removed: number) => void
|
|
103
105
|
}
|
|
@@ -128,9 +130,25 @@ export function wireHotSwapInvalidation(
|
|
|
128
130
|
(message) => {
|
|
129
131
|
const addonKey = message?.payload?.addonKey
|
|
130
132
|
if (!addonKey) return
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
const store = useMetadataCache.getState()
|
|
134
|
+
let removed = store.invalidateAddon(addonKey, matcher)
|
|
135
|
+
// Matcher-miss fallback. `invalidateAddon`'s default matcher only
|
|
136
|
+
// recognises cache keys that carry the addon prefix
|
|
137
|
+
// (`purchases`, `purchases.Model`, `purchases:Model`). But most
|
|
138
|
+
// hosts cache metadata under the model's TABLE name
|
|
139
|
+
// (`purchase_order_items`, `suppliers`) — which has no `purchases.`
|
|
140
|
+
// prefix — so a scoped invalidation removes 0 entries and the
|
|
141
|
+
// stale label/ref survives (the "why did I have to clear site
|
|
142
|
+
// data" bug). A manifest change is rare (addon install/upgrade),
|
|
143
|
+
// so when scoping matched nothing we drop the WHOLE metadata cache:
|
|
144
|
+
// correctness over a micro-optimisation. Entries refetch lazily on
|
|
145
|
+
// the next DynamicTable/relation mount, picking up the new
|
|
146
|
+
// fields/refs/labels. Hosts that DO namespace their keys keep the
|
|
147
|
+
// cheap scoped path (removed > 0).
|
|
148
|
+
if (removed === 0) {
|
|
149
|
+
store.clearAll()
|
|
150
|
+
removed = -1 // sentinel: "cleared all" (distinct from 0 = no-op)
|
|
151
|
+
}
|
|
134
152
|
onSwap?.(message, removed)
|
|
135
153
|
},
|
|
136
154
|
)
|