@asteby/metacore-runtime-react 49.5.0 → 49.7.0

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 (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. package/src/business/use-async-search.ts +0 -43
@@ -0,0 +1,372 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // RecordPicker — THE "pick a related record" control of the runtime.
3
+ //
4
+ // Before it, every surface carried its own picker: the declarative
5
+ // DynamicSelectField, the hand-written EntitySelect, the business
6
+ // Customer/Product/Vehicle pickers, the DocumentEditor's product cell, the
7
+ // legacy `search` field of the generic modal and the multi-select of `ref`
8
+ // arrays — each with its own search loop, list markup, keyboard handling and
9
+ // create button. They are now thin configurations of this one primitive:
10
+ //
11
+ // - data: caller-owned `items` (options resolver, static list, a search the
12
+ // caller drives) or a `search(q, signal)` fetcher run here with debounce,
13
+ // cancellation and a min-chars gate; loading / empty / error states;
14
+ // - selection: single or `multiple` (chips with remove);
15
+ // - create / edit: "+" joined to the trigger when empty, pencil when a value
16
+ // is selected, "Crear …" at the foot of the list prefilled with the search
17
+ // (record-picker-actions.tsx);
18
+ // - rich options by props/slots: lead (avatar / initials / thumbnail),
19
+ // subtitle (SKU, RFC…), trailing (price / stock), or a full `renderItem`;
20
+ // - presentation: `trigger="button"` (select-like, search inside the list)
21
+ // or `trigger="input"` (type in the field itself; `freeText` keeps the text
22
+ // as the value, e.g. a free invoice line), `variant="field" | "cell"`;
23
+ // - the list is ALWAYS portaled (never clipped by a table / modal overflow),
24
+ // collision-aware (flips when there is no room) and stacked above dialogs;
25
+ // - keyboard: ↑/↓/Home/End move, Enter picks, Esc closes (without closing the
26
+ // dialog around it), Tab closes and moves on; ARIA combobox + listbox with
27
+ // aria-activedescendant (focus never leaves the text box).
28
+ //
29
+ // Built only on @asteby/metacore-ui primitives (Popover, Button, Input, Badge),
30
+ // so it themes (light / dark) with the host.
31
+ import { useCallback, useEffect, useId, useRef, useState, } from 'react';
32
+ import { Check, ChevronsUpDown, Loader2, Plus, Search, X } from 'lucide-react';
33
+ import { Badge, Button, Input, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
34
+ import { JOINED_TRIGGER_CLASS, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions';
35
+ import { useDebouncedValue } from './use-debounced-value';
36
+ /**
37
+ * z-index of the portaled list. Dialogs/sheets sit at 50; the list must stay
38
+ * above the modal it was opened from regardless of portal order.
39
+ */
40
+ export const RECORD_PICKER_Z_INDEX = 60;
41
+ const cx = (...parts) => parts.filter(Boolean).join(' ');
42
+ /** Debounced async search with cancellation. `search` must be stable (useCallback). */
43
+ export function useRecordSearch(query, search, opts = {}) {
44
+ const { minChars = 2, delay = 250, enabled = true } = opts;
45
+ const trimmed = query.trim();
46
+ const q = useDebouncedValue(trimmed, delay);
47
+ const [results, setResults] = useState([]);
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState(false);
50
+ // The query the current results answer. Until it matches what was typed
51
+ // (debounce running, or the request not answered yet) the search is
52
+ // `pending` — so a list never flashes "no results" before searching.
53
+ const [doneFor, setDoneFor] = useState(null);
54
+ useEffect(() => {
55
+ if (!enabled || q.length < minChars) {
56
+ setResults((r) => (r.length ? [] : r));
57
+ setLoading(false);
58
+ setError(false);
59
+ setDoneFor(null);
60
+ return;
61
+ }
62
+ const ctrl = new AbortController();
63
+ setLoading(true);
64
+ setError(false);
65
+ search(q, ctrl.signal)
66
+ .then((rows) => {
67
+ if (!ctrl.signal.aborted)
68
+ setResults(rows);
69
+ })
70
+ .catch(() => {
71
+ if (!ctrl.signal.aborted) {
72
+ setResults([]);
73
+ setError(true);
74
+ }
75
+ })
76
+ .finally(() => {
77
+ if (!ctrl.signal.aborted) {
78
+ setLoading(false);
79
+ setDoneFor(q);
80
+ }
81
+ });
82
+ return () => ctrl.abort();
83
+ }, [q, search, minChars, enabled]);
84
+ return { results, loading, error, pending: enabled && trimmed.length >= minChars && doneFor !== trimmed };
85
+ }
86
+ const NO_SEARCH = () => Promise.resolve([]);
87
+ /**
88
+ * Stable identity for a caller's search function: always calls the latest one,
89
+ * so an inline (non-memoized) fetcher doesn't re-trigger the search on every
90
+ * render.
91
+ */
92
+ export function useLatestSearch(fn) {
93
+ const ref = useRef(fn);
94
+ ref.current = fn;
95
+ return useCallback((...args) => ref.current(...args), []);
96
+ }
97
+ const TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
98
+ /** Focus the tabbable after `from` (skipping portaled popper content). */
99
+ function focusNextAfter(from) {
100
+ if (!from || typeof document === 'undefined')
101
+ return;
102
+ const all = Array.from(document.querySelectorAll(TABBABLE)).filter((el) => el.tabIndex >= 0 && !el.closest('[data-radix-popper-content-wrapper]'));
103
+ const i = all.indexOf(from);
104
+ (all[i + 1] ?? from).focus();
105
+ }
106
+ function asKeys(value, multiple) {
107
+ if (multiple)
108
+ return Array.isArray(value) ? value.map(String) : [];
109
+ if (value == null || value === '' || Array.isArray(value))
110
+ return [];
111
+ return [String(value)];
112
+ }
113
+ export function RecordPicker(props) {
114
+ const { getKey, getLabel, items, search, debounceMs = 250, multiple = false, value, selected, onSelect, onClear, onRemove, renderItem, renderLead, getDescription, renderTrailing, renderValue, isItemDisabled, placeholder = 'Seleccionar…', searchPlaceholder = 'Buscar…', emptyText = 'Sin resultados', loadingText = 'Buscando…', errorText = 'No se pudo buscar. Inténtalo de nuevo.', minCharsText, clearLabel = 'Quitar selección', listLabel, entityLabel = '', onCreate, onEdit, createLabel, editLabel, createFooter = 'always', createFooterLabel, hideJoinedAction = false, trigger = 'button', variant = 'field', freeText = false, icon: Icon, id, ariaLabel, disabled = false, readOnly = false, invalid = false, slot, anchorSlot, contentSlot = 'record-picker-content', className, triggerClassName, triggerProps, inputRef, minListWidth = '14rem', after, below, beforeList, onInputKeyDown, onUnhandledKeyDown, } = props;
115
+ const isInput = trigger === 'input';
116
+ const minChars = props.minChars ?? (isInput ? 2 : 0);
117
+ const showCheck = props.showCheck ?? !isInput;
118
+ const closeOnSelect = props.closeOnSelect ?? !multiple;
119
+ const listId = useId();
120
+ const triggerRef = useRef(null);
121
+ const anchorRef = useRef(null);
122
+ const tabbingRef = useRef(false);
123
+ // open / query: controlled when the caller passes them.
124
+ const [openState, setOpenState] = useState(false);
125
+ const open = props.open ?? openState;
126
+ const { onOpenChange, onQueryChange } = props;
127
+ const controlledOpen = props.open !== undefined;
128
+ const setOpen = useCallback((o) => {
129
+ if (!controlledOpen)
130
+ setOpenState(o);
131
+ onOpenChange?.(o);
132
+ }, [controlledOpen, onOpenChange]);
133
+ const [queryState, setQueryState] = useState('');
134
+ const query = props.query ?? queryState;
135
+ const controlledQuery = props.query !== undefined;
136
+ const setQuery = useCallback((q) => {
137
+ if (!controlledQuery)
138
+ setQueryState(q);
139
+ onQueryChange?.(q);
140
+ }, [controlledQuery, onQueryChange]);
141
+ const trimmed = query.trim();
142
+ const belowMin = trimmed.length < minChars;
143
+ const listOpen = !disabled && !readOnly && open && (!isInput || !belowMin);
144
+ const internal = useRecordSearch(query, (search ?? NO_SEARCH), {
145
+ minChars,
146
+ delay: debounceMs,
147
+ enabled: !!search && !items && open && !disabled && !readOnly,
148
+ });
149
+ const rows = items ?? internal.results;
150
+ const loading = props.loading ?? (internal.loading || internal.pending);
151
+ const error = props.error ?? internal.error;
152
+ const keys = asKeys(value, multiple);
153
+ const hasValue = keys.length > 0;
154
+ const isSelected = (item) => keys.includes(getKey(item));
155
+ const single = !multiple ? (selected ?? null) : null;
156
+ const selectedList = multiple && Array.isArray(selected) ? selected : [];
157
+ const showFooter = !!onCreate &&
158
+ !loading &&
159
+ createFooter !== 'never' &&
160
+ (createFooter === 'always' || (rows.length === 0 && !belowMin && !error));
161
+ const count = rows.length + (showFooter ? 1 : 0);
162
+ // Highlight: the selected row when (re)opening, else the first. Keyed on the
163
+ // row ids (not the array identity) so a caller recomputing `items` on every
164
+ // render doesn't reset the keyboard position.
165
+ const [active, setActive] = useState(0);
166
+ const rowSig = rows.map(getKey).join('\u0001');
167
+ useEffect(() => {
168
+ const i = rows.findIndex((r) => keys.includes(getKey(r)));
169
+ setActive(i >= 0 ? i : 0);
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [rowSig, trimmed, listOpen]);
172
+ useEffect(() => {
173
+ if (!listOpen || typeof document === 'undefined')
174
+ return;
175
+ const el = document.getElementById(`${listId}-o${active}`);
176
+ el?.scrollIntoView?.({ block: 'nearest' });
177
+ }, [active, listOpen, listId]);
178
+ const enabledAt = (i) => i === rows.length ? showFooter : !!rows[i] && !isItemDisabled?.(rows[i]);
179
+ const step = (from, dir) => {
180
+ for (let i = from + dir; i >= 0 && i < count; i += dir)
181
+ if (enabledAt(i))
182
+ return i;
183
+ return from;
184
+ };
185
+ const close = () => setOpen(false);
186
+ const runCreate = () => {
187
+ const q = trimmed;
188
+ close();
189
+ if (!freeText)
190
+ setQuery('');
191
+ onCreate?.(q);
192
+ };
193
+ const choose = (i) => {
194
+ if (i === rows.length && showFooter)
195
+ return runCreate();
196
+ const item = rows[i];
197
+ if (!item || isItemDisabled?.(item))
198
+ return;
199
+ onSelect(item);
200
+ if (closeOnSelect)
201
+ close();
202
+ if (!freeText && !multiple)
203
+ setQuery('');
204
+ };
205
+ const onKeyDown = (e) => {
206
+ if (onInputKeyDown) {
207
+ // Only a preventDefault() from the caller takes over (Radix may
208
+ // already have prevented Esc at the document level).
209
+ const before = e.defaultPrevented;
210
+ onInputKeyDown(e);
211
+ if (!before && e.defaultPrevented)
212
+ return;
213
+ }
214
+ if (listOpen && count > 0) {
215
+ if (e.key === 'ArrowDown') {
216
+ e.preventDefault();
217
+ setActive((a) => step(Math.min(a, count - 1), 1));
218
+ return;
219
+ }
220
+ if (e.key === 'ArrowUp') {
221
+ e.preventDefault();
222
+ setActive((a) => step(Math.min(a, count), -1));
223
+ return;
224
+ }
225
+ if (!isInput && (e.key === 'Home' || e.key === 'End')) {
226
+ e.preventDefault();
227
+ setActive(e.key === 'Home' ? step(-1, 1) : step(count, -1));
228
+ return;
229
+ }
230
+ if (e.key === 'Enter' && enabledAt(active)) {
231
+ e.preventDefault();
232
+ choose(active);
233
+ return;
234
+ }
235
+ }
236
+ if (isInput) {
237
+ if (listOpen && e.key === 'Escape') {
238
+ // Close the list, not the dialog around it.
239
+ e.preventDefault();
240
+ e.stopPropagation();
241
+ close();
242
+ return;
243
+ }
244
+ if (!listOpen && !freeText && e.key === 'ArrowDown' && !belowMin) {
245
+ e.preventDefault();
246
+ setOpen(true);
247
+ return;
248
+ }
249
+ if (e.key === 'Tab')
250
+ close();
251
+ }
252
+ else if (e.key === 'Tab' && !e.shiftKey) {
253
+ // Close and move on to the next field (not to the end of <body>,
254
+ // where the portaled list lives).
255
+ e.preventDefault();
256
+ tabbingRef.current = true;
257
+ close();
258
+ return;
259
+ }
260
+ onUnhandledKeyDown?.(e);
261
+ };
262
+ const activeId = listOpen && count > 0 && enabledAt(active) ? `${listId}-o${active}` : undefined;
263
+ // ---- the list (shared by both trigger modes) --------------------------
264
+ const status = (() => {
265
+ if (loading && rows.length === 0) {
266
+ return (_jsxs("div", { role: "status", className: "flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground", "data-slot": "picker-loading", children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": true }), loadingText] }));
267
+ }
268
+ if (loading)
269
+ return null;
270
+ if (error) {
271
+ return (_jsx("p", { role: "alert", className: "px-2 py-4 text-center text-sm text-destructive", "data-slot": "picker-error", children: errorText }));
272
+ }
273
+ if (rows.length > 0)
274
+ return null;
275
+ if (belowMin && minChars > 0) {
276
+ return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-6 text-muted-foreground", "data-slot": "picker-min-chars", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "text-xs", children: minCharsText ?? `Escribe al menos ${minChars} caracteres` })] }));
277
+ }
278
+ return (_jsx("div", { className: "px-2 py-6 text-center text-sm text-muted-foreground", "data-slot": "picker-empty", children: emptyText }));
279
+ })();
280
+ const optionClass = (on, dis) => cx('relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden', on && 'bg-accent text-accent-foreground', dis && 'pointer-events-none opacity-50');
281
+ const list = (_jsxs("div", { className: "max-h-72 overflow-y-auto overflow-x-hidden overscroll-contain", "data-slot": "record-picker-list", children: [beforeList, status, _jsxs("ul", { id: listId, role: "listbox", "aria-label": listLabel ?? ariaLabel ?? entityLabel ?? undefined, "aria-multiselectable": multiple || undefined, className: cx('p-1', count === 0 && 'hidden'), children: [rows.map((item, i) => {
282
+ const sel = isSelected(item);
283
+ const dis = !!isItemDisabled?.(item);
284
+ const on = i === active;
285
+ const desc = getDescription?.(item);
286
+ return (_jsx("li", { id: `${listId}-o${i}`, role: "option", "aria-selected": multiple ? sel : on, "aria-disabled": dis || undefined, "data-active": on || undefined, "data-selected": sel || undefined, "data-slot": "record-picker-option", className: optionClass(on, dis), onMouseDown: (e) => e.preventDefault(), onMouseMove: () => {
287
+ if (!on && !dis)
288
+ setActive(i);
289
+ }, onClick: () => choose(i), children: renderItem ? (renderItem(item, { active: on, selected: sel, disabled: dis })) : (_jsxs(_Fragment, { children: [showCheck ? (_jsx(Check, { className: cx('size-4 shrink-0', sel ? 'opacity-100' : 'opacity-0'), "aria-hidden": true })) : null, renderLead?.(item, 'option'), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate", children: getLabel(item) }), desc != null && desc !== '' ? (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: desc })) : null] }), renderTrailing ? (_jsx("span", { className: "ml-auto flex shrink-0 items-center gap-2", children: renderTrailing(item, 'option') })) : null] })) }, getKey(item)));
290
+ }), showFooter ? (_jsxs(_Fragment, { children: [rows.length > 0 ? _jsx("li", { role: "presentation", className: "-mx-1 my-1 h-px bg-border" }) : null, _jsxs("li", { id: `${listId}-o${rows.length}`, role: "option", "aria-selected": active === rows.length, "data-active": active === rows.length || undefined, "data-slot": "picker-create", className: cx(optionClass(active === rows.length, false), 'text-primary'), onMouseDown: (e) => e.preventDefault(), onMouseMove: () => setActive(rows.length), onClick: runCreate, children: [_jsx(Plus, { className: "size-4 shrink-0", "aria-hidden": true }), _jsxs("span", { className: "truncate", children: [createFooterLabel ? createFooterLabel(trimmed) : `Crear ${entityLabel}`.trim(), !createFooterLabel && trimmed ? _jsxs("span", { className: "text-muted-foreground", children: [" \u00AB", trimmed, "\u00BB"] }) : null] })] })] })) : null] })] }));
291
+ const contentStyle = {
292
+ width: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
293
+ maxWidth: 'calc(100vw - 1rem)',
294
+ zIndex: RECORD_PICKER_Z_INDEX,
295
+ };
296
+ const joined = !hideJoinedAction && !readOnly && hasRecordPickerAction(hasValue, onCreate, onEdit);
297
+ const action = joined ? (_jsx(RecordPickerAction, { hasValue: hasValue, label: entityLabel, onCreate: onCreate ? () => runCreate() : undefined, onEdit: onEdit, createLabel: createLabel, editLabel: editLabel, disabled: disabled, variant: variant })) : null;
298
+ const defaultValueNode = (item) => item ? (_jsxs(_Fragment, { children: [renderLead?.(item, 'value'), _jsx("span", { className: "min-w-0 flex-1 truncate", children: getLabel(item) }), renderTrailing?.(item, 'value')] })) : (_jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: placeholder }));
299
+ const valueNode = renderValue ? renderValue(single) : defaultValueNode(single);
300
+ const wrap = (row) => below ? (_jsxs("div", { className: cx('w-full min-w-0 space-y-1.5', className), "data-slot": slot, children: [row, below] })) : (row);
301
+ const rowClass = cx('flex w-full min-w-0 items-center gap-1.5', !below && className);
302
+ const rowSlot = below ? undefined : slot;
303
+ // ---- read-only --------------------------------------------------------
304
+ if (readOnly) {
305
+ return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: id, disabled: true, "aria-readonly": "true", "aria-label": ariaLabel, className: cx('w-full min-w-0 cursor-default justify-start font-normal opacity-100', triggerClassName), ...triggerProps, children: _jsx("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: valueNode }) }));
306
+ }
307
+ // ---- input trigger ----------------------------------------------------
308
+ if (isInput) {
309
+ const showValue = !freeText && !multiple && hasValue && !!(single || renderValue);
310
+ const inputCls = cx(variant === 'cell' ? 'h-8 pl-8' : 'pl-8', joined && JOINED_TRIGGER_CLASS, triggerClassName);
311
+ return wrap(_jsxs("div", { className: rowClass, "data-slot": rowSlot, children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-stretch", children: [_jsxs(Popover, { open: listOpen, onOpenChange: (o) => !o && close(), children: [_jsx(PopoverAnchor, { asChild: true, children: _jsx("div", { ref: anchorRef, className: "relative w-full min-w-0", "data-slot": anchorSlot, children: showValue ? (_jsxs("div", { className: cx('flex min-h-9 w-full min-w-0 items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm dark:bg-input/30', joined && 'rounded-r-none', invalid && 'border-destructive'), "data-slot": "record-picker-value", children: [Icon ? _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }) : null, _jsx("div", { className: "min-w-0 flex-1", children: valueNode }), onClear && !disabled ? (_jsx(Button, { type: "button", size: "icon", variant: "ghost", className: "size-7 shrink-0", onClick: onClear, "aria-label": clearLabel, children: _jsx(X, { className: "size-4" }) })) : null] })) : (_jsxs(_Fragment, { children: [_jsx(Search, { className: cx('pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground', variant === 'cell' ? 'size-3.5' : 'size-4'), "aria-hidden": true }), _jsx(Input, { ref: inputRef, id: id, value: query, disabled: disabled, role: "combobox", "aria-autocomplete": "list", "aria-expanded": listOpen, "aria-controls": listOpen ? listId : undefined, "aria-activedescendant": activeId, "aria-label": ariaLabel, "aria-invalid": invalid || undefined, autoComplete: "off", spellCheck: false, placeholder: placeholder, className: inputCls, onChange: (e) => {
312
+ setQuery(e.target.value);
313
+ setOpen(true);
314
+ }, onBlur: () => close(), onKeyDown: onKeyDown, ...triggerProps })] })) }) }), _jsx(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-0", "data-slot": contentSlot,
315
+ // Focus stays in the text box: keep typing while the list shows.
316
+ onOpenAutoFocus: (e) => e.preventDefault(),
317
+ // Esc is handled by the text box (closes the list and
318
+ // stops there, so the dialog around it stays open).
319
+ onEscapeKeyDown: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(),
320
+ // Clicking the list (scrollbar included) must not blur the box.
321
+ onMouseDown: (e) => e.preventDefault(), onInteractOutside: (e) => {
322
+ if (anchorRef.current?.contains(e.target))
323
+ e.preventDefault();
324
+ }, style: contentStyle, children: list })] }), action] }), after] }));
325
+ }
326
+ // ---- button trigger ---------------------------------------------------
327
+ const onTriggerKeyDown = (e) => {
328
+ if (disabled)
329
+ return;
330
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || (multiple && (e.key === 'Enter' || e.key === ' '))) {
331
+ e.preventDefault();
332
+ setOpen(true);
333
+ return;
334
+ }
335
+ // Type-to-search: a printable key on the closed trigger opens the list
336
+ // with that character already in the search box.
337
+ if (!open && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
338
+ e.preventDefault();
339
+ setQuery(e.key);
340
+ setOpen(true);
341
+ }
342
+ };
343
+ const clearable = !!onClear && hasValue && !disabled && !multiple;
344
+ const cellCls = 'h-8 border-transparent bg-transparent px-2 shadow-none hover:border-input hover:bg-transparent dark:bg-transparent dark:hover:bg-transparent';
345
+ const comboboxAria = {
346
+ role: 'combobox',
347
+ 'aria-expanded': listOpen,
348
+ 'aria-haspopup': 'listbox',
349
+ 'aria-controls': listOpen ? listId : undefined,
350
+ 'aria-invalid': invalid || undefined,
351
+ 'aria-label': ariaLabel,
352
+ id,
353
+ };
354
+ const triggerEl = multiple ? (_jsxs("div", { ...comboboxAria, ref: (el) => {
355
+ triggerRef.current = el;
356
+ }, tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, onKeyDown: onTriggerKeyDown, className: cx('flex min-h-9 w-full min-w-0 cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30', variant === 'cell' && 'min-h-8 border-transparent shadow-none', invalid && 'border-destructive ring-1 ring-destructive/30', disabled && 'pointer-events-none opacity-50', joined && JOINED_TRIGGER_CLASS, triggerClassName), "data-empty": !hasValue, ...triggerProps, children: [_jsx("span", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-1", children: selectedList.length > 0 ? (selectedList.map((item) => (_jsxs(Badge, { variant: "secondary", className: "max-w-full gap-1 pr-1 font-normal", "data-slot": "record-picker-chip", children: [_jsx("span", { className: "truncate", children: getLabel(item) }), onRemove && !disabled ? (_jsx("button", { type: "button", "aria-label": `Quitar ${getLabel(item)}`, className: "rounded-full p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
357
+ e.stopPropagation();
358
+ onRemove(item);
359
+ }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(X, { className: "size-3" }) })) : null] }, getKey(item))))) : (_jsx("span", { className: "truncate text-muted-foreground", children: placeholder })) }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true })] })) : (_jsxs(Button, { ...comboboxAria, ref: (el) => {
360
+ triggerRef.current = el;
361
+ }, type: "button", variant: "outline", disabled: disabled, onKeyDown: onTriggerKeyDown, className: cx('w-full min-w-0 flex-1 justify-between gap-2 font-normal', variant === 'cell' && cellCls, clearable && 'pr-14', joined && JOINED_TRIGGER_CLASS, invalid && 'border-destructive ring-1 ring-destructive/30', triggerClassName), "data-empty": !hasValue, ...triggerProps, children: [Icon ? _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }) : null, _jsx("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: valueNode }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true })] }));
362
+ return wrap(_jsxs("div", { className: rowClass, "data-slot": rowSlot, children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-center", children: [_jsxs("div", { className: "relative flex min-w-0 flex-1 items-center", children: [_jsxs(Popover, { open: listOpen, onOpenChange: (o) => (disabled ? undefined : setOpen(o)), children: [_jsx(PopoverTrigger, { asChild: true, children: triggerEl }), _jsxs(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-0", "data-slot": contentSlot, style: contentStyle, onCloseAutoFocus: (e) => {
363
+ if (!tabbingRef.current)
364
+ return;
365
+ tabbingRef.current = false;
366
+ e.preventDefault();
367
+ focusNextAfter(triggerRef.current);
368
+ }, children: [_jsxs("div", { className: "flex h-10 items-center gap-2 border-b px-3", "data-slot": "record-picker-search", children: [_jsx(Search, { className: "size-4 shrink-0 opacity-50", "aria-hidden": true }), _jsx("input", { role: "combobox", "aria-expanded": listOpen, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": activeId, "aria-label": searchPlaceholder, autoComplete: "off", spellCheck: false, placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: onKeyDown, className: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground" })] }), list] })] }), clearable ? (_jsx("button", { type: "button", "aria-label": clearLabel, title: clearLabel, onClick: (e) => {
369
+ e.stopPropagation();
370
+ onClear?.();
371
+ }, className: "absolute right-8 top-1/2 z-10 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", "data-slot": "record-picker-clear", children: _jsx(X, { className: "size-3.5" }) })) : null] }), action] }), after] }));
372
+ }
@@ -0,0 +1,34 @@
1
+ import { type ResolvedOption } from './use-options-resolver';
2
+ export type OptionIdResolution = {
3
+ status: 'found';
4
+ option: ResolvedOption;
5
+ }
6
+ /** The host resolved the ids and this one is not there (deleted, or not visible). */
7
+ | {
8
+ status: 'missing';
9
+ }
10
+ /** Could not tell (old host, error): show the raw value. */
11
+ | {
12
+ status: 'unknown';
13
+ };
14
+ /** Ids the kernel accepts per request (dynamic.MaxOptionsIDs). */
15
+ export declare const OPTION_IDS_CHUNK = 100;
16
+ export interface UseResolveOptionIdsArgs {
17
+ /** FK target: `/options/<ref>`. */
18
+ ref?: string;
19
+ /** Explicit options endpoint (wins over `ref`), as in useOptionsResolver. */
20
+ endpoint?: string;
21
+ /** `?field=`; defaults to `id` with a `ref`. */
22
+ field?: string;
23
+ /** Ids to label. Pass only those not already loaded / seeded. */
24
+ ids: readonly (string | number)[];
25
+ enabled?: boolean;
26
+ }
27
+ export interface UseResolveOptionIdsResult {
28
+ /** Settled resolutions by id (as given, stringified). */
29
+ resolved: ReadonlyMap<string, OptionIdResolution>;
30
+ /** Some id is still being resolved. */
31
+ loading: boolean;
32
+ }
33
+ export declare function useResolveOptionIds(args: UseResolveOptionIdsArgs): UseResolveOptionIdsResult;
34
+ //# sourceMappingURL=use-option-ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-option-ids.d.ts","sourceRoot":"","sources":["../src/use-option-ids.ts"],"names":[],"mappings":"AAuBA,OAAO,EAA0D,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEpH,MAAM,MAAM,kBAAkB,GACxB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,cAAc,CAAA;CAAE;AAC7C,qFAAqF;GACnF;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE;AACvB,4DAA4D;GAC1D;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAAA;AAE3B,kEAAkE;AAClE,eAAO,MAAM,gBAAgB,MAAM,CAAA;AA8EnC,MAAM,WAAW,uBAAuB;IACpC,mCAAmC;IACnC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,GAAG,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;IACjC,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,yBAAyB;IACtC,yDAAyD;IACzD,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;IACjD,uCAAuC;IACvC,OAAO,EAAE,OAAO,CAAA;CACnB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,yBAAyB,CAqC5F"}
@@ -0,0 +1,138 @@
1
+ // useResolveOptionIds — labels for values a picker already holds.
2
+ //
3
+ // A RecordPicker editing a saved record (a product with its brand) holds an id
4
+ // whose label is in no loaded page: options load only once the popover opens,
5
+ // so the trigger used to show the raw UUID unless the caller had a seed (the
6
+ // relation sibling the table served). This hook asks the canonical options
7
+ // endpoint for exactly those ids:
8
+ //
9
+ // GET /api/options/<ref>?field=id&ids=a,b
10
+ //
11
+ // - One react-query entry per (org/branch, endpoint, field, id): two pickers
12
+ // on the same value share it, and nothing is refetched for 30 s.
13
+ // - Every id requested within a few ms for the same endpoint + field travels in
14
+ // ONE request (a column of cells, a multi-select's chips), chunked at the
15
+ // kernel's 100-id cap.
16
+ // - A host that predates `ids` ignores the parameter and answers a first page.
17
+ // The answer is then filtered by id: a hit still resolves, a miss stays
18
+ // `unknown` (the caller shows the value as before) instead of being reported
19
+ // as deleted. A response that only holds requested ids is a host that
20
+ // understood `ids`, so a requested id absent from it is `missing`.
21
+ import { useContext, useMemo } from 'react';
22
+ import { QueryClient, QueryClientContext, useQueries } from '@tanstack/react-query';
23
+ import { useApi } from './api-context';
24
+ import { onOptionsCacheInvalidated, optionsScope, projectOption } from './use-options-resolver';
25
+ /** Ids the kernel accepts per request (dynamic.MaxOptionsIDs). */
26
+ export const OPTION_IDS_CHUNK = 100;
27
+ const OPTION_IDS_TTL_MS = 30_000;
28
+ const OPTION_IDS_WINDOW_MS = 5;
29
+ const QUERY_ROOT = 'metacore-option-ids';
30
+ // Hosts without a QueryClientProvider still get dedup + cache.
31
+ let fallbackClient = null;
32
+ function getFallbackClient() {
33
+ if (!fallbackClient)
34
+ fallbackClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
35
+ return fallbackClient;
36
+ }
37
+ const knownClients = new Set();
38
+ onOptionsCacheInvalidated(() => {
39
+ for (const client of knownClients)
40
+ void client.invalidateQueries({ queryKey: [QUERY_ROOT] });
41
+ });
42
+ const pendingBatches = new Map();
43
+ function enqueue(api, scope, url, field, id) {
44
+ const key = [scope, url, field].join('\n');
45
+ // Keyed without the client: useApi() hands each component its own wrapper
46
+ // of the same host client, and pickers of one ref must share the request.
47
+ let batch = pendingBatches.get(key);
48
+ if (!batch) {
49
+ batch = { api, url, field, waiters: new Map() };
50
+ pendingBatches.set(key, batch);
51
+ const flushing = batch;
52
+ setTimeout(() => {
53
+ if (pendingBatches.get(key) === flushing)
54
+ pendingBatches.delete(key);
55
+ void flush(flushing);
56
+ }, OPTION_IDS_WINDOW_MS);
57
+ }
58
+ const target = batch;
59
+ return new Promise((resolve) => {
60
+ const list = target.waiters.get(id) ?? [];
61
+ list.push({ resolve });
62
+ target.waiters.set(id, list);
63
+ });
64
+ }
65
+ async function flush(batch) {
66
+ const ids = [...batch.waiters.keys()];
67
+ for (let i = 0; i < ids.length; i += OPTION_IDS_CHUNK) {
68
+ const chunk = ids.slice(i, i + OPTION_IDS_CHUNK);
69
+ const result = await fetchIds(batch.api, batch.url, batch.field, chunk);
70
+ for (const id of chunk) {
71
+ const r = result.get(id) ?? { status: 'unknown' };
72
+ for (const w of batch.waiters.get(id) ?? [])
73
+ w.resolve(r);
74
+ }
75
+ }
76
+ }
77
+ async function fetchIds(api, url, field, ids) {
78
+ const out = new Map();
79
+ try {
80
+ const res = await api.get(url, { params: { field, ids: ids.join(',') } });
81
+ const body = res?.data;
82
+ if (!body || body.success !== true || !Array.isArray(body.data))
83
+ return out;
84
+ const options = body.data.map(projectOption);
85
+ const byId = new Map();
86
+ for (const o of options)
87
+ byId.set(String(o.id).toLowerCase(), o);
88
+ const asked = new Set(ids.map((id) => id.toLowerCase()));
89
+ // Only requested ids back → the host honoured `ids`.
90
+ const honoured = options.every((o) => asked.has(String(o.id).toLowerCase()));
91
+ for (const id of ids) {
92
+ const hit = byId.get(id.toLowerCase());
93
+ if (hit)
94
+ out.set(id, { status: 'found', option: hit });
95
+ else if (honoured)
96
+ out.set(id, { status: 'missing' });
97
+ }
98
+ }
99
+ catch {
100
+ // Network / permission error: leave every id `unknown`.
101
+ }
102
+ return out;
103
+ }
104
+ export function useResolveOptionIds(args) {
105
+ const { ref, endpoint, field, ids, enabled = true } = args;
106
+ const api = useApi();
107
+ const contextClient = useContext(QueryClientContext);
108
+ const client = contextClient ?? getFallbackClient();
109
+ knownClients.add(client);
110
+ const url = endpoint || (ref ? `/options/${ref}` : '');
111
+ const effectiveField = field || (ref ? 'id' : '');
112
+ const scope = optionsScope();
113
+ const list = useMemo(() => [...new Set(ids.map(String).filter((id) => id !== ''))], [ids.map(String).join('\u0001')]);
114
+ const active = enabled && !!url && !!effectiveField && list.length > 0;
115
+ const results = useQueries({
116
+ queries: (active ? list : []).map((id) => ({
117
+ queryKey: [QUERY_ROOT, scope, url, effectiveField, id],
118
+ queryFn: () => enqueue(api, scope, url, effectiveField, id),
119
+ staleTime: OPTION_IDS_TTL_MS,
120
+ retry: false,
121
+ refetchOnWindowFocus: false,
122
+ })),
123
+ }, client);
124
+ const resolved = new Map();
125
+ let loading = false;
126
+ if (active) {
127
+ list.forEach((id, i) => {
128
+ const r = results[i];
129
+ if (r?.data)
130
+ resolved.set(id, r.data);
131
+ else if (r?.isError)
132
+ resolved.set(id, { status: 'unknown' });
133
+ else
134
+ loading = true;
135
+ });
136
+ }
137
+ return { resolved, loading };
138
+ }
@@ -95,7 +95,11 @@ export interface UseOptionsResolverResult {
95
95
  * lists it instead of serving the previous 30 s snapshot.
96
96
  */
97
97
  export declare function invalidateOptionsCache(): void;
98
+ /** Runs `fn` on every invalidateOptionsCache (id-label lookups drop with it). */
99
+ export declare function onOptionsCacheInvalidated(fn: () => void): () => void;
98
100
  export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
101
+ /** Org + branch of the host session: part of every options cache key. */
102
+ export declare function optionsScope(): string;
99
103
  export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
100
104
  export declare function projectOption(raw: any): ResolvedOption;
101
105
  //# sourceMappingURL=use-options-resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAcA,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAG1E,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;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACjC;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;IACjB;;;OAGG;IACH,YAAY,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACjC,gFAAgF;IAChF,SAAS,CAAC,EAAE,OAAO,CAAA;CACtB;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,CAuKzF;AASD,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAqBtD"}
1
+ {"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAcA,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAG1E,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;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACjC;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;IACjB;;;OAGG;IACH,YAAY,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACjC,gFAAgF;IAChF,SAAS,CAAC,EAAE,OAAO,CAAA;CACtB;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,CAK7C;AAID,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAGpE;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;AAED,yEAAyE;AACzE,wBAAgB,YAAY,IAAI,MAAM,CAWrC;AAiBD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CAuKzF;AASD,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAqBtD"}
@@ -40,11 +40,20 @@ export function invalidateOptionsCache() {
40
40
  optionsGeneration++;
41
41
  optionsCache.clear();
42
42
  optionsInflight.clear();
43
+ for (const fn of invalidationListeners)
44
+ fn();
45
+ }
46
+ const invalidationListeners = new Set();
47
+ /** Runs `fn` on every invalidateOptionsCache (id-label lookups drop with it). */
48
+ export function onOptionsCacheInvalidated(fn) {
49
+ invalidationListeners.add(fn);
50
+ return () => invalidationListeners.delete(fn);
43
51
  }
44
52
  export function optionsRequestKey(scope, url, field, query, limit, filter) {
45
53
  return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n');
46
54
  }
47
- function optionsScope() {
55
+ /** Org + branch of the host session: part of every options cache key. */
56
+ export function optionsScope() {
48
57
  if (typeof localStorage === 'undefined')
49
58
  return '';
50
59
  let org = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "49.5.0",
3
+ "version": "49.7.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  // #1025 VehiclePicker, #1026 RelateDocuments, #1027 PrintSendDialog.
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
5
5
 
6
6
  vi.mock('react-i18next', () => ({
7
7
  useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
@@ -49,7 +49,9 @@ describe('VehiclePicker', () => {
49
49
  fireEvent.change(screen.getByPlaceholderText('Placa o VIN'), { target: { value: 'ABC' } })
50
50
  const opt = await screen.findByRole('option')
51
51
  expect(search.mock.calls[0][0]).toBe('ABC')
52
- fireEvent.click(opt.querySelector('button')!)
52
+ // La opción misma es el elemento interactivo (RecordPicker: role=option
53
+ // sin <button> anidado, como pide ARIA para listbox).
54
+ fireEvent.click(opt)
53
55
  expect(onChange).toHaveBeenCalledWith(veh)
54
56
  })
55
57
 
@@ -102,7 +104,8 @@ describe('RelateDocuments', () => {
102
104
  const { rerender } = render(<RelateDocuments value={value} onChange={onChange} relationTypes={types} search={search} />)
103
105
  fireEvent.change(screen.getByPlaceholderText('Buscar por folio o UUID'), { target: { value: 'A-' } })
104
106
  const list = await screen.findByRole('listbox')
105
- fireEvent.click(list.querySelectorAll('button')[0])
107
+ // RecordPicker: se elige la opción (role=option), no un <button> interno.
108
+ fireEvent.click((await within(list).findAllByRole('option'))[0]!)
106
109
  expect(onChange).toHaveBeenLastCalledWith([
107
110
  expect.objectContaining({ related_document_id: 'd1', relation_type: '01', uuid: 'UUID-1', folio: 'A-1' }),
108
111
  ])