@asteby/metacore-runtime-react 22.0.0 → 23.0.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.
- package/CHANGELOG.md +33 -0
- package/dist/dynamic-kanban.d.ts +19 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +96 -41
- package/dist/dynamic-table.js +1 -1
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +25 -1
- package/dist/use-facet-loaders.d.ts +23 -7
- package/dist/use-facet-loaders.d.ts.map +1 -1
- package/dist/use-facet-loaders.js +49 -8
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +101 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +164 -75
- package/src/dynamic-table.tsx +1 -1
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 23.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 25a78e7: Fixes del embudo por columna del kanban (LaneFilterButton).
|
|
8
|
+
- **Precarga de opciones dinámicas:** el value-picker del embudo ahora usa el
|
|
9
|
+
MISMO combobox pro que el Sheet — si el campo elegido tiene options estáticas
|
|
10
|
+
o `loadOptions` (facet), renderiza el combobox multi-select con carga lazy
|
|
11
|
+
(counts, buscador "Buscar valores...", estados de carga). Antes un campo facet
|
|
12
|
+
(p.ej. "Repo") caía al input crudo "Valor..." porque sus options aún no
|
|
13
|
+
estaban cargadas. Solo cae a input de texto cuando el campo es texto libre sin
|
|
14
|
+
facets. Nuevo componente compartible `FilterValueCombobox` en
|
|
15
|
+
`@asteby/metacore-ui`. El filtrado del lane sigue client-side: igualdad/IN
|
|
16
|
+
para valores de select/facet, substring para texto libre.
|
|
17
|
+
- **Traducción de opciones:** las opciones de Stage (y cualquier option con key
|
|
18
|
+
i18n del manifest) se traducen con `t(label, {defaultValue})` en runtime-react
|
|
19
|
+
ANTES de pasarlas al control (ColumnFilterControl vive en ui, sin i18n). Aplica
|
|
20
|
+
al embudo, al Sheet, a los chips y a los resúmenes de valor; el valor activo de
|
|
21
|
+
stage se muestra traducido y con su color.
|
|
22
|
+
- **Diseño del popover del embudo:** select de campo y control de valor a ancho
|
|
23
|
+
completo, popover más ancho (`w-72`), combobox con borde redondeado, botones
|
|
24
|
+
Limpiar/Aplicar full-width en fila — al nivel del resto del rediseño.
|
|
25
|
+
- **Prefetch de facetas:** al resolver la metadata se precargan en paralelo las
|
|
26
|
+
facetas de TODOS los campos facet (un `api.get` por campo, dedupeado por firma,
|
|
27
|
+
`allSettled` para que un campo con error no rompa el resto), sembrando el cache
|
|
28
|
+
del loader y poblando las `options` del `ColumnFilterConfig`. Así el popover
|
|
29
|
+
del Sheet y el value-picker del lane abren instantáneos con valores + counts,
|
|
30
|
+
sin "Cargando…"; el spinner queda solo para el refetch con búsqueda (`q`).
|
|
31
|
+
`useFacetLoaders` ahora expone `prefetchFacets` y `facetOptions`.
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [25a78e7]
|
|
34
|
+
- @asteby/metacore-ui@2.9.0
|
|
35
|
+
|
|
3
36
|
## 22.0.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -50,6 +50,25 @@ export declare function summarizeFilterValues(values: string[] | undefined, opti
|
|
|
50
50
|
label: string;
|
|
51
51
|
value: string;
|
|
52
52
|
}[] | undefined, maxShown?: number): string;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
55
|
+
* equality (IN — the card's field value must be one of them); a free-text
|
|
56
|
+
* `text` matches by case-insensitive substring. No field / no criteria → passes.
|
|
57
|
+
* Pure — exported for unit tests.
|
|
58
|
+
*/
|
|
59
|
+
export declare function cardMatchesLaneFunnel(card: any, filter: {
|
|
60
|
+
field?: string;
|
|
61
|
+
values?: string[];
|
|
62
|
+
text?: string;
|
|
63
|
+
} | undefined): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
66
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
67
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
68
|
+
*/
|
|
69
|
+
export declare function translateOptionLabels<T extends {
|
|
70
|
+
label: string;
|
|
71
|
+
}>(options: T[], translate: (key: string) => string): T[];
|
|
53
72
|
/**
|
|
54
73
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
55
74
|
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA+D9B,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAMhB;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAejE;AAQD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAA;AAE/C,wBAAgB,YAAY,CACxB,OAAO,EAAE,GAAG,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,EAAE,GACpB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAepB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,WAAW,EAAE,eAAe,EAAE,GAAG,SAAS,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACX,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAC3B,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAYpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,EAC5B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,SAAS,EACvD,QAAQ,SAAI,GACb,MAAM,CAuBR;AAgCD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDragg
|
|
|
5
5
|
import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, ToggleLeft, Type, X, } from 'lucide-react';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
8
|
-
import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
|
|
8
|
+
import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
|
|
9
9
|
import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
10
10
|
import { useApi } from './api-context';
|
|
11
11
|
import { useDynamicFilters } from './use-dynamic-filters';
|
|
@@ -187,6 +187,32 @@ function chipValueColor(config) {
|
|
|
187
187
|
const opt = config.options.find((o) => o.value === value);
|
|
188
188
|
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
189
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
192
|
+
* equality (IN — the card's field value must be one of them); a free-text
|
|
193
|
+
* `text` matches by case-insensitive substring. No field / no criteria → passes.
|
|
194
|
+
* Pure — exported for unit tests.
|
|
195
|
+
*/
|
|
196
|
+
export function cardMatchesLaneFunnel(card, filter) {
|
|
197
|
+
if (!filter?.field)
|
|
198
|
+
return true;
|
|
199
|
+
const raw = String(card?.[filter.field] ?? '');
|
|
200
|
+
if (filter.values && filter.values.length > 0) {
|
|
201
|
+
return filter.values.includes(raw);
|
|
202
|
+
}
|
|
203
|
+
if (filter.text?.trim()) {
|
|
204
|
+
return raw.toLowerCase().includes(filter.text.trim().toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
210
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
211
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
212
|
+
*/
|
|
213
|
+
export function translateOptionLabels(options, translate) {
|
|
214
|
+
return options.map((o) => ({ ...o, label: translate(o.label) }));
|
|
215
|
+
}
|
|
190
216
|
/**
|
|
191
217
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
192
218
|
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
@@ -308,11 +334,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
308
334
|
if (!metadata)
|
|
309
335
|
return [];
|
|
310
336
|
const out = [];
|
|
337
|
+
// Option labels come from the manifest as i18n keys (e.g.
|
|
338
|
+
// "integration_github.stage.backlog"). ColumnFilterControl lives in the
|
|
339
|
+
// ui package (no i18n), so translate labels HERE — on the static options
|
|
340
|
+
// and on whatever the facet loader resolves — before they ever reach a
|
|
341
|
+
// control, chip or value summary. A raw value (a repo name) has no key,
|
|
342
|
+
// so t() returns it verbatim via defaultValue.
|
|
343
|
+
const tr = (label) => t(label, { defaultValue: label });
|
|
311
344
|
for (const [key, config] of columnFilterConfigs) {
|
|
312
345
|
const f = metadata.filters?.find((x) => x.key === key);
|
|
313
346
|
const c = metadata.columns.find((x) => x.key === key);
|
|
314
347
|
const rawLabel = f?.label || c?.label || key;
|
|
315
|
-
|
|
348
|
+
const translatedConfig = {
|
|
349
|
+
...config,
|
|
350
|
+
options: translateOptionLabels(config.options, tr),
|
|
351
|
+
loadOptions: config.loadOptions
|
|
352
|
+
? (q) => config.loadOptions(q).then((opts) => translateOptionLabels(opts, tr))
|
|
353
|
+
: undefined,
|
|
354
|
+
};
|
|
355
|
+
out.push({
|
|
356
|
+
key,
|
|
357
|
+
label: tr(rawLabel),
|
|
358
|
+
config: translatedConfig,
|
|
359
|
+
});
|
|
316
360
|
}
|
|
317
361
|
return out;
|
|
318
362
|
}, [metadata, columnFilterConfigs, t]);
|
|
@@ -342,7 +386,9 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
342
386
|
setLaneFilters((prev) => {
|
|
343
387
|
const merged = { ...prev[stageKey], ...patch };
|
|
344
388
|
const next = { ...prev };
|
|
345
|
-
const hasFunnel = !!(merged.field &&
|
|
389
|
+
const hasFunnel = !!(merged.field &&
|
|
390
|
+
((merged.values && merged.values.length > 0) ||
|
|
391
|
+
merged.text?.trim()));
|
|
346
392
|
const hasQuery = !!merged.query?.trim();
|
|
347
393
|
if (hasFunnel || hasQuery)
|
|
348
394
|
next[stageKey] = merged;
|
|
@@ -475,12 +521,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
475
521
|
// (query) are AND-combined.
|
|
476
522
|
const laneFilter = laneFilters[stage.key];
|
|
477
523
|
let cards = allCards;
|
|
478
|
-
if (laneFilter?.field
|
|
479
|
-
|
|
480
|
-
const field = laneFilter.field;
|
|
481
|
-
cards = cards.filter((c) => String(c[field] ?? '')
|
|
482
|
-
.toLowerCase()
|
|
483
|
-
.includes(v));
|
|
524
|
+
if (laneFilter?.field) {
|
|
525
|
+
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
|
|
484
526
|
}
|
|
485
527
|
if (laneFilter?.query?.trim()) {
|
|
486
528
|
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
@@ -490,7 +532,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
490
532
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
491
533
|
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
492
534
|
field: f?.field,
|
|
493
|
-
|
|
535
|
+
values: f?.values,
|
|
536
|
+
text: f?.text,
|
|
494
537
|
}), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
|
|
495
538
|
}) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
|
|
496
539
|
}
|
|
@@ -527,11 +570,18 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
527
570
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
528
571
|
isDark,
|
|
529
572
|
});
|
|
530
|
-
const
|
|
573
|
+
const funnelField = filterFields.find((f) => f.key === laneFilter?.field);
|
|
574
|
+
const funnelActive = !!(laneFilter?.field &&
|
|
575
|
+
((laneFilter.values && laneFilter.values.length > 0) ||
|
|
576
|
+
laneFilter.text?.trim()));
|
|
531
577
|
const queryActive = !!laneFilter?.query?.trim();
|
|
532
578
|
const laneActive = funnelActive || queryActive;
|
|
533
|
-
const activeFieldLabel =
|
|
534
|
-
|
|
579
|
+
const activeFieldLabel = funnelField?.label ?? laneFilter?.field;
|
|
580
|
+
// Human summary of the funnel value: resolved option labels for picked
|
|
581
|
+
// values, or the raw free text.
|
|
582
|
+
const funnelSummary = laneFilter?.values && laneFilter.values.length > 0
|
|
583
|
+
? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
|
|
584
|
+
: laneFilter?.text ?? '';
|
|
535
585
|
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
536
586
|
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
537
587
|
// it survives collapse and combines with the funnel.
|
|
@@ -541,8 +591,12 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
541
591
|
if (searchOpen)
|
|
542
592
|
searchRef.current?.focus();
|
|
543
593
|
}, [searchOpen]);
|
|
544
|
-
const funnelValue = laneFilter?.field
|
|
545
|
-
? {
|
|
594
|
+
const funnelValue = laneFilter?.field
|
|
595
|
+
? {
|
|
596
|
+
field: laneFilter.field,
|
|
597
|
+
values: laneFilter.values,
|
|
598
|
+
text: laneFilter.text,
|
|
599
|
+
}
|
|
546
600
|
: undefined;
|
|
547
601
|
return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
|
|
548
602
|
opacity: dimmed ? 0.45 : 1,
|
|
@@ -560,7 +614,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
560
614
|
setSearchOpen(false);
|
|
561
615
|
}, placeholder: t('kanban.searchLanePlaceholder', {
|
|
562
616
|
defaultValue: 'Buscar tarjetas...',
|
|
563
|
-
}), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ",
|
|
617
|
+
}), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", funnelSummary] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
|
|
564
618
|
defaultValue: 'Limpiar',
|
|
565
619
|
}), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
|
|
566
620
|
}
|
|
@@ -571,53 +625,54 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
571
625
|
const { t } = useTranslation();
|
|
572
626
|
const [open, setOpen] = useState(false);
|
|
573
627
|
const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
|
|
574
|
-
const [
|
|
628
|
+
const [values, setValues] = useState(value?.values ?? []);
|
|
629
|
+
const [text, setText] = useState(value?.text ?? '');
|
|
575
630
|
// Re-seed the draft from the committed filter each time the popover opens.
|
|
576
631
|
useEffect(() => {
|
|
577
632
|
if (open) {
|
|
578
633
|
setField(value?.field ?? fields[0]?.key ?? '');
|
|
579
|
-
|
|
634
|
+
setValues(value?.values ?? []);
|
|
635
|
+
setText(value?.text ?? '');
|
|
580
636
|
}
|
|
581
637
|
}, [open, value, fields]);
|
|
582
638
|
if (fields.length === 0)
|
|
583
639
|
return null;
|
|
584
|
-
const active = !!(value &&
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
640
|
+
const active = !!(value &&
|
|
641
|
+
((value.values && value.values.length > 0) || value.text?.trim()));
|
|
642
|
+
// The value step mirrors the sheet: when the chosen field is a select or a
|
|
643
|
+
// facet (static options OR a lazy loader), render the SAME pro combobox —
|
|
644
|
+
// multi-select, searchable, with counts. Only a genuinely free-text field
|
|
645
|
+
// (no options, no loader) falls back to a raw "Contiene..." input.
|
|
646
|
+
const cfg = fields.find((f) => f.key === field)?.config;
|
|
647
|
+
const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions;
|
|
648
|
+
const toggle = (v) => setValues((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
|
|
591
649
|
const apply = () => {
|
|
592
|
-
if (field &&
|
|
593
|
-
onChange({ field,
|
|
650
|
+
if (field && values.length > 0)
|
|
651
|
+
onChange({ field, values });
|
|
652
|
+
else if (field && text.trim())
|
|
653
|
+
onChange({ field, text: text.trim() });
|
|
594
654
|
else
|
|
595
655
|
onChange(null);
|
|
596
656
|
setOpen(false);
|
|
597
657
|
};
|
|
658
|
+
const clear = () => {
|
|
659
|
+
onChange(null);
|
|
660
|
+
setOpen(false);
|
|
661
|
+
};
|
|
598
662
|
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
|
|
599
663
|
defaultValue: 'Filtrar columna',
|
|
600
|
-
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-
|
|
664
|
+
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
|
|
601
665
|
setField(f);
|
|
602
666
|
// Reset the value when switching fields — a value picked
|
|
603
667
|
// for one field is meaningless for another.
|
|
668
|
+
setValues([]);
|
|
604
669
|
setText('');
|
|
605
|
-
}, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }),
|
|
606
|
-
setText(v);
|
|
607
|
-
if (field && v)
|
|
608
|
-
onChange({ field, value: v });
|
|
609
|
-
setOpen(false);
|
|
610
|
-
}, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: t('kanban.filterValue', {
|
|
611
|
-
defaultValue: 'Valor...',
|
|
612
|
-
}) }) }), _jsx(SelectContent, { children: valueOptions.map((o) => (_jsx(SelectItem, { value: o.value, className: "text-xs", children: o.label }, o.value))) })] })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
|
|
670
|
+
}, children: [_jsx(SelectTrigger, { className: "h-8 w-full text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), hasValuePicker ? (_jsx("div", { className: "overflow-hidden rounded-lg border", children: _jsx(FilterValueCombobox, { staticOptions: cfg?.options, loadOptions: cfg?.loadOptions, selected: values, onToggle: toggle }, field) })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
|
|
613
671
|
if (e.key === 'Enter')
|
|
614
672
|
apply();
|
|
615
673
|
}, placeholder: t('kanban.filterValue', {
|
|
616
|
-
defaultValue: '
|
|
617
|
-
}), className: "h-8 text-xs" })), _jsxs("div", { className: "flex
|
|
618
|
-
onChange(null);
|
|
619
|
-
setOpen(false);
|
|
620
|
-
}, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
|
|
674
|
+
defaultValue: 'Contiene...',
|
|
675
|
+
}), className: "h-8 w-full text-xs" })), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { variant: "outline", size: "sm", className: "h-7 flex-1 text-xs", onClick: clear, disabled: !active && values.length === 0 && !text.trim(), children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsxs(Button, { size: "sm", className: "h-7 flex-1 text-xs", onClick: apply, disabled: values.length === 0 && !text.trim(), children: [t('kanban.apply', { defaultValue: 'Aplicar' }), values.length > 0 ? ` (${values.length})` : ''] })] })] })] }));
|
|
621
676
|
}
|
|
622
677
|
function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
|
|
623
678
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
package/dist/dynamic-table.js
CHANGED
|
@@ -462,7 +462,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
462
462
|
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
463
463
|
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
464
464
|
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
|
|
465
|
-
const getFacetLoader = useFacetLoaders(facetsBase);
|
|
465
|
+
const { getFacetLoader } = useFacetLoaders(facetsBase);
|
|
466
466
|
const columnFilterConfigs = useMemo(() => {
|
|
467
467
|
const map = new Map();
|
|
468
468
|
if (!metadata)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,kBAAkB,EAAgB,MAAM,wBAAwB,CAAA;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,uBAAuB;IACtC,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACxC,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,iFAAiF;IACjF,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;IACpD;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC,uDAAuD;IACvD,yBAAyB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IACxE,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,CAAA;IACzB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,aAAa,GAAG,IAAI,EAC9B,IAAI,GAAE,wBAA6B,GAClC,uBAAuB,
|
|
1
|
+
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,kBAAkB,EAAgB,MAAM,wBAAwB,CAAA;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,uBAAuB;IACtC,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACxC,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,iFAAiF;IACjF,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;IACpD;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC,uDAAuD;IACvD,yBAAyB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IACxE,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,CAAA;IACzB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,aAAa,GAAG,IAAI,EAC9B,IAAI,GAAE,wBAA6B,GAClC,uBAAuB,CAyTzB"}
|
|
@@ -36,7 +36,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
36
36
|
: model
|
|
37
37
|
? `/data/${model}/facets`
|
|
38
38
|
: null);
|
|
39
|
-
const getFacetLoader = useFacetLoaders(facetsBase);
|
|
39
|
+
const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase);
|
|
40
40
|
// Prefetch the option lists for relation/select filters (once per model). The
|
|
41
41
|
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
42
42
|
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
@@ -152,6 +152,9 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
152
152
|
if (loader) {
|
|
153
153
|
fType = 'facet';
|
|
154
154
|
loadOptions = loader;
|
|
155
|
+
// Prewarmed values (from prefetchFacets) → the popover opens with the
|
|
156
|
+
// list already there, no "Cargando…" flash.
|
|
157
|
+
options = facetOptions.get(f.column || f.key) ?? [];
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
@@ -217,6 +220,8 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
217
220
|
if (loader) {
|
|
218
221
|
filterType = 'facet';
|
|
219
222
|
loadOptions = loader;
|
|
223
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
224
|
+
options = facetOptions.get(c.key) ?? [];
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
map.set(c.key, {
|
|
@@ -238,7 +243,26 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
238
243
|
handleDynamicFilterChange,
|
|
239
244
|
facetsBase,
|
|
240
245
|
getFacetLoader,
|
|
246
|
+
facetOptions,
|
|
241
247
|
]);
|
|
248
|
+
// Prewarm every facet field in one burst once the configs settle, so the
|
|
249
|
+
// Sheet popover and the lane value-picker open instantly (values + counts)
|
|
250
|
+
// instead of flashing "Cargando…". Deduped inside prefetchFacets by field set.
|
|
251
|
+
const facetFieldsSig = useMemo(() => {
|
|
252
|
+
const keys = [];
|
|
253
|
+
// Key on `filterKey` (the column the loader queries), not the config map key
|
|
254
|
+
// — they differ when an explicit filter sets a distinct `column`.
|
|
255
|
+
for (const config of columnFilterConfigs.values()) {
|
|
256
|
+
if (config.filterType === 'facet')
|
|
257
|
+
keys.push(config.filterKey);
|
|
258
|
+
}
|
|
259
|
+
return keys.join('|');
|
|
260
|
+
}, [columnFilterConfigs]);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!facetFieldsSig)
|
|
263
|
+
return;
|
|
264
|
+
prefetchFacets(facetFieldsSig.split('|'));
|
|
265
|
+
}, [facetFieldsSig, prefetchFacets]);
|
|
242
266
|
const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
|
|
243
267
|
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
244
268
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { FilterOption } from './dynamic-columns-shim';
|
|
2
|
+
export interface UseFacetLoadersResult {
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-field loader → `<facetsBase>?field=&q=&limit=`. Cached per
|
|
5
|
+
* `field+query`. Null `facetsBase` → yields `undefined` (column stays text).
|
|
6
|
+
*/
|
|
7
|
+
getFacetLoader: (field: string) => ((q?: string) => Promise<FilterOption[]>) | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Warms the caches for a set of facet fields in ONE parallel burst (called
|
|
10
|
+
* when metadata resolves), so a popover opens instantly with values + counts
|
|
11
|
+
* instead of showing "Cargando…". Deduped by the field set; a field whose
|
|
12
|
+
* request fails is simply omitted (it degrades to lazy/text — the rest are
|
|
13
|
+
* unaffected). Resolved options also land in `facetOptions`.
|
|
14
|
+
*/
|
|
15
|
+
prefetchFacets: (fields: string[]) => void;
|
|
16
|
+
/** Prefetched options per field (empty until `prefetchFacets` resolves). */
|
|
17
|
+
facetOptions: Map<string, FilterOption[]>;
|
|
18
|
+
}
|
|
2
19
|
/**
|
|
3
20
|
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
4
21
|
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
@@ -11,12 +28,11 @@ export declare function isLongTextColumn(c: {
|
|
|
11
28
|
cellStyle?: string;
|
|
12
29
|
}): boolean;
|
|
13
30
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* when `facetsBase` changes (model switch).
|
|
19
|
-
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
31
|
+
* Facet option machinery: a stable per-field lazy loader (`getFacetLoader`, with
|
|
32
|
+
* a `field+query` result cache) plus `prefetchFacets` to warm every facet
|
|
33
|
+
* field's values up front and `facetOptions` holding those prewarmed results.
|
|
34
|
+
* Loader identity is memoized per field (so it doesn't churn the config memo
|
|
35
|
+
* that depends on it). Caches reset when `facetsBase` changes (model switch).
|
|
20
36
|
*/
|
|
21
|
-
export declare function useFacetLoaders(facetsBase: string | null):
|
|
37
|
+
export declare function useFacetLoaders(facetsBase: string | null): UseFacetLoadersResult;
|
|
22
38
|
//# sourceMappingURL=use-facet-loaders.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-facet-loaders.d.ts","sourceRoot":"","sources":["../src/use-facet-loaders.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAE1D;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAQV;AAED
|
|
1
|
+
{"version":3,"file":"use-facet-loaders.d.ts","sourceRoot":"","sources":["../src/use-facet-loaders.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAE1D,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,cAAc,EAAE,CACd,KAAK,EAAE,MAAM,KACV,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,GAAG,SAAS,CAAA;IAC1D;;;;;;OAMG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC1C,4EAA4E;IAC5E,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAA;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAQV;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,qBAAqB,CAwFhF"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// per-field option loader + result cache lives here to guarantee they facet
|
|
5
5
|
// identically (one board and its table sibling hit the same `/facets` endpoint
|
|
6
6
|
// with the same caching).
|
|
7
|
-
import { useCallback, useEffect, useRef } from 'react';
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { useApi } from './api-context';
|
|
9
9
|
/**
|
|
10
10
|
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
@@ -21,22 +21,32 @@ export function isLongTextColumn(c) {
|
|
|
21
21
|
return /^(body|description|content|notes?|comment|message|summary)$/i.test(c.key);
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* when `facetsBase` changes (model switch).
|
|
29
|
-
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
24
|
+
* Facet option machinery: a stable per-field lazy loader (`getFacetLoader`, with
|
|
25
|
+
* a `field+query` result cache) plus `prefetchFacets` to warm every facet
|
|
26
|
+
* field's values up front and `facetOptions` holding those prewarmed results.
|
|
27
|
+
* Loader identity is memoized per field (so it doesn't churn the config memo
|
|
28
|
+
* that depends on it). Caches reset when `facetsBase` changes (model switch).
|
|
30
29
|
*/
|
|
31
30
|
export function useFacetLoaders(facetsBase) {
|
|
32
31
|
const api = useApi();
|
|
33
32
|
const loaderCache = useRef(new Map());
|
|
34
33
|
const resultCache = useRef(new Map());
|
|
34
|
+
const prefetchSig = useRef(null);
|
|
35
|
+
const mountedRef = useRef(true);
|
|
36
|
+
const [facetOptions, setFacetOptions] = useState(new Map());
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
mountedRef.current = true;
|
|
39
|
+
return () => {
|
|
40
|
+
mountedRef.current = false;
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
35
43
|
useEffect(() => {
|
|
36
44
|
loaderCache.current.clear();
|
|
37
45
|
resultCache.current.clear();
|
|
46
|
+
prefetchSig.current = null;
|
|
47
|
+
setFacetOptions(new Map());
|
|
38
48
|
}, [facetsBase]);
|
|
39
|
-
|
|
49
|
+
const getFacetLoader = useCallback((field) => {
|
|
40
50
|
if (!facetsBase)
|
|
41
51
|
return undefined;
|
|
42
52
|
const existing = loaderCache.current.get(field);
|
|
@@ -64,4 +74,35 @@ export function useFacetLoaders(facetsBase) {
|
|
|
64
74
|
loaderCache.current.set(field, loader);
|
|
65
75
|
return loader;
|
|
66
76
|
}, [api, facetsBase]);
|
|
77
|
+
const prefetchFacets = useCallback((fields) => {
|
|
78
|
+
if (!facetsBase || fields.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
const sig = [...fields].sort().join('|');
|
|
81
|
+
if (prefetchSig.current === sig)
|
|
82
|
+
return;
|
|
83
|
+
prefetchSig.current = sig;
|
|
84
|
+
// One parallel burst; the loader seeds its own `${field}::` cache so the
|
|
85
|
+
// subsequent lazy open (q === '') is a cache hit. allSettled so one bad
|
|
86
|
+
// field never blocks the others.
|
|
87
|
+
void Promise.allSettled(fields.map(async (field) => {
|
|
88
|
+
const loader = getFacetLoader(field);
|
|
89
|
+
if (!loader)
|
|
90
|
+
return null;
|
|
91
|
+
const opts = await loader();
|
|
92
|
+
return { field, opts };
|
|
93
|
+
})).then((results) => {
|
|
94
|
+
if (!mountedRef.current)
|
|
95
|
+
return;
|
|
96
|
+
setFacetOptions((prev) => {
|
|
97
|
+
const next = new Map(prev);
|
|
98
|
+
for (const r of results) {
|
|
99
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
100
|
+
next.set(r.value.field, r.value.opts);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}, [facetsBase, getFacetLoader]);
|
|
107
|
+
return { getFacetLoader, prefetchFacets, facetOptions };
|
|
67
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.
|
|
41
|
+
"@asteby/metacore-ui": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^6.0.0",
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@asteby/metacore-
|
|
71
|
-
"@asteby/metacore-
|
|
70
|
+
"@asteby/metacore-ui": "2.9.0",
|
|
71
|
+
"@asteby/metacore-sdk": "3.2.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|