@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 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":"AAkEA,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"}
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"}
@@ -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
- const isActions = header.id === 'actions';
266
- 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));
267
- }) }, 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
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;;;;;;OAMG;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,CAeZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CACxC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,OAAO,GAAE,8BAAmC,GAC7C,IAAI,CAON"}
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 removed = useMetadataCache
76
- .getState()
77
- .invalidateAddon(addonKey, matcher);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "29.0.0",
3
+ "version": "29.0.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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'))
@@ -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 removed = useMetadataCache
132
- .getState()
133
- .invalidateAddon(addonKey, matcher)
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
  )