@asteby/metacore-runtime-react 49.8.0 → 49.9.1
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 +25 -0
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +5 -1
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.js +2 -2
- package/dist/business/line-product-cell.js +1 -1
- package/dist/business/product-display.d.ts +18 -0
- package/dist/business/product-display.d.ts.map +1 -0
- package/dist/business/product-display.js +40 -0
- package/dist/business/product-options.d.ts +6 -1
- package/dist/business/product-options.d.ts.map +1 -1
- package/dist/business/product-options.js +10 -1
- package/dist/business/product-search.d.ts +8 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dynamic-select-field.d.ts +7 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +18 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/option-display.d.ts +68 -0
- package/dist/option-display.d.ts.map +1 -0
- package/dist/option-display.js +135 -0
- package/dist/query-batch.d.ts +1 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +3 -1
- package/dist/use-options-resolver.d.ts +16 -1
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +38 -6
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +7 -7
- package/src/__tests__/option-display.test.tsx +241 -0
- package/src/business/document-editor.tsx +11 -2
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +8 -5
- package/src/business/line-product-cell.tsx +1 -1
- package/src/business/product-display.ts +62 -0
- package/src/business/product-options.tsx +10 -1
- package/src/business/product-search.ts +8 -0
- package/src/dynamic-select-field.tsx +45 -1
- package/src/index.ts +10 -0
- package/src/option-display.tsx +255 -0
- package/src/query-batch.ts +2 -0
- package/src/use-options-resolver.ts +49 -4
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// OptionDisplay — paints the declarative option display the kernel resolves for
|
|
3
|
+
// every option of a model with `option_display` (manifest v3): avatar, title on
|
|
4
|
+
// up to two lines, muted subtitle, badges, and right-aligned metrics (price,
|
|
5
|
+
// stock…) with tabular numbers and a toned chip («Agotado» in danger, low
|
|
6
|
+
// stock in warning). The server already evaluated formats' inputs, tones,
|
|
7
|
+
// dim/block — this file only formats and paints, so every picker of the model
|
|
8
|
+
// (dynamic_select, the document editor's product cell, an addon screen) reads
|
|
9
|
+
// the same.
|
|
10
|
+
//
|
|
11
|
+
// It plugs into <RecordPicker> through its existing slots (`renderItem`,
|
|
12
|
+
// `renderValue`, `isItemDisabled`), never by editing the picker itself. An
|
|
13
|
+
// option without `display` keeps rendering exactly as before.
|
|
14
|
+
import { useTranslation } from 'react-i18next';
|
|
15
|
+
import { Badge } from '@asteby/metacore-ui/primitives';
|
|
16
|
+
import { useFormatter } from './business/format';
|
|
17
|
+
import { OptionThumb } from './record-picker-option';
|
|
18
|
+
/** Reads a well-formed display off any option-like value (null when absent). */
|
|
19
|
+
export function getOptionDisplay(option) {
|
|
20
|
+
const d = option && typeof option === 'object' ? option.display : null;
|
|
21
|
+
return d && typeof d === 'object' ? d : null;
|
|
22
|
+
}
|
|
23
|
+
const TONE_VARIANT = {
|
|
24
|
+
success: 'success',
|
|
25
|
+
warning: 'warning',
|
|
26
|
+
danger: 'danger',
|
|
27
|
+
info: 'info',
|
|
28
|
+
neutral: 'muted',
|
|
29
|
+
};
|
|
30
|
+
export const toneVariant = (tone) => TONE_VARIANT[tone ?? ''] ?? 'muted';
|
|
31
|
+
const asNumber = (v) => {
|
|
32
|
+
if (typeof v === 'number')
|
|
33
|
+
return Number.isFinite(v) ? v : null;
|
|
34
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
35
|
+
const n = Number(v);
|
|
36
|
+
return Number.isFinite(n) ? n : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const RELATIVE_UNITS = [
|
|
41
|
+
['year', 365 * 86400],
|
|
42
|
+
['month', 30 * 86400],
|
|
43
|
+
['week', 7 * 86400],
|
|
44
|
+
['day', 86400],
|
|
45
|
+
['hour', 3600],
|
|
46
|
+
['minute', 60],
|
|
47
|
+
];
|
|
48
|
+
/** «hace 3 días» / «en 2 semanas» in the formatter's locale. */
|
|
49
|
+
export function formatRelativeDate(input, locale, now = new Date()) {
|
|
50
|
+
if (input == null || input === '')
|
|
51
|
+
return '';
|
|
52
|
+
const d = new Date(input);
|
|
53
|
+
if (Number.isNaN(d.getTime()))
|
|
54
|
+
return String(input);
|
|
55
|
+
const secs = (d.getTime() - now.getTime()) / 1000;
|
|
56
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
57
|
+
for (const [unit, size] of RELATIVE_UNITS) {
|
|
58
|
+
if (Math.abs(secs) >= size)
|
|
59
|
+
return rtf.format(Math.round(secs / size), unit);
|
|
60
|
+
}
|
|
61
|
+
return rtf.format(0, 'minute');
|
|
62
|
+
}
|
|
63
|
+
/** Formats one trailing value per its declared format (org currency for money). */
|
|
64
|
+
export function formatTrailingValue(item, fmt, now) {
|
|
65
|
+
const v = item.value;
|
|
66
|
+
if (v == null || v === '')
|
|
67
|
+
return '';
|
|
68
|
+
switch (item.format) {
|
|
69
|
+
case 'money':
|
|
70
|
+
return fmt.money(v, item.currency || undefined);
|
|
71
|
+
case 'number':
|
|
72
|
+
return fmt.quantity(v);
|
|
73
|
+
case 'integer': {
|
|
74
|
+
const n = asNumber(v);
|
|
75
|
+
return n == null ? String(v) : new Intl.NumberFormat(fmt.locale, { maximumFractionDigits: 0 }).format(n);
|
|
76
|
+
}
|
|
77
|
+
case 'percent': {
|
|
78
|
+
const n = asNumber(v);
|
|
79
|
+
if (n == null)
|
|
80
|
+
return String(v);
|
|
81
|
+
// 0.16 and 16 both mean 16 %.
|
|
82
|
+
const frac = Math.abs(n) <= 1 ? n : n / 100;
|
|
83
|
+
return new Intl.NumberFormat(fmt.locale, { style: 'percent', maximumFractionDigits: 1 }).format(frac);
|
|
84
|
+
}
|
|
85
|
+
case 'date':
|
|
86
|
+
return fmt.date(v);
|
|
87
|
+
case 'relative_date':
|
|
88
|
+
return formatRelativeDate(v, fmt.locale, now);
|
|
89
|
+
default:
|
|
90
|
+
return typeof v === 'number' ? fmt.quantity(v) : String(v);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One metric. A toned metric is a chip carrying its value and label («18
|
|
95
|
+
* disp.», or the tone's text «Agotado»); a plain one is the value in tabular
|
|
96
|
+
* numbers, with its label inline only when `showLabel` (several plain metrics
|
|
97
|
+
* side by side need it to tell «Saldo» from «Vencido»; a lone price does not).
|
|
98
|
+
*/
|
|
99
|
+
function TrailingMetric({ item, fmt, showLabel }) {
|
|
100
|
+
const { t } = useTranslation();
|
|
101
|
+
const label = item.label ? t(item.label, { defaultValue: item.label }) : '';
|
|
102
|
+
const value = formatTrailingValue(item, fmt);
|
|
103
|
+
if (item.tone) {
|
|
104
|
+
const text = item.text ? t(item.text, { defaultValue: item.text }) : [value, label].filter(Boolean).join(' ');
|
|
105
|
+
return (_jsx(Badge, { variant: toneVariant(item.tone), className: "font-medium tabular-nums", title: label && item.text ? `${label}: ${value}` : undefined, "data-slot": "option-metric-chip", "data-key": item.key, "data-tone": item.tone, children: text }));
|
|
106
|
+
}
|
|
107
|
+
return (_jsxs("span", { className: "flex items-baseline gap-1 whitespace-nowrap", title: label || undefined, "data-slot": "option-metric", "data-key": item.key, children: [showLabel && label ? _jsx("span", { className: "text-[11px] text-muted-foreground", children: label }) : null, _jsx("span", { className: "text-sm font-semibold tabular-nums text-foreground", "data-slot": "option-metric-value", children: item.text ? t(item.text, { defaultValue: item.text }) : value })] }));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The option row: avatar 32px · title (≤2 lines) + badges · muted subtitle ·
|
|
111
|
+
* metrics aligned right. `dimmed` attenuates the content, never the hover.
|
|
112
|
+
*/
|
|
113
|
+
export function OptionDisplayRow({ display, label, active, selected, currency, avatarSize = 32 }) {
|
|
114
|
+
const { t } = useTranslation();
|
|
115
|
+
const fmt = useFormatter({ currency });
|
|
116
|
+
const title = display.title || label || '';
|
|
117
|
+
const trailing = display.trailing ?? [];
|
|
118
|
+
const badges = display.badges ?? [];
|
|
119
|
+
const plainCount = trailing.filter((i) => !i.tone).length;
|
|
120
|
+
return (_jsxs("span", { className: "flex w-full min-w-0 items-center gap-3 py-0.5", "data-slot": "option-display", "data-tone": display.tone || undefined, "data-dimmed": display.dimmed || undefined, "data-selected": selected || undefined, children: [_jsx("span", { className: display.dimmed ? 'opacity-60 grayscale' : undefined, children: _jsx(OptionThumb, { image: display.image ?? null, name: title, size: avatarSize }) }), _jsxs("span", { className: 'flex min-w-0 flex-1 flex-col gap-0.5' + (display.dimmed ? ' opacity-60' : ''), children: [_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [_jsx("span", { className: 'line-clamp-2 min-w-0 break-words text-sm font-medium leading-snug' + (active ? ' text-accent-foreground' : ''), title: title, "data-slot": "option-display-title", children: title }), badges.map((b, i) => (_jsx(Badge, { variant: toneVariant(b.tone), className: "mt-px px-1.5 py-0 text-[10px]", "data-slot": "option-display-badge", children: t(b.text, { defaultValue: b.text }) }, i)))] }), display.subtitle ? (_jsx("span", { className: "truncate text-xs text-muted-foreground", "data-slot": "option-display-subtitle", children: display.subtitle })) : null] }), trailing.length > 0 ? (_jsx("span", { className: "ml-auto flex shrink-0 flex-col items-end justify-center gap-1 text-right", "data-slot": "option-display-trailing", children: trailing.map((item) => (_jsx(TrailingMetric, { item: item, fmt: fmt, showLabel: plainCount > 1 }, item.key))) })) : null] }));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Compact summary for a picker's trigger: small avatar, title and the most
|
|
124
|
+
* relevant metric (the toned one, else the first).
|
|
125
|
+
*/
|
|
126
|
+
export function OptionDisplayValue({ display, label, currency }) {
|
|
127
|
+
const fmt = useFormatter({ currency });
|
|
128
|
+
const title = display.title || label || '';
|
|
129
|
+
const trailing = display.trailing ?? [];
|
|
130
|
+
const lead = trailing.find((i) => i.tone) ?? trailing[0];
|
|
131
|
+
const extra = lead && trailing.find((i) => i !== lead && !i.tone);
|
|
132
|
+
return (_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2", "data-slot": "option-display-value", children: [_jsx(OptionThumb, { image: display.image ?? null, name: title, size: 20 }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: title }), extra ? (_jsx("span", { className: "shrink-0 text-xs tabular-nums text-muted-foreground", children: formatTrailingValue(extra, fmt) })) : null, lead ? _jsx(TrailingMetric, { item: lead, fmt: fmt }) : null] }));
|
|
133
|
+
}
|
|
134
|
+
/** Whether any of the options carries a display (switches a picker to rich rows). */
|
|
135
|
+
export const hasOptionDisplays = (options) => options.some((o) => getOptionDisplay(o) !== null);
|
package/dist/query-batch.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export declare function resetQueryBatchCache(): void;
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function invalidateQueryBatchData(): void;
|
|
50
50
|
export declare function optionsModelFromUrl(url: string): string | null;
|
|
51
|
-
export declare function optionsBatchToken(model: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
|
|
51
|
+
export declare function optionsBatchToken(model: string, field: string, query: string, limit: number | undefined, filter: string | undefined, context?: Record<string, string>): string;
|
|
52
52
|
/** Names each distinct token so the server can key the response. */
|
|
53
53
|
export declare function nameBatchTokens(tokens: string[]): {
|
|
54
54
|
name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAaD,wBAAgB,oBAAoB,IAAI,IAAI,CAO3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAaD,wBAAgB,oBAAoB,IAAI,IAAI,CAO3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,MAAM,CAQR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAsB/E;AAwED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CA8BxF;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;;;GA2BxG"}
|
package/dist/query-batch.js
CHANGED
|
@@ -179,7 +179,7 @@ export function optionsModelFromUrl(url) {
|
|
|
179
179
|
const match = url.match(/^\/options\/([A-Za-z][A-Za-z0-9_]*)$/);
|
|
180
180
|
return match ? match[1] : null;
|
|
181
181
|
}
|
|
182
|
-
export function optionsBatchToken(model, field, query, limit, filter) {
|
|
182
|
+
export function optionsBatchToken(model, field, query, limit, filter, context) {
|
|
183
183
|
const params = new URLSearchParams();
|
|
184
184
|
params.set('field', field);
|
|
185
185
|
if (query)
|
|
@@ -188,6 +188,8 @@ export function optionsBatchToken(model, field, query, limit, filter) {
|
|
|
188
188
|
params.set('limit', String(limit));
|
|
189
189
|
if (filter)
|
|
190
190
|
params.set('filter_value', filter);
|
|
191
|
+
for (const k of Object.keys(context ?? {}).sort())
|
|
192
|
+
params.set(`ctx.${k}`, context[k]);
|
|
191
193
|
return `o:${model}?${params.toString()}`;
|
|
192
194
|
}
|
|
193
195
|
/** Names each distinct token so the server can key the response. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type OptionFilterRule } from './option-filter';
|
|
2
|
+
import type { OptionDisplayData } from './option-display';
|
|
2
3
|
export interface ResolvedOption {
|
|
3
4
|
/** Canonical id (server-side primary key). */
|
|
4
5
|
id: string | number;
|
|
@@ -12,6 +13,12 @@ export interface ResolvedOption {
|
|
|
12
13
|
image?: string | null;
|
|
13
14
|
color?: string | null;
|
|
14
15
|
icon?: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Presentación declarativa resuelta por el kernel (manifest v3
|
|
18
|
+
* `option_display`): título, subtítulo, imagen, métricas a la derecha con
|
|
19
|
+
* tono (precio, stock…) y badges. Ausente en hosts/modelos sin display.
|
|
20
|
+
*/
|
|
21
|
+
display?: OptionDisplayData | null;
|
|
15
22
|
/**
|
|
16
23
|
* Campos extra del payload (precio, costo, sku, tasa) que no son decoración.
|
|
17
24
|
* Solo se llena cuando el option trae números o strings además de id/label.
|
|
@@ -80,6 +87,12 @@ export interface UseOptionsResolverArgs {
|
|
|
80
87
|
optionFilter?: OptionFilterRule[];
|
|
81
88
|
/** Current selection: never hidden by `optionFilter`, so its label survives. */
|
|
82
89
|
keepValue?: unknown;
|
|
90
|
+
/**
|
|
91
|
+
* Picker context for the option display's contributed metrics, sent as
|
|
92
|
+
* `?ctx.<key>=<value>` (e.g. `{ warehouse_id }` of the document being
|
|
93
|
+
* edited → stock in that warehouse). Empty values are dropped.
|
|
94
|
+
*/
|
|
95
|
+
context?: Record<string, string | null | undefined>;
|
|
83
96
|
}
|
|
84
97
|
export interface UseOptionsResolverResult {
|
|
85
98
|
options: ResolvedOption[];
|
|
@@ -97,7 +110,9 @@ export interface UseOptionsResolverResult {
|
|
|
97
110
|
export declare function invalidateOptionsCache(): void;
|
|
98
111
|
/** Runs `fn` on every invalidateOptionsCache (id-label lookups drop with it). */
|
|
99
112
|
export declare function onOptionsCacheInvalidated(fn: () => void): () => void;
|
|
100
|
-
export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
|
|
113
|
+
export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined, context?: Record<string, string>): string;
|
|
114
|
+
/** Normalized picker context: string values only, empty dropped (null when none). */
|
|
115
|
+
export declare function normalizeOptionsContext(context: Record<string, string | null | undefined> | undefined): Record<string, string> | undefined;
|
|
101
116
|
/** Org + branch of the host session: part of every options cache key. */
|
|
102
117
|
export declare function optionsScope(): string;
|
|
103
118
|
export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
|
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -49,8 +49,34 @@ export function onOptionsCacheInvalidated(fn) {
|
|
|
49
49
|
invalidationListeners.add(fn);
|
|
50
50
|
return () => invalidationListeners.delete(fn);
|
|
51
51
|
}
|
|
52
|
-
export function optionsRequestKey(scope, url, field, query, limit, filter) {
|
|
53
|
-
|
|
52
|
+
export function optionsRequestKey(scope, url, field, query, limit, filter, context) {
|
|
53
|
+
const parts = [scope, url, field, query, String(limit ?? ''), filter ?? ''];
|
|
54
|
+
const ctx = contextKey(context);
|
|
55
|
+
if (ctx)
|
|
56
|
+
parts.push(ctx);
|
|
57
|
+
return parts.join('\n');
|
|
58
|
+
}
|
|
59
|
+
/** Normalized picker context: string values only, empty dropped (null when none). */
|
|
60
|
+
export function normalizeOptionsContext(context) {
|
|
61
|
+
if (!context)
|
|
62
|
+
return undefined;
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const [k, v] of Object.entries(context)) {
|
|
65
|
+
if (v == null)
|
|
66
|
+
continue;
|
|
67
|
+
const s = String(v).trim();
|
|
68
|
+
if (s)
|
|
69
|
+
out[k] = s;
|
|
70
|
+
}
|
|
71
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
72
|
+
}
|
|
73
|
+
function contextKey(context) {
|
|
74
|
+
if (!context)
|
|
75
|
+
return '';
|
|
76
|
+
return Object.keys(context)
|
|
77
|
+
.sort()
|
|
78
|
+
.map((k) => `${k}=${context[k]}`)
|
|
79
|
+
.join('&');
|
|
54
80
|
}
|
|
55
81
|
/** Org + branch of the host session: part of every options cache key. */
|
|
56
82
|
export function optionsScope() {
|
|
@@ -83,6 +109,8 @@ function readOptionsEnvelope(body) {
|
|
|
83
109
|
}
|
|
84
110
|
export function useOptionsResolver(args) {
|
|
85
111
|
const { modelKey, fieldKey, ref, query, limit, enabled = true, endpoint, filterValue, optionFilter, keepValue, } = args;
|
|
112
|
+
const context = normalizeOptionsContext(args.context);
|
|
113
|
+
const ctxSig = contextKey(context);
|
|
86
114
|
const api = useApi();
|
|
87
115
|
const [options, setOptions] = useState([]);
|
|
88
116
|
const [meta, setMeta] = useState(null);
|
|
@@ -123,7 +151,7 @@ export function useOptionsResolver(args) {
|
|
|
123
151
|
setError(null);
|
|
124
152
|
return;
|
|
125
153
|
}
|
|
126
|
-
const key = optionsRequestKey(optionsScope(), url, effectiveField, query ?? '', limit, filterValue);
|
|
154
|
+
const key = optionsRequestKey(optionsScope(), url, effectiveField, query ?? '', limit, filterValue, context);
|
|
127
155
|
if (refreshKey !== seenRefresh.current) {
|
|
128
156
|
seenRefresh.current = refreshKey;
|
|
129
157
|
optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1);
|
|
@@ -151,11 +179,13 @@ export function useOptionsResolver(args) {
|
|
|
151
179
|
params.limit = limit;
|
|
152
180
|
if (filterValue)
|
|
153
181
|
params.filter_value = filterValue;
|
|
182
|
+
for (const [k, v] of Object.entries(context ?? {}))
|
|
183
|
+
params[`ctx.${k}`] = v;
|
|
154
184
|
const model = optionsModelFromUrl(url);
|
|
155
185
|
pending = (async () => {
|
|
156
186
|
if (model) {
|
|
157
187
|
try {
|
|
158
|
-
const part = await loadQueryPart(api, optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue));
|
|
188
|
+
const part = await loadQueryPart(api, optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue, context));
|
|
159
189
|
if (!part.success) {
|
|
160
190
|
throw new Error(part.message || 'options resolver: unsuccessful response');
|
|
161
191
|
}
|
|
@@ -214,7 +244,8 @@ export function useOptionsResolver(args) {
|
|
|
214
244
|
return () => {
|
|
215
245
|
cancelled = true;
|
|
216
246
|
};
|
|
217
|
-
|
|
247
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- context is keyed by ctxSig
|
|
248
|
+
}, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey, ctxSig]);
|
|
218
249
|
// Rules arrive as a fresh array each render; key on their content.
|
|
219
250
|
const filterKey = optionFilter && optionFilter.length > 0 ? JSON.stringify(optionFilter) : '';
|
|
220
251
|
const visibleOptions = useMemo(() => (filterKey ? applyOptionFilter(options, optionFilter ?? [], keepValue) : options),
|
|
@@ -233,7 +264,7 @@ export function useOptionsResolver(args) {
|
|
|
233
264
|
* id/value and label/name fields for legacy parity — we accept either
|
|
234
265
|
* and surface a stable shape downstream.
|
|
235
266
|
*/
|
|
236
|
-
const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon']);
|
|
267
|
+
const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon', 'display']);
|
|
237
268
|
export function projectOption(raw) {
|
|
238
269
|
const id = raw?.id ?? raw?.value ?? '';
|
|
239
270
|
const label = String(raw?.label ?? raw?.name ?? id ?? '');
|
|
@@ -255,6 +286,7 @@ export function projectOption(raw) {
|
|
|
255
286
|
image: raw?.image ?? null,
|
|
256
287
|
color: raw?.color ?? null,
|
|
257
288
|
icon: raw?.icon ?? null,
|
|
289
|
+
...(raw?.display && typeof raw.display === 'object' ? { display: raw.display } : {}),
|
|
258
290
|
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
259
291
|
};
|
|
260
292
|
}
|
package/package.json
CHANGED
|
@@ -47,17 +47,17 @@ describe('DocumentLinesGrid — select de producto en la celda', () => {
|
|
|
47
47
|
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
48
48
|
const td = input.closest('td')!
|
|
49
49
|
expect(td).toBeTruthy()
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// ese renglón aún no llena (cantidad, precio…), así no se corta.
|
|
50
|
+
// Ocupa SOLO su columna: no invade Cant./Precio/Desc.; el renglón vacío
|
|
51
|
+
// tiene una celda por columna del encabezado.
|
|
53
52
|
const row = td.closest('tr')!
|
|
54
53
|
expect(row.getAttribute('data-slot')).toBe('line-draft-row')
|
|
55
54
|
const columns = document.querySelectorAll('[data-slot="line-items-editor"] thead th').length
|
|
56
|
-
expect(td.colSpan).toBe(
|
|
57
|
-
expect(row.children.length).toBe(
|
|
58
|
-
|
|
55
|
+
expect(td.colSpan).toBe(1)
|
|
56
|
+
expect(row.children.length).toBe(columns)
|
|
57
|
+
expect(row.firstElementChild).toBe(td)
|
|
58
|
+
// Placeholder corto que cabe, sin tooltip nativo del navegador.
|
|
59
59
|
expect(input.getAttribute('placeholder')).toBe('Buscar producto…')
|
|
60
|
-
expect(input.
|
|
60
|
+
expect(input.hasAttribute('title')).toBe(false)
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it('la lista sale en un portal fuera del contenedor con overflow y Enter elige y pasa a «Cant.»', async () => {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Declarative option display (kernel `option_display`): the picker paints the
|
|
4
|
+
// row the server resolved — avatar, title, subtitle, toned metrics (price,
|
|
5
|
+
// stock «Agotado»), badges — and keeps the plain row when there is none.
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
8
|
+
|
|
9
|
+
vi.mock('react-i18next', () => {
|
|
10
|
+
const value = { t: (k: string, o?: Record<string, any>) => o?.defaultValue ?? k, i18n: { language: 'es' } }
|
|
11
|
+
return { useTranslation: () => value }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
import { DynamicSelectField } from '../dynamic-select-field'
|
|
15
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
16
|
+
import { CurrencyContext } from '../org-runtime-context'
|
|
17
|
+
import { invalidateOptionsCache, projectOption, optionsRequestKey } from '../use-options-resolver'
|
|
18
|
+
import { optionsBatchToken } from '../query-batch'
|
|
19
|
+
import {
|
|
20
|
+
OptionDisplayRow,
|
|
21
|
+
formatRelativeDate,
|
|
22
|
+
formatTrailingValue,
|
|
23
|
+
getOptionDisplay,
|
|
24
|
+
toneVariant,
|
|
25
|
+
type OptionDisplayData,
|
|
26
|
+
} from '../option-display'
|
|
27
|
+
import { createFormatter } from '../business/format'
|
|
28
|
+
import { withOptionDisplays } from '../business/product-display'
|
|
29
|
+
import { ProductHitRow } from '../business/product-options'
|
|
30
|
+
import type { ActionFieldDef } from '../types'
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
invalidateOptionsCache()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const tire: OptionDisplayData = {
|
|
38
|
+
title: 'Llanta Michelin Primacy 4 205/55R16 91V',
|
|
39
|
+
subtitle: 'MIC-2055516 · 205/55R16',
|
|
40
|
+
image: null,
|
|
41
|
+
trailing: [
|
|
42
|
+
{ key: 'price', label: 'Precio', value: 1899.5, format: 'money' },
|
|
43
|
+
{ key: 'stock', label: 'Disp.', value: 8, format: 'number', tone: 'success' },
|
|
44
|
+
],
|
|
45
|
+
badges: [],
|
|
46
|
+
tone: 'success',
|
|
47
|
+
}
|
|
48
|
+
const oil: OptionDisplayData = {
|
|
49
|
+
title: 'Aceite 5W30',
|
|
50
|
+
subtitle: 'ACE-530',
|
|
51
|
+
trailing: [
|
|
52
|
+
{ key: 'price', label: 'Precio', value: 250, format: 'money' },
|
|
53
|
+
{ key: 'stock', label: 'Disp.', value: 0, format: 'number', tone: 'danger', text: 'Agotado' },
|
|
54
|
+
],
|
|
55
|
+
badges: [{ text: 'Servicio', tone: 'info' }],
|
|
56
|
+
tone: 'danger',
|
|
57
|
+
dimmed: true,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('formatTrailingValue', () => {
|
|
61
|
+
const fmt = createFormatter({ currency: 'MXN' })
|
|
62
|
+
it('money with the org currency (or the item own)', () => {
|
|
63
|
+
expect(formatTrailingValue({ key: 'p', value: 1899.5, format: 'money' }, fmt)).toBe('$1,899.50')
|
|
64
|
+
expect(formatTrailingValue({ key: 'p', value: 10, format: 'money', currency: 'USD' }, fmt)).toBe('$10.00')
|
|
65
|
+
})
|
|
66
|
+
it('number, integer, percent, text', () => {
|
|
67
|
+
expect(formatTrailingValue({ key: 'n', value: 1234.5, format: 'number' }, fmt)).toBe('1,234.5')
|
|
68
|
+
expect(formatTrailingValue({ key: 'n', value: '7.6', format: 'integer' }, fmt)).toBe('8')
|
|
69
|
+
expect(formatTrailingValue({ key: 'n', value: 0.16, format: 'percent' }, fmt)).toBe('16%')
|
|
70
|
+
expect(formatTrailingValue({ key: 'n', value: 16, format: 'percent' }, fmt)).toBe('16%')
|
|
71
|
+
expect(formatTrailingValue({ key: 'n', value: 'ACEPTADA', format: 'text' }, fmt)).toBe('ACEPTADA')
|
|
72
|
+
expect(formatTrailingValue({ key: 'n', value: null, format: 'money' }, fmt)).toBe('')
|
|
73
|
+
})
|
|
74
|
+
it('relative dates', () => {
|
|
75
|
+
const now = new Date('2026-10-07T12:00:00Z')
|
|
76
|
+
expect(formatRelativeDate('2026-10-04T12:00:00Z', 'es-MX', now)).toBe('hace 3 días')
|
|
77
|
+
expect(formatRelativeDate('2026-10-21T12:00:00Z', 'es-MX', now)).toBe('dentro de 2 semanas')
|
|
78
|
+
})
|
|
79
|
+
it('tone → badge variant', () => {
|
|
80
|
+
expect(toneVariant('danger')).toBe('danger')
|
|
81
|
+
expect(toneVariant('neutral')).toBe('muted')
|
|
82
|
+
expect(toneVariant(undefined)).toBe('muted')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('OptionDisplayRow', () => {
|
|
87
|
+
it('paints title, subtitle, metrics aligned with tabular numbers and the toned chip', () => {
|
|
88
|
+
render(
|
|
89
|
+
<CurrencyContext.Provider value="MXN">
|
|
90
|
+
<OptionDisplayRow display={oil} />
|
|
91
|
+
</CurrencyContext.Provider>,
|
|
92
|
+
)
|
|
93
|
+
expect(screen.getByText('Aceite 5W30')).toBeTruthy()
|
|
94
|
+
expect(screen.getByText('ACE-530')).toBeTruthy()
|
|
95
|
+
expect(screen.getByText('$250.00').className).toContain('tabular-nums')
|
|
96
|
+
const chip = screen.getByText('Agotado')
|
|
97
|
+
expect(chip.getAttribute('data-tone')).toBe('danger')
|
|
98
|
+
expect(screen.getByText('Servicio')).toBeTruthy()
|
|
99
|
+
const row = document.querySelector('[data-slot="option-display"]')!
|
|
100
|
+
expect(row.getAttribute('data-dimmed')).toBe('true')
|
|
101
|
+
expect(document.querySelectorAll('[data-slot="option-metric"]')).toHaveLength(1)
|
|
102
|
+
expect(document.querySelectorAll('[data-slot="option-metric-chip"]')).toHaveLength(1)
|
|
103
|
+
// Title clamps to two lines instead of truncating to one.
|
|
104
|
+
expect(document.querySelector('[data-slot="option-display-title"]')!.className).toContain('line-clamp-2')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('options wire: display + context', () => {
|
|
109
|
+
it('projectOption keeps display and leaves it out of meta', () => {
|
|
110
|
+
const o = projectOption({ id: 'p1', label: 'Llanta', display: tire, sku: 'X' })
|
|
111
|
+
expect(getOptionDisplay(o)).toEqual(tire)
|
|
112
|
+
expect(o.meta).toEqual({ sku: 'X' })
|
|
113
|
+
expect(getOptionDisplay(projectOption({ id: 'p2', label: 'Plain' }))).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
it('context is part of the cache key and the batch token', () => {
|
|
116
|
+
const a = optionsRequestKey('s', '/options/p', 'id', '', 20, undefined, { warehouse_id: 'W1' })
|
|
117
|
+
const b = optionsRequestKey('s', '/options/p', 'id', '', 20, undefined, { warehouse_id: 'W2' })
|
|
118
|
+
expect(a).not.toBe(b)
|
|
119
|
+
expect(optionsRequestKey('s', '/options/p', 'id', '', 20, undefined)).toBe(['s', '/options/p', 'id', '', '20', ''].join('\n'))
|
|
120
|
+
expect(optionsBatchToken('products.Product', 'id', 'll', 20, undefined, { warehouse_id: 'W1' })).toBe(
|
|
121
|
+
'o:products.Product?field=id&q=ll&limit=20&ctx.warehouse_id=W1',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
function host(rows: unknown[]) {
|
|
127
|
+
const get = vi.fn(async (_url: string, config?: { params?: Record<string, unknown> }) => {
|
|
128
|
+
const ids = config?.params?.ids
|
|
129
|
+
const data = typeof ids === 'string' ? rows.filter((r: any) => ids.split(',').includes(r.id)) : rows
|
|
130
|
+
return { data: { success: true, data, meta: { type: 'dynamic' } } }
|
|
131
|
+
})
|
|
132
|
+
const api = { get, post: vi.fn(async () => ({ data: {} })), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
|
|
133
|
+
return { api, get }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const product: ActionFieldDef = { key: 'product_id', label: 'Producto', type: 'dynamic_select', ref: 'products.Product' }
|
|
137
|
+
|
|
138
|
+
describe('DynamicSelectField with option_display', () => {
|
|
139
|
+
it('lists rich rows, sends the picker context and summarizes the selection in the trigger', async () => {
|
|
140
|
+
const h = host([
|
|
141
|
+
{ id: 'p1', value: 'p1', label: 'Llanta', display: tire },
|
|
142
|
+
{ id: 'p2', value: 'p2', label: 'Aceite', display: oil },
|
|
143
|
+
])
|
|
144
|
+
const onChange = vi.fn()
|
|
145
|
+
render(
|
|
146
|
+
<ApiProvider client={h.api}>
|
|
147
|
+
<DynamicSelectField field={product} value="" onChange={onChange} optionsContext={{ warehouse_id: 'W1', branch_id: '' }} />
|
|
148
|
+
</ApiProvider>,
|
|
149
|
+
)
|
|
150
|
+
await act(async () => {
|
|
151
|
+
;(screen.getByRole('combobox') as HTMLButtonElement).click()
|
|
152
|
+
})
|
|
153
|
+
await screen.findByText('Llanta Michelin Primacy 4 205/55R16 91V')
|
|
154
|
+
const params = h.get.mock.calls[0][1]?.params as Record<string, unknown>
|
|
155
|
+
expect(params['ctx.warehouse_id']).toBe('W1')
|
|
156
|
+
expect('ctx.branch_id' in params).toBe(false)
|
|
157
|
+
expect(screen.getByText('Agotado')).toBeTruthy()
|
|
158
|
+
// «Agotado» is dimmed but still selectable (not blocked).
|
|
159
|
+
const options = screen.getAllByRole('option')
|
|
160
|
+
expect(options[1].getAttribute('aria-disabled')).toBeNull()
|
|
161
|
+
await act(async () => {
|
|
162
|
+
options[1].click()
|
|
163
|
+
})
|
|
164
|
+
expect(onChange).toHaveBeenCalledWith('p2')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('a blocked option cannot be picked', async () => {
|
|
168
|
+
const h = host([{ id: 'p3', value: 'p3', label: 'Bloqueado', display: { ...oil, title: 'Bloqueado', blocked: true } }])
|
|
169
|
+
const onChange = vi.fn()
|
|
170
|
+
render(
|
|
171
|
+
<ApiProvider client={h.api}>
|
|
172
|
+
<DynamicSelectField field={product} value="" onChange={onChange} />
|
|
173
|
+
</ApiProvider>,
|
|
174
|
+
)
|
|
175
|
+
await act(async () => {
|
|
176
|
+
;(screen.getByRole('combobox') as HTMLButtonElement).click()
|
|
177
|
+
})
|
|
178
|
+
const opt = (await screen.findByText('Bloqueado')).closest('[role="option"]') as HTMLElement
|
|
179
|
+
expect(opt.getAttribute('aria-disabled')).toBe('true')
|
|
180
|
+
await act(async () => {
|
|
181
|
+
opt.click()
|
|
182
|
+
})
|
|
183
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('a held value shows the compact summary (title + toned metric)', async () => {
|
|
187
|
+
const h = host([{ id: 'p2', value: 'p2', label: 'Aceite', display: oil }])
|
|
188
|
+
render(
|
|
189
|
+
<ApiProvider client={h.api}>
|
|
190
|
+
<DynamicSelectField field={product} value="p2" onChange={vi.fn()} />
|
|
191
|
+
</ApiProvider>,
|
|
192
|
+
)
|
|
193
|
+
await waitFor(() => expect(document.querySelector('[data-slot="option-display-value"]')).toBeTruthy())
|
|
194
|
+
expect(screen.getByText('Aceite 5W30')).toBeTruthy()
|
|
195
|
+
expect(screen.getByText('Agotado')).toBeTruthy()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('without display the plain row stays', async () => {
|
|
199
|
+
const h = host([{ id: 'b1', value: 'b1', label: 'Michelin' }])
|
|
200
|
+
render(
|
|
201
|
+
<ApiProvider client={h.api}>
|
|
202
|
+
<DynamicSelectField field={{ ...product, ref: 'Brand' }} value="" onChange={vi.fn()} />
|
|
203
|
+
</ApiProvider>,
|
|
204
|
+
)
|
|
205
|
+
await act(async () => {
|
|
206
|
+
;(screen.getByRole('combobox') as HTMLButtonElement).click()
|
|
207
|
+
})
|
|
208
|
+
await screen.findByText('Michelin')
|
|
209
|
+
expect(document.querySelector('[data-slot="option-display"]')).toBeNull()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('withOptionDisplays (product line search)', () => {
|
|
214
|
+
const search = vi.fn(async () => [
|
|
215
|
+
{ id: 'P1', name: 'Llanta', price: 1899.5 },
|
|
216
|
+
{ id: 'p2', name: 'Aceite', price: 250 },
|
|
217
|
+
])
|
|
218
|
+
it('one ids lookup with the context, displays merged by id (case-insensitive)', async () => {
|
|
219
|
+
const h = host([{ id: 'p1', display: tire }, { id: 'p2', display: oil }])
|
|
220
|
+
const wrapped = withOptionDisplays(search, h.api as any, { model: 'products.Product', context: { warehouse_id: 'W1', branch_id: null } })
|
|
221
|
+
const out = await wrapped({ kind: 'text', raw: 'a', text: 'a' } as any, new AbortController().signal)
|
|
222
|
+
expect(h.get).toHaveBeenCalledTimes(1)
|
|
223
|
+
expect(h.get.mock.calls[0][0]).toBe('/options/products.Product')
|
|
224
|
+
expect(h.get.mock.calls[0][1]?.params).toEqual({ field: 'id', ids: 'P1,p2', 'ctx.warehouse_id': 'W1' })
|
|
225
|
+
expect(out[1].display).toEqual(oil)
|
|
226
|
+
expect(out[0].price).toBe(1899.5)
|
|
227
|
+
})
|
|
228
|
+
it('a failing lookup returns the results untouched', async () => {
|
|
229
|
+
const api = { get: vi.fn(async () => Promise.reject(new Error('501'))) }
|
|
230
|
+
const out = await withOptionDisplays(search, api as any, { model: 'products.Product' })({} as any, new AbortController().signal)
|
|
231
|
+
expect(out.map((r) => r.display)).toEqual([undefined, undefined])
|
|
232
|
+
})
|
|
233
|
+
it('ProductHitRow paints the display when the product carries one', () => {
|
|
234
|
+
render(<ProductHitRow hit={{ product: { id: 'p2', name: 'Aceite', display: oil } }} currency="MXN" />)
|
|
235
|
+
expect(screen.getByText('Agotado')).toBeTruthy()
|
|
236
|
+
cleanup()
|
|
237
|
+
render(<ProductHitRow hit={{ product: { id: 'p2', name: 'Aceite plano', sku: 'ACE' } }} currency="MXN" />)
|
|
238
|
+
expect(screen.getByText('Aceite plano')).toBeTruthy()
|
|
239
|
+
expect(document.querySelector('[data-slot="option-display"]')).toBeNull()
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -30,6 +30,7 @@ import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn }
|
|
|
30
30
|
import { computeTotals, serializeLineItems, taxBreakdown, type LineItem } from './line-items'
|
|
31
31
|
import { roundMoney, toAmount, useFormatter } from './format'
|
|
32
32
|
import { catalogRecordToProduct, createCatalogProductSearch } from './catalog-product-search'
|
|
33
|
+
import { withOptionDisplays } from './product-display'
|
|
33
34
|
import type { ProductResult } from './product-search'
|
|
34
35
|
import { requestRecordCreate, requestRecordEdit, withSearchPrefill } from '../record-picker-actions'
|
|
35
36
|
import {
|
|
@@ -183,9 +184,17 @@ export function DocumentEditor({
|
|
|
183
184
|
const relCache = useRef(new Map<string, Promise<any[]>>())
|
|
184
185
|
|
|
185
186
|
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
187
|
+
// The header's warehouse (when the document type declares one) scopes the
|
|
188
|
+
// stock the catalog's option_display shows in the product rows.
|
|
189
|
+
const headerWarehouse = typeof header.warehouse_id === 'string' ? header.warehouse_id : ''
|
|
186
190
|
const search = useMemo(
|
|
187
|
-
() =>
|
|
188
|
-
|
|
191
|
+
() =>
|
|
192
|
+
withOptionDisplays(
|
|
193
|
+
searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
|
|
194
|
+
api,
|
|
195
|
+
{ model: productModel ?? 'products.Product', context: { warehouse_id: headerWarehouse } },
|
|
196
|
+
),
|
|
197
|
+
[searchProducts, api, productModel, taxRate, headerWarehouse],
|
|
189
198
|
)
|
|
190
199
|
|
|
191
200
|
const isInstalled = useCallback((k?: string) => !k || !installed || installed.addons.has(k), [installed])
|
package/src/business/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export * from './vehicle-picker'
|
|
|
22
22
|
export * from './relate-documents'
|
|
23
23
|
export * from './print-send-dialog'
|
|
24
24
|
export * from './catalog-product-search'
|
|
25
|
+
export * from './product-display'
|
|
25
26
|
export * from './document-editor-model'
|
|
26
27
|
export * from './document-editor-panels'
|
|
27
28
|
export * from './payment-allocator'
|