@asteby/metacore-runtime-react 39.2.5 → 39.2.6
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 +8 -0
- package/dist/activity-value-renderer.js +5 -5
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +5 -6
- package/dist/dynamic-icon.d.ts +2 -0
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +36 -12
- package/dist/icon-picker-field.js +2 -2
- package/dist/use-options-resolver.d.ts +1 -10
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +90 -48
- package/package.json +3 -3
- package/src/__tests__/use-options-resolver.test.ts +9 -1
- package/src/activity-value-renderer.tsx +6 -6
- package/src/dynamic-columns.tsx +7 -8
- package/src/dynamic-icon.tsx +44 -12
- package/src/icon-picker-field.tsx +2 -2
- package/src/use-options-resolver.ts +103 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 39.2.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8629139: Los iconos de Lucide se cargan por glifo, las celdas de una columna de referencia comparten una sola petición de options, y la tile del POS pinta solo lo que entra en pantalla.
|
|
8
|
+
- Updated dependencies [8629139]
|
|
9
|
+
- @asteby/metacore-ui@2.18.2
|
|
10
|
+
|
|
3
11
|
## 39.2.5
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
|
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
* column-factory machinery — only React + metacore-ui primitives.
|
|
12
12
|
*/
|
|
13
13
|
import * as React from 'react';
|
|
14
|
-
import
|
|
14
|
+
import { Calendar, Check, ExternalLink, Mail, Minus } from 'lucide-react';
|
|
15
15
|
import { es, enUS } from 'date-fns/locale';
|
|
16
16
|
import { Badge, Avatar, AvatarImage, AvatarFallback, } from '@asteby/metacore-ui/primitives';
|
|
17
17
|
import { generateBadgeStyles, getInitials, optionColor, relationChipStyles } from '@asteby/metacore-ui/lib';
|
|
@@ -153,13 +153,13 @@ export const ActivityValueRenderer = ({ value, col, timeZone, currency, locale =
|
|
|
153
153
|
const formatted = formatDateCell(value, renderAs, dateLocale, timeZone);
|
|
154
154
|
if (!formatted)
|
|
155
155
|
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
156
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-1 text-sm text-muted-foreground", title: formatted.title, children: [_jsx(
|
|
156
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1 text-sm text-muted-foreground", title: formatted.title, children: [_jsx(Calendar, { className: "h-3 w-3 opacity-60" }), formatted.display] }));
|
|
157
157
|
}
|
|
158
158
|
// -----------------------------------------------------------------------
|
|
159
159
|
// Boolean
|
|
160
160
|
// -----------------------------------------------------------------------
|
|
161
161
|
if (renderAs === 'boolean') {
|
|
162
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [value ? (_jsx(
|
|
162
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [value ? (_jsx(Check, { className: "h-3.5 w-3.5 text-green-500" })) : (_jsx(Minus, { className: "h-3.5 w-3.5 text-muted-foreground" })), _jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' })] }));
|
|
163
163
|
}
|
|
164
164
|
// -----------------------------------------------------------------------
|
|
165
165
|
// Currency
|
|
@@ -219,14 +219,14 @@ export const ActivityValueRenderer = ({ value, col, timeZone, currency, locale =
|
|
|
219
219
|
catch {
|
|
220
220
|
label = urlStr;
|
|
221
221
|
}
|
|
222
|
-
return (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 text-sm text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(
|
|
222
|
+
return (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 text-sm text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(ExternalLink, { className: "h-3 w-3 shrink-0" }), _jsx("span", { className: "truncate", style: { maxWidth: 200 }, children: label })] }));
|
|
223
223
|
}
|
|
224
224
|
// -----------------------------------------------------------------------
|
|
225
225
|
// Email
|
|
226
226
|
// -----------------------------------------------------------------------
|
|
227
227
|
if (renderAs === 'email') {
|
|
228
228
|
const email = String(value);
|
|
229
|
-
return (_jsxs("a", { href: `mailto:${email}`, className: "inline-flex items-center gap-1 text-sm text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(
|
|
229
|
+
return (_jsxs("a", { href: `mailto:${email}`, className: "inline-flex items-center gap-1 text-sm text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(Mail, { className: "h-3 w-3 shrink-0" }), _jsx("span", { className: "truncate", style: { maxWidth: 200 }, children: email })] }));
|
|
230
230
|
}
|
|
231
231
|
// -----------------------------------------------------------------------
|
|
232
232
|
// Relation chip (FK / reference)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -15,8 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
15
15
|
import * as React from 'react';
|
|
16
16
|
import { format } from 'date-fns';
|
|
17
17
|
import { es, enUS } from 'date-fns/locale';
|
|
18
|
-
import
|
|
19
|
-
import { MoreHorizontal } from 'lucide-react';
|
|
18
|
+
import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react';
|
|
20
19
|
import { Avatar, AvatarFallback, AvatarImage, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, InitialsAvatar, } from '@asteby/metacore-ui';
|
|
21
20
|
import { DataTableColumnHeader, FilterableColumnHeader, } from '@asteby/metacore-ui/data-table';
|
|
22
21
|
import { generateBadgeStyles, getInitials, relationChipStyles, } from '@asteby/metacore-ui/lib';
|
|
@@ -106,7 +105,7 @@ const CodeCell = ({ text, maxLength }) => {
|
|
|
106
105
|
/* clipboard unavailable */
|
|
107
106
|
}
|
|
108
107
|
};
|
|
109
|
-
return (_jsxs("div", { className: "group flex items-center gap-1.5", children: [_jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/80", title: text, children: display }), _jsx("button", { type: "button", onClick: onCopy, className: "opacity-0 transition-opacity group-hover:opacity-100 text-muted-foreground hover:text-foreground", "aria-label": "Copiar", title: "Copiar", children: copied ? (_jsx(
|
|
108
|
+
return (_jsxs("div", { className: "group flex items-center gap-1.5", children: [_jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/80", title: text, children: display }), _jsx("button", { type: "button", onClick: onCopy, className: "opacity-0 transition-opacity group-hover:opacity-100 text-muted-foreground hover:text-foreground", "aria-label": "Copiar", title: "Copiar", children: copied ? (_jsx(Check, { className: "h-3.5 w-3.5 text-green-500" })) : (_jsx(Copy, { className: "h-3.5 w-3.5" })) })] }));
|
|
110
109
|
};
|
|
111
110
|
/**
|
|
112
111
|
* Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
|
|
@@ -762,7 +761,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
762
761
|
const formatted = formatDateCell(value, renderAs, dateLocale, timeZone);
|
|
763
762
|
if (!formatted)
|
|
764
763
|
return _jsx("span", { className: "text-muted-foreground", children: "-" });
|
|
765
|
-
return (_jsxs("div", { className: "flex items-center gap-1.5 text-muted-foreground", title: formatted.title, children: [_jsx(
|
|
764
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 text-muted-foreground", title: formatted.title, children: [_jsx(Calendar, { className: "h-3.5 w-3.5 opacity-70" }), _jsx("span", { className: "text-sm font-medium", children: formatted.display })] }));
|
|
766
765
|
}
|
|
767
766
|
case 'search':
|
|
768
767
|
case 'avatar':
|
|
@@ -819,7 +818,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
819
818
|
if (!value)
|
|
820
819
|
return _jsx(EmptyCell, {});
|
|
821
820
|
const email = String(value);
|
|
822
|
-
return (_jsxs("a", { href: `mailto:${email}`, className: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(
|
|
821
|
+
return (_jsxs("a", { href: `mailto:${email}`, className: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline", onClick: (e) => e.stopPropagation(), children: [_jsx(Mail, { className: "h-3.5 w-3.5 shrink-0 opacity-70" }), _jsx("span", { className: "truncate max-w-[260px]", children: email })] }));
|
|
823
822
|
}
|
|
824
823
|
case 'currency': {
|
|
825
824
|
const num = typeof value === 'number' ? value : Number(value);
|
|
@@ -909,7 +908,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
909
908
|
}
|
|
910
909
|
case 'boolean': {
|
|
911
910
|
const showText = styleCfg(col, 'show_text', 'showText') !== false;
|
|
912
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [value ? (_jsx(
|
|
911
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [value ? (_jsx(Check, { className: "h-4 w-4 text-green-500" })) : (_jsx(Minus, { className: "h-4 w-4 text-muted-foreground" })), showText && (_jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' }))] }));
|
|
913
912
|
}
|
|
914
913
|
case 'media-gallery': {
|
|
915
914
|
if (!value || (Array.isArray(value) && value.length === 0)) {
|
package/dist/dynamic-icon.d.ts
CHANGED
|
@@ -5,4 +5,6 @@ export interface DynamicIconProps {
|
|
|
5
5
|
export declare function DynamicIcon({ name, className }: DynamicIconProps): import("react").JSX.Element | null;
|
|
6
6
|
export declare function resolveLucideIconName(value: unknown): string | null;
|
|
7
7
|
export declare function isLucideIconName(value: unknown): value is string;
|
|
8
|
+
/** PascalCase names of every lucide glyph, for the icon picker grid. */
|
|
9
|
+
export declare function lucideIconNames(): string[];
|
|
8
10
|
//# sourceMappingURL=dynamic-icon.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-icon.d.ts","sourceRoot":"","sources":["../src/dynamic-icon.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;
|
|
1
|
+
{"version":3,"file":"dynamic-icon.d.ts","sourceRoot":"","sources":["../src/dynamic-icon.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AAuBD,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,gBAAgB,sCAchE;AAMD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CASnE;AASD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEhE;AAED,wEAAwE;AACxE,wBAAgB,eAAe,IAAI,MAAM,EAAE,CAE1C"}
|
package/dist/dynamic-icon.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import
|
|
2
|
+
// DynamicIcon resolves a lucide glyph by name without pulling the full icon
|
|
3
|
+
// set into the shared chunk. Each glyph is its own dynamic import.
|
|
4
|
+
import { lazy, Suspense, useMemo } from 'react';
|
|
5
|
+
import dynamicIconImports from 'lucide-react/dynamicIconImports';
|
|
6
|
+
function pascalToKebab(pascal) {
|
|
7
|
+
return pascal
|
|
8
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
9
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
10
|
+
.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function kebabToPascal(kebab) {
|
|
13
|
+
return kebab
|
|
14
|
+
.split('-')
|
|
15
|
+
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ''))
|
|
16
|
+
.join('');
|
|
17
|
+
}
|
|
18
|
+
function loaderFor(pascal) {
|
|
19
|
+
const kebab = pascalToKebab(pascal);
|
|
20
|
+
return dynamicIconImports[kebab];
|
|
21
|
+
}
|
|
6
22
|
export function DynamicIcon({ name, className }) {
|
|
7
|
-
const resolved = resolveLucideIconName(name)
|
|
8
|
-
const Icon =
|
|
23
|
+
const resolved = resolveLucideIconName(name);
|
|
24
|
+
const Icon = useMemo(() => {
|
|
25
|
+
if (!resolved)
|
|
26
|
+
return null;
|
|
27
|
+
const loader = loaderFor(resolved);
|
|
28
|
+
if (!loader)
|
|
29
|
+
return null;
|
|
30
|
+
return lazy(loader);
|
|
31
|
+
}, [resolved]);
|
|
9
32
|
if (!Icon)
|
|
10
33
|
return null;
|
|
11
|
-
return _jsx(Icon, { className: className });
|
|
34
|
+
return (_jsx(Suspense, { fallback: null, children: _jsx(Icon, { className: className }) }));
|
|
12
35
|
}
|
|
13
36
|
// resolveLucideIconName — canonical PascalCase lucide name for a value that is
|
|
14
37
|
// either already PascalCase ("CreditCard") or the kebab slug lucide documents
|
|
@@ -21,14 +44,11 @@ export function resolveLucideIconName(value) {
|
|
|
21
44
|
return null;
|
|
22
45
|
let name = value;
|
|
23
46
|
if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
|
24
|
-
name = value
|
|
25
|
-
.split('-')
|
|
26
|
-
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
27
|
-
.join('');
|
|
47
|
+
name = kebabToPascal(value);
|
|
28
48
|
}
|
|
29
49
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name))
|
|
30
50
|
return null;
|
|
31
|
-
return
|
|
51
|
+
return loaderFor(name) ? name : null;
|
|
32
52
|
}
|
|
33
53
|
// isLucideIconName — true when a string is a lucide-react icon name
|
|
34
54
|
// ("Banknote", "CreditCard", or the kebab slug "credit-card"). Lets image-ish
|
|
@@ -40,3 +60,7 @@ export function resolveLucideIconName(value) {
|
|
|
40
60
|
export function isLucideIconName(value) {
|
|
41
61
|
return resolveLucideIconName(value) !== null;
|
|
42
62
|
}
|
|
63
|
+
/** PascalCase names of every lucide glyph, for the icon picker grid. */
|
|
64
|
+
export function lucideIconNames() {
|
|
65
|
+
return Object.keys(dynamicIconImports).map(kebabToPascal);
|
|
66
|
+
}
|
|
@@ -13,15 +13,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
13
13
|
// initial mode is inferred from the current value — path-like ("/" or ".")
|
|
14
14
|
// means image, anything else means icon.
|
|
15
15
|
import { useMemo, useState } from 'react';
|
|
16
|
-
import { icons } from 'lucide-react';
|
|
17
16
|
import { ChevronsUpDown } from 'lucide-react';
|
|
17
|
+
import { lucideIconNames } from './dynamic-icon';
|
|
18
18
|
import { Button, Input, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
|
|
19
19
|
import { DynamicIcon, resolveLucideIconName } from './dynamic-icon';
|
|
20
20
|
import { UploadField } from './upload-field';
|
|
21
21
|
/** Icons rendered per "page" — grows as the user scrolls to the bottom. Keeps
|
|
22
22
|
* the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
|
|
23
23
|
const PAGE = 60;
|
|
24
|
-
const ALL_ICON_NAMES =
|
|
24
|
+
const ALL_ICON_NAMES = lucideIconNames();
|
|
25
25
|
/** True when a stored value looks like an image url/path rather than an icon name. */
|
|
26
26
|
export function looksLikeImageValue(value) {
|
|
27
27
|
return typeof value === 'string' && value !== '' && /[/.]/.test(value);
|
|
@@ -76,16 +76,7 @@ export interface UseOptionsResolverResult {
|
|
|
76
76
|
/** Forces a refetch. Useful after a parent record updates. */
|
|
77
77
|
refetch: () => void;
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
* Resolves select options for a field via the canonical
|
|
81
|
-
* `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
|
|
82
|
-
* `{ data, meta: { type, count } }` projected into a stable shape.
|
|
83
|
-
*
|
|
84
|
-
* The hook is intentionally minimal: it does NOT debounce `query`
|
|
85
|
-
* (callers should hold the controlled value and pass it post-debounce)
|
|
86
|
-
* and does NOT cache across hook instances (apps that need shared state
|
|
87
|
-
* compose this with TanStack Query in their own layer).
|
|
88
|
-
*/
|
|
79
|
+
export declare function optionsRequestKey(scope: string, url: string, field: string, query: string, limit: number | undefined, filter: string | undefined): string;
|
|
89
80
|
export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
|
|
90
81
|
/**
|
|
91
82
|
* Normalizes the wire shape into ResolvedOption. The kernel returns dual
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;
|
|
1
|
+
{"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAoBD,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAER;AA8BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CAkIzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
|
|
@@ -17,11 +17,46 @@ import { useApi } from './api-context';
|
|
|
17
17
|
* `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
|
|
18
18
|
* `{ data, meta: { type, count } }` projected into a stable shape.
|
|
19
19
|
*
|
|
20
|
-
* The hook
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The hook does NOT debounce `query` (callers should pass it post-debounce).
|
|
21
|
+
* Identical lookups share one in-flight request and a 30s cache, so a column
|
|
22
|
+
* of reference cells does not fan out one request per row. The key includes
|
|
23
|
+
* the org and branch from the host session.
|
|
24
24
|
*/
|
|
25
|
+
const OPTIONS_TTL_MS = 30_000;
|
|
26
|
+
const optionsCache = new Map();
|
|
27
|
+
const optionsInflight = new Map();
|
|
28
|
+
const optionsEpoch = new Map();
|
|
29
|
+
export function optionsRequestKey(scope, url, field, query, limit, filter) {
|
|
30
|
+
return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n');
|
|
31
|
+
}
|
|
32
|
+
function optionsScope() {
|
|
33
|
+
if (typeof localStorage === 'undefined')
|
|
34
|
+
return '';
|
|
35
|
+
let org = '';
|
|
36
|
+
try {
|
|
37
|
+
const raw = localStorage.getItem('auth_user');
|
|
38
|
+
if (raw)
|
|
39
|
+
org = String(JSON.parse(raw).organization_id ?? '');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
org = '';
|
|
43
|
+
}
|
|
44
|
+
const branch = localStorage.getItem('active_branch_id') ?? '';
|
|
45
|
+
return `${org}|${branch}`;
|
|
46
|
+
}
|
|
47
|
+
function readOptionsEnvelope(body) {
|
|
48
|
+
const rawOptions = Array.isArray(body?.data) ? body.data : [];
|
|
49
|
+
const metaPayload = body?.meta && typeof body.meta === 'object'
|
|
50
|
+
? body.meta
|
|
51
|
+
: { type: body?.type, count: rawOptions.length };
|
|
52
|
+
return {
|
|
53
|
+
options: rawOptions.map(projectOption),
|
|
54
|
+
meta: {
|
|
55
|
+
type: metaPayload?.type ?? 'dynamic',
|
|
56
|
+
count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
25
60
|
export function useOptionsResolver(args) {
|
|
26
61
|
const { modelKey, fieldKey, ref, query, limit, enabled = true, endpoint, filterValue, } = args;
|
|
27
62
|
const api = useApi();
|
|
@@ -55,9 +90,7 @@ export function useOptionsResolver(args) {
|
|
|
55
90
|
return 'id';
|
|
56
91
|
return '';
|
|
57
92
|
}, [fieldKey, ref]);
|
|
58
|
-
|
|
59
|
-
// previous one — matters for typeahead callers passing changing `query`.
|
|
60
|
-
const abortRef = useRef(null);
|
|
93
|
+
const seenRefresh = useRef(0);
|
|
61
94
|
useEffect(() => {
|
|
62
95
|
if (!enabled || !url || !effectiveField) {
|
|
63
96
|
setOptions([]);
|
|
@@ -66,61 +99,70 @@ export function useOptionsResolver(args) {
|
|
|
66
99
|
setError(null);
|
|
67
100
|
return;
|
|
68
101
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
const key = optionsRequestKey(optionsScope(), url, effectiveField, query ?? '', limit, filterValue);
|
|
103
|
+
if (refreshKey !== seenRefresh.current) {
|
|
104
|
+
seenRefresh.current = refreshKey;
|
|
105
|
+
optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1);
|
|
106
|
+
optionsCache.delete(key);
|
|
107
|
+
optionsInflight.delete(key);
|
|
108
|
+
}
|
|
109
|
+
const epoch = optionsEpoch.get(key) ?? 0;
|
|
110
|
+
const cached = optionsCache.get(key);
|
|
111
|
+
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
112
|
+
setOptions(cached.payload.options);
|
|
113
|
+
setMeta(cached.payload.meta);
|
|
114
|
+
setLoading(false);
|
|
115
|
+
setError(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
73
118
|
setLoading(true);
|
|
74
119
|
setError(null);
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
params
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
params.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// older deployments that still emit a root-level `type`
|
|
98
|
-
// by reading either spot — the projection prefers the
|
|
99
|
-
// canonical location so the SDK guides apps to the new
|
|
100
|
-
// shape without breaking grace-period upgrades.
|
|
101
|
-
const metaPayload = body.meta && typeof body.meta === 'object'
|
|
102
|
-
? body.meta
|
|
103
|
-
: { type: body.type, count: rawOptions.length };
|
|
104
|
-
setMeta({
|
|
105
|
-
type: metaPayload?.type ?? 'dynamic',
|
|
106
|
-
count: typeof metaPayload?.count === 'number'
|
|
107
|
-
? metaPayload.count
|
|
108
|
-
: rawOptions.length,
|
|
120
|
+
let pending = optionsInflight.get(key);
|
|
121
|
+
if (!pending) {
|
|
122
|
+
const params = { field: effectiveField };
|
|
123
|
+
if (query)
|
|
124
|
+
params.q = query;
|
|
125
|
+
if (typeof limit === 'number' && limit > 0)
|
|
126
|
+
params.limit = limit;
|
|
127
|
+
if (filterValue)
|
|
128
|
+
params.filter_value = filterValue;
|
|
129
|
+
pending = api.get(url, { params }).then((res) => {
|
|
130
|
+
const body = res.data;
|
|
131
|
+
if (!body || body.success !== true) {
|
|
132
|
+
throw new Error(body?.message || 'options resolver: unsuccessful response');
|
|
133
|
+
}
|
|
134
|
+
const payload = readOptionsEnvelope(body);
|
|
135
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
136
|
+
optionsCache.set(key, { payload, at: Date.now() });
|
|
137
|
+
}
|
|
138
|
+
return payload;
|
|
139
|
+
}).finally(() => {
|
|
140
|
+
if (optionsInflight.get(key) === pending)
|
|
141
|
+
optionsInflight.delete(key);
|
|
109
142
|
});
|
|
143
|
+
optionsInflight.set(key, pending);
|
|
144
|
+
}
|
|
145
|
+
let cancelled = false;
|
|
146
|
+
pending
|
|
147
|
+
.then((payload) => {
|
|
148
|
+
if (cancelled)
|
|
149
|
+
return;
|
|
150
|
+
setOptions(payload.options);
|
|
151
|
+
setMeta(payload.meta);
|
|
110
152
|
})
|
|
111
153
|
.catch((err) => {
|
|
112
|
-
if (
|
|
154
|
+
if (cancelled)
|
|
113
155
|
return;
|
|
114
156
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
115
157
|
setOptions([]);
|
|
116
158
|
setMeta(null);
|
|
117
159
|
})
|
|
118
160
|
.finally(() => {
|
|
119
|
-
if (!
|
|
161
|
+
if (!cancelled)
|
|
120
162
|
setLoading(false);
|
|
121
163
|
});
|
|
122
164
|
return () => {
|
|
123
|
-
|
|
165
|
+
cancelled = true;
|
|
124
166
|
};
|
|
125
167
|
}, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey]);
|
|
126
168
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "39.2.
|
|
3
|
+
"version": "39.2.6",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@asteby/metacore-sdk": "^3.8.0",
|
|
30
|
-
"@asteby/metacore-ui": "^2.18.
|
|
30
|
+
"@asteby/metacore-ui": "^2.18.2",
|
|
31
31
|
"@tanstack/react-query": ">=5",
|
|
32
32
|
"@tanstack/react-router": ">=1.100",
|
|
33
33
|
"@tanstack/react-table": ">=8",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@asteby/metacore-sdk": "3.8.0",
|
|
54
|
-
"@asteby/metacore-ui": "2.18.
|
|
54
|
+
"@asteby/metacore-ui": "2.18.2",
|
|
55
55
|
"@tanstack/react-query": "^5.95.0",
|
|
56
56
|
"@tanstack/react-router": "^1.168.0",
|
|
57
57
|
"@tanstack/react-table": "^8.20.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { projectOption } from '../use-options-resolver'
|
|
2
|
+
import { optionsRequestKey, projectOption } from '../use-options-resolver'
|
|
3
3
|
import {
|
|
4
4
|
resolveValidatorToken,
|
|
5
5
|
setOrgConfigBridge,
|
|
@@ -11,6 +11,14 @@ import {
|
|
|
11
11
|
// hook performs) so consumers can rely on the v0.9.0 envelope reading
|
|
12
12
|
// without spinning up a renderer.
|
|
13
13
|
|
|
14
|
+
describe('optionsRequestKey', () => {
|
|
15
|
+
it('keeps two orgs from sharing a column cache', () => {
|
|
16
|
+
const a = optionsRequestKey('org-a|branch', '/options/Product', 'id', '', 200, undefined)
|
|
17
|
+
const b = optionsRequestKey('org-b|branch', '/options/Product', 'id', '', 200, undefined)
|
|
18
|
+
expect(a).not.toBe(b)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
14
22
|
describe('projectOption', () => {
|
|
15
23
|
it('mirrors id into value and label into name when missing', () => {
|
|
16
24
|
const out = projectOption({ id: 'abc', label: 'Hello' })
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as React from 'react'
|
|
14
|
-
import
|
|
14
|
+
import { Calendar, Check, ExternalLink, Mail, Minus } from 'lucide-react'
|
|
15
15
|
import { es, enUS } from 'date-fns/locale'
|
|
16
16
|
import {
|
|
17
17
|
Badge,
|
|
@@ -231,7 +231,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
|
|
|
231
231
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
|
|
232
232
|
title={formatted.title}
|
|
233
233
|
>
|
|
234
|
-
<
|
|
234
|
+
<Calendar className="h-3 w-3 opacity-60" />
|
|
235
235
|
{formatted.display}
|
|
236
236
|
</span>
|
|
237
237
|
)
|
|
@@ -245,9 +245,9 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
|
|
|
245
245
|
return (
|
|
246
246
|
<span className="inline-flex items-center gap-1">
|
|
247
247
|
{value ? (
|
|
248
|
-
<
|
|
248
|
+
<Check className="h-3.5 w-3.5 text-green-500" />
|
|
249
249
|
) : (
|
|
250
|
-
<
|
|
250
|
+
<Minus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
251
251
|
)}
|
|
252
252
|
<span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
|
|
253
253
|
</span>
|
|
@@ -350,7 +350,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
|
|
|
350
350
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
351
351
|
onClick={(e) => e.stopPropagation()}
|
|
352
352
|
>
|
|
353
|
-
<
|
|
353
|
+
<ExternalLink className="h-3 w-3 shrink-0" />
|
|
354
354
|
<span className="truncate" style={{ maxWidth: 200 }}>{label}</span>
|
|
355
355
|
</a>
|
|
356
356
|
)
|
|
@@ -368,7 +368,7 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
|
|
|
368
368
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
369
369
|
onClick={(e) => e.stopPropagation()}
|
|
370
370
|
>
|
|
371
|
-
<
|
|
371
|
+
<Mail className="h-3 w-3 shrink-0" />
|
|
372
372
|
<span className="truncate" style={{ maxWidth: 200 }}>{email}</span>
|
|
373
373
|
</a>
|
|
374
374
|
)
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -16,8 +16,7 @@ import * as React from 'react'
|
|
|
16
16
|
import { ColumnDef } from '@tanstack/react-table'
|
|
17
17
|
import { format, type Locale } from 'date-fns'
|
|
18
18
|
import { es, enUS } from 'date-fns/locale'
|
|
19
|
-
import
|
|
20
|
-
import { MoreHorizontal } from 'lucide-react'
|
|
19
|
+
import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
|
|
21
20
|
import {
|
|
22
21
|
Avatar,
|
|
23
22
|
AvatarFallback,
|
|
@@ -198,9 +197,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
|
|
|
198
197
|
title="Copiar"
|
|
199
198
|
>
|
|
200
199
|
{copied ? (
|
|
201
|
-
<
|
|
200
|
+
<Check className="h-3.5 w-3.5 text-green-500" />
|
|
202
201
|
) : (
|
|
203
|
-
<
|
|
202
|
+
<Copy className="h-3.5 w-3.5" />
|
|
204
203
|
)}
|
|
205
204
|
</button>
|
|
206
205
|
</div>
|
|
@@ -1082,7 +1081,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1082
1081
|
className="flex items-center gap-1.5 text-muted-foreground"
|
|
1083
1082
|
title={formatted.title}
|
|
1084
1083
|
>
|
|
1085
|
-
<
|
|
1084
|
+
<Calendar className="h-3.5 w-3.5 opacity-70" />
|
|
1086
1085
|
<span className="text-sm font-medium">
|
|
1087
1086
|
{formatted.display}
|
|
1088
1087
|
</span>
|
|
@@ -1174,7 +1173,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1174
1173
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
1175
1174
|
onClick={(e) => e.stopPropagation()}
|
|
1176
1175
|
>
|
|
1177
|
-
<
|
|
1176
|
+
<Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
|
1178
1177
|
<span className="truncate max-w-[260px]">{email}</span>
|
|
1179
1178
|
</a>
|
|
1180
1179
|
)
|
|
@@ -1337,9 +1336,9 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1337
1336
|
return (
|
|
1338
1337
|
<span className="inline-flex items-center gap-1.5">
|
|
1339
1338
|
{value ? (
|
|
1340
|
-
<
|
|
1339
|
+
<Check className="h-4 w-4 text-green-500" />
|
|
1341
1340
|
) : (
|
|
1342
|
-
<
|
|
1341
|
+
<Minus className="h-4 w-4 text-muted-foreground" />
|
|
1343
1342
|
)}
|
|
1344
1343
|
{showText && (
|
|
1345
1344
|
<span className="text-sm text-muted-foreground">
|
package/src/dynamic-icon.tsx
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import
|
|
1
|
+
// DynamicIcon resolves a lucide glyph by name without pulling the full icon
|
|
2
|
+
// set into the shared chunk. Each glyph is its own dynamic import.
|
|
3
|
+
import { lazy, Suspense, useMemo, type ComponentType } from 'react'
|
|
4
|
+
import dynamicIconImports from 'lucide-react/dynamicIconImports'
|
|
5
5
|
|
|
6
6
|
export interface DynamicIconProps {
|
|
7
7
|
name: string
|
|
8
8
|
className?: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
type IconName = keyof typeof dynamicIconImports
|
|
12
|
+
|
|
13
|
+
function pascalToKebab(pascal: string): string {
|
|
14
|
+
return pascal
|
|
15
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
16
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function kebabToPascal(kebab: string): string {
|
|
21
|
+
return kebab
|
|
22
|
+
.split('-')
|
|
23
|
+
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ''))
|
|
24
|
+
.join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loaderFor(pascal: string) {
|
|
28
|
+
const kebab = pascalToKebab(pascal) as IconName
|
|
29
|
+
return dynamicIconImports[kebab]
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
export function DynamicIcon({ name, className }: DynamicIconProps) {
|
|
12
|
-
const resolved = resolveLucideIconName(name)
|
|
13
|
-
const Icon = (
|
|
33
|
+
const resolved = resolveLucideIconName(name)
|
|
34
|
+
const Icon = useMemo(() => {
|
|
35
|
+
if (!resolved) return null
|
|
36
|
+
const loader = loaderFor(resolved)
|
|
37
|
+
if (!loader) return null
|
|
38
|
+
return lazy(loader as () => Promise<{ default: ComponentType<{ className?: string }> }>)
|
|
39
|
+
}, [resolved])
|
|
14
40
|
if (!Icon) return null
|
|
15
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
<Suspense fallback={null}>
|
|
43
|
+
<Icon className={className} />
|
|
44
|
+
</Suspense>
|
|
45
|
+
)
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
// resolveLucideIconName — canonical PascalCase lucide name for a value that is
|
|
@@ -24,13 +54,10 @@ export function resolveLucideIconName(value: unknown): string | null {
|
|
|
24
54
|
if (/[/\\.:\s]/.test(value)) return null
|
|
25
55
|
let name = value
|
|
26
56
|
if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
|
|
27
|
-
name = value
|
|
28
|
-
.split('-')
|
|
29
|
-
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
30
|
-
.join('')
|
|
57
|
+
name = kebabToPascal(value)
|
|
31
58
|
}
|
|
32
59
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) return null
|
|
33
|
-
return (
|
|
60
|
+
return loaderFor(name) ? name : null
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
// isLucideIconName — true when a string is a lucide-react icon name
|
|
@@ -43,3 +70,8 @@ export function resolveLucideIconName(value: unknown): string | null {
|
|
|
43
70
|
export function isLucideIconName(value: unknown): value is string {
|
|
44
71
|
return resolveLucideIconName(value) !== null
|
|
45
72
|
}
|
|
73
|
+
|
|
74
|
+
/** PascalCase names of every lucide glyph, for the icon picker grid. */
|
|
75
|
+
export function lucideIconNames(): string[] {
|
|
76
|
+
return Object.keys(dynamicIconImports).map(kebabToPascal)
|
|
77
|
+
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// initial mode is inferred from the current value — path-like ("/" or ".")
|
|
13
13
|
// means image, anything else means icon.
|
|
14
14
|
import { useMemo, useState } from 'react'
|
|
15
|
-
import { icons } from 'lucide-react'
|
|
16
15
|
import { ChevronsUpDown } from 'lucide-react'
|
|
16
|
+
import { lucideIconNames } from './dynamic-icon'
|
|
17
17
|
import {
|
|
18
18
|
Button,
|
|
19
19
|
Input,
|
|
@@ -35,7 +35,7 @@ export interface IconPickerFieldProps {
|
|
|
35
35
|
* the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
|
|
36
36
|
const PAGE = 60
|
|
37
37
|
|
|
38
|
-
const ALL_ICON_NAMES =
|
|
38
|
+
const ALL_ICON_NAMES = lucideIconNames()
|
|
39
39
|
|
|
40
40
|
/** True when a stored value looks like an image url/path rather than an icon name. */
|
|
41
41
|
export function looksLikeImageValue(value: unknown): boolean {
|
|
@@ -100,11 +100,58 @@ export interface UseOptionsResolverResult {
|
|
|
100
100
|
* `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
|
|
101
101
|
* `{ data, meta: { type, count } }` projected into a stable shape.
|
|
102
102
|
*
|
|
103
|
-
* The hook
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
103
|
+
* The hook does NOT debounce `query` (callers should pass it post-debounce).
|
|
104
|
+
* Identical lookups share one in-flight request and a 30s cache, so a column
|
|
105
|
+
* of reference cells does not fan out one request per row. The key includes
|
|
106
|
+
* the org and branch from the host session.
|
|
107
107
|
*/
|
|
108
|
+
const OPTIONS_TTL_MS = 30_000
|
|
109
|
+
|
|
110
|
+
type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
|
|
111
|
+
|
|
112
|
+
const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
|
|
113
|
+
const optionsInflight = new Map<string, Promise<OptionsPayload>>()
|
|
114
|
+
const optionsEpoch = new Map<string, number>()
|
|
115
|
+
|
|
116
|
+
export function optionsRequestKey(
|
|
117
|
+
scope: string,
|
|
118
|
+
url: string,
|
|
119
|
+
field: string,
|
|
120
|
+
query: string,
|
|
121
|
+
limit: number | undefined,
|
|
122
|
+
filter: string | undefined,
|
|
123
|
+
): string {
|
|
124
|
+
return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function optionsScope(): string {
|
|
128
|
+
if (typeof localStorage === 'undefined') return ''
|
|
129
|
+
let org = ''
|
|
130
|
+
try {
|
|
131
|
+
const raw = localStorage.getItem('auth_user')
|
|
132
|
+
if (raw) org = String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
|
|
133
|
+
} catch {
|
|
134
|
+
org = ''
|
|
135
|
+
}
|
|
136
|
+
const branch = localStorage.getItem('active_branch_id') ?? ''
|
|
137
|
+
return `${org}|${branch}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readOptionsEnvelope(body: any): OptionsPayload {
|
|
141
|
+
const rawOptions: any[] = Array.isArray(body?.data) ? body.data : []
|
|
142
|
+
const metaPayload =
|
|
143
|
+
body?.meta && typeof body.meta === 'object'
|
|
144
|
+
? body.meta
|
|
145
|
+
: { type: body?.type, count: rawOptions.length }
|
|
146
|
+
return {
|
|
147
|
+
options: rawOptions.map(projectOption),
|
|
148
|
+
meta: {
|
|
149
|
+
type: metaPayload?.type ?? 'dynamic',
|
|
150
|
+
count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
108
155
|
export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult {
|
|
109
156
|
const {
|
|
110
157
|
modelKey,
|
|
@@ -146,9 +193,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
146
193
|
return ''
|
|
147
194
|
}, [fieldKey, ref])
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
// previous one — matters for typeahead callers passing changing `query`.
|
|
151
|
-
const abortRef = useRef<AbortController | null>(null)
|
|
196
|
+
const seenRefresh = useRef(0)
|
|
152
197
|
|
|
153
198
|
useEffect(() => {
|
|
154
199
|
if (!enabled || !url || !effectiveField) {
|
|
@@ -158,61 +203,75 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
158
203
|
setError(null)
|
|
159
204
|
return
|
|
160
205
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
206
|
+
|
|
207
|
+
const key = optionsRequestKey(
|
|
208
|
+
optionsScope(),
|
|
209
|
+
url,
|
|
210
|
+
effectiveField,
|
|
211
|
+
query ?? '',
|
|
212
|
+
limit,
|
|
213
|
+
filterValue,
|
|
214
|
+
)
|
|
215
|
+
if (refreshKey !== seenRefresh.current) {
|
|
216
|
+
seenRefresh.current = refreshKey
|
|
217
|
+
optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1)
|
|
218
|
+
optionsCache.delete(key)
|
|
219
|
+
optionsInflight.delete(key)
|
|
220
|
+
}
|
|
221
|
+
const epoch = optionsEpoch.get(key) ?? 0
|
|
222
|
+
const cached = optionsCache.get(key)
|
|
223
|
+
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
224
|
+
setOptions(cached.payload.options)
|
|
225
|
+
setMeta(cached.payload.meta)
|
|
226
|
+
setLoading(false)
|
|
227
|
+
setError(null)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
165
230
|
|
|
166
231
|
setLoading(true)
|
|
167
232
|
setError(null)
|
|
168
233
|
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
api.get(url, { params, signal: controller.signal })
|
|
178
|
-
.then((res) => {
|
|
179
|
-
if (controller.signal.aborted) return
|
|
234
|
+
let pending = optionsInflight.get(key)
|
|
235
|
+
if (!pending) {
|
|
236
|
+
const params: Record<string, string | number> = { field: effectiveField }
|
|
237
|
+
if (query) params.q = query
|
|
238
|
+
if (typeof limit === 'number' && limit > 0) params.limit = limit
|
|
239
|
+
if (filterValue) params.filter_value = filterValue
|
|
240
|
+
pending = api.get(url, { params }).then((res) => {
|
|
180
241
|
const body = (res as { data: any }).data
|
|
181
242
|
if (!body || body.success !== true) {
|
|
182
243
|
throw new Error(body?.message || 'options resolver: unsuccessful response')
|
|
183
244
|
}
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
: rawOptions.length,
|
|
202
|
-
})
|
|
245
|
+
const payload = readOptionsEnvelope(body)
|
|
246
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
247
|
+
optionsCache.set(key, { payload, at: Date.now() })
|
|
248
|
+
}
|
|
249
|
+
return payload
|
|
250
|
+
}).finally(() => {
|
|
251
|
+
if (optionsInflight.get(key) === pending) optionsInflight.delete(key)
|
|
252
|
+
})
|
|
253
|
+
optionsInflight.set(key, pending)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let cancelled = false
|
|
257
|
+
pending
|
|
258
|
+
.then((payload) => {
|
|
259
|
+
if (cancelled) return
|
|
260
|
+
setOptions(payload.options)
|
|
261
|
+
setMeta(payload.meta)
|
|
203
262
|
})
|
|
204
263
|
.catch((err: any) => {
|
|
205
|
-
if (
|
|
264
|
+
if (cancelled) return
|
|
206
265
|
setError(err instanceof Error ? err : new Error(String(err)))
|
|
207
266
|
setOptions([])
|
|
208
267
|
setMeta(null)
|
|
209
268
|
})
|
|
210
269
|
.finally(() => {
|
|
211
|
-
if (!
|
|
270
|
+
if (!cancelled) setLoading(false)
|
|
212
271
|
})
|
|
213
272
|
|
|
214
273
|
return () => {
|
|
215
|
-
|
|
274
|
+
cancelled = true
|
|
216
275
|
}
|
|
217
276
|
}, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
|
|
218
277
|
|