@asteby/metacore-runtime-react 21.1.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 +79 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +35 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +279 -36
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +99 -5
- package/dist/use-facet-loaders.d.ts +38 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +108 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +197 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +243 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +607 -100
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +125 -5
- package/src/use-facet-loaders.ts +146 -0
package/dist/dynamic-kanban.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
|
|
5
|
-
import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react';
|
|
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';
|
|
9
|
-
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
8
|
+
import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
|
|
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';
|
|
12
12
|
import { useMetadataCache } from './metadata-cache';
|
|
@@ -129,6 +129,103 @@ export function selectCardColumns(metadata, maxFields = 3) {
|
|
|
129
129
|
.slice(0, maxFields);
|
|
130
130
|
return { title, fields };
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Human-readable summary of a field's selected filter values for the removable
|
|
134
|
+
* chip row. Resolves option labels, unwraps the wire operators
|
|
135
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
136
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
137
|
+
*/
|
|
138
|
+
export function summarizeFilterValues(values, options, maxShown = 2) {
|
|
139
|
+
if (!values || values.length === 0)
|
|
140
|
+
return '';
|
|
141
|
+
const opts = options ?? [];
|
|
142
|
+
const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
|
|
143
|
+
const first = values[0];
|
|
144
|
+
if (values.length === 1) {
|
|
145
|
+
if (first.startsWith('ILIKE:'))
|
|
146
|
+
return `"${first.slice(6)}"`;
|
|
147
|
+
if (first.startsWith('IN:')) {
|
|
148
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown);
|
|
149
|
+
}
|
|
150
|
+
if (first.startsWith('RANGE:')) {
|
|
151
|
+
const [min, max] = first.slice(6).split(',');
|
|
152
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
153
|
+
}
|
|
154
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
155
|
+
return first.replace('_', ' – ');
|
|
156
|
+
}
|
|
157
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
158
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
|
|
159
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
160
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
161
|
+
}
|
|
162
|
+
return summarizeList(values, labelFor, maxShown);
|
|
163
|
+
}
|
|
164
|
+
function summarizeList(items, labelFor, maxShown) {
|
|
165
|
+
const labels = items.map(labelFor);
|
|
166
|
+
if (labels.length <= maxShown)
|
|
167
|
+
return labels.join(', ');
|
|
168
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
172
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
173
|
+
* operator/range/free-text values that carry no option color.
|
|
174
|
+
*/
|
|
175
|
+
function chipValueColor(config) {
|
|
176
|
+
const sel = config.selectedValues;
|
|
177
|
+
if (!sel || sel.length === 0)
|
|
178
|
+
return undefined;
|
|
179
|
+
const first = sel[0];
|
|
180
|
+
let value = first;
|
|
181
|
+
if (first.startsWith('IN:'))
|
|
182
|
+
value = first.slice(3).split(',')[0];
|
|
183
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
|
|
184
|
+
return undefined;
|
|
185
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
186
|
+
return undefined;
|
|
187
|
+
const opt = config.options.find((o) => o.value === value);
|
|
188
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
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
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
218
|
+
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
219
|
+
* matches everything. Pure — exported for unit tests.
|
|
220
|
+
*/
|
|
221
|
+
export function cardMatchesLaneQuery(card, cols, query) {
|
|
222
|
+
const q = query.trim().toLowerCase();
|
|
223
|
+
if (!q)
|
|
224
|
+
return true;
|
|
225
|
+
return cols.some((c) => String(card?.[c.key] ?? '')
|
|
226
|
+
.toLowerCase()
|
|
227
|
+
.includes(q));
|
|
228
|
+
}
|
|
132
229
|
// ---------------------------------------------------------------------------
|
|
133
230
|
// Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
|
|
134
231
|
// ---------------------------------------------------------------------------
|
|
@@ -199,7 +296,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
199
296
|
// Shared metadata-driven filter engine — the SAME configs, option prefetch
|
|
200
297
|
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
201
298
|
// identically to its table sibling.
|
|
202
|
-
const { globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, clearAll, } = useDynamicFilters(metadata, { defaultFilters });
|
|
299
|
+
const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
|
|
203
300
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
204
301
|
const fetchData = useCallback(async () => {
|
|
205
302
|
if (!metadata)
|
|
@@ -237,26 +334,64 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
237
334
|
if (!metadata)
|
|
238
335
|
return [];
|
|
239
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 });
|
|
240
344
|
for (const [key, config] of columnFilterConfigs) {
|
|
241
345
|
const f = metadata.filters?.find((x) => x.key === key);
|
|
242
346
|
const c = metadata.columns.find((x) => x.key === key);
|
|
243
347
|
const rawLabel = f?.label || c?.label || key;
|
|
244
|
-
|
|
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
|
+
});
|
|
245
360
|
}
|
|
246
361
|
return out;
|
|
247
362
|
}, [metadata, columnFilterConfigs, t]);
|
|
363
|
+
// Split filters into active (with a selection) and the rest — the Sheet
|
|
364
|
+
// groups the active ones on top, the rest alphabetically. Also drives the
|
|
365
|
+
// removable chip row below the toolbar.
|
|
366
|
+
const { activeFields, inactiveFields } = useMemo(() => {
|
|
367
|
+
const active = filterFields.filter((f) => (f.config.selectedValues?.length ?? 0) > 0);
|
|
368
|
+
const inactive = filterFields
|
|
369
|
+
.filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
|
|
370
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
371
|
+
return { activeFields: active, inactiveFields: inactive };
|
|
372
|
+
}, [filterFields]);
|
|
248
373
|
// Sheet (grouped global filters) open state + per-lane client-side filters.
|
|
249
374
|
// A lane filter narrows ONLY that stage's already-fetched cards by a field
|
|
250
375
|
// value — instant, no refetch — so a user can drill into one column without
|
|
251
376
|
// touching the rest of the board (the global filters, by contrast, refetch
|
|
252
377
|
// the whole board server-side).
|
|
253
378
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
379
|
+
// Per-lane client-side narrowing. Two independent, AND-combined dimensions:
|
|
380
|
+
// - `field`/`value`: the funnel — a field-scoped substring match.
|
|
381
|
+
// - `query`: the lane search — a substring over the card title + every
|
|
382
|
+
// visible field value.
|
|
383
|
+
// A lane with neither is dropped from the map (so it reads as "unfiltered").
|
|
254
384
|
const [laneFilters, setLaneFilters] = useState({});
|
|
255
|
-
const
|
|
385
|
+
const updateLaneFilter = useCallback((stageKey, patch) => {
|
|
256
386
|
setLaneFilters((prev) => {
|
|
387
|
+
const merged = { ...prev[stageKey], ...patch };
|
|
257
388
|
const next = { ...prev };
|
|
258
|
-
|
|
259
|
-
|
|
389
|
+
const hasFunnel = !!(merged.field &&
|
|
390
|
+
((merged.values && merged.values.length > 0) ||
|
|
391
|
+
merged.text?.trim()));
|
|
392
|
+
const hasQuery = !!merged.query?.trim();
|
|
393
|
+
if (hasFunnel || hasQuery)
|
|
394
|
+
next[stageKey] = merged;
|
|
260
395
|
else
|
|
261
396
|
delete next[stageKey];
|
|
262
397
|
return next;
|
|
@@ -267,6 +402,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
267
402
|
const transitions = metadata?.transitions;
|
|
268
403
|
const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
|
|
269
404
|
const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
|
|
405
|
+
// Columns the lane search scans: the card title + its visible field cells.
|
|
406
|
+
const searchCols = useMemo(() => [titleCol, ...fieldCols].filter(Boolean), [titleCol, fieldCols]);
|
|
270
407
|
// Row-placement actions resolved EXACTLY like DynamicTable's action column:
|
|
271
408
|
// capability-gated (when a <PermissionsProvider> is mounted) and with the
|
|
272
409
|
// implicit View/Edit/Delete trio materialized for CRUD models. An action the
|
|
@@ -362,37 +499,122 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
362
499
|
order: Number.MAX_SAFE_INTEGER,
|
|
363
500
|
});
|
|
364
501
|
}
|
|
365
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-
|
|
502
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
|
|
503
|
+
defaultValue: 'Con filtros activos',
|
|
504
|
+
}) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
|
|
505
|
+
? t('kanban.activeCount', {
|
|
506
|
+
defaultValue: '{{count}} activos',
|
|
507
|
+
count: activeFilterCount,
|
|
508
|
+
})
|
|
509
|
+
: t('kanban.noActiveFilters', {
|
|
510
|
+
defaultValue: 'Sin filtros',
|
|
511
|
+
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), activeFields.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "kanban-filter-chips", children: [activeFields.map((field) => {
|
|
512
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
513
|
+
const dot = chipValueColor(field.config);
|
|
514
|
+
return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('kanban.removeFilter', {
|
|
515
|
+
defaultValue: 'Quitar filtro',
|
|
516
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
517
|
+
}), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: clearAll, children: t('kanban.clearAll', { defaultValue: 'Limpiar todo' }) })] })), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
366
518
|
const allCards = grouped.get(stage.key) ?? [];
|
|
367
|
-
// Per-lane client-side narrowing
|
|
368
|
-
//
|
|
369
|
-
//
|
|
519
|
+
// Per-lane client-side narrowing (instant, scoped to this
|
|
520
|
+
// stage). The funnel (field/value) and the lane search
|
|
521
|
+
// (query) are AND-combined.
|
|
370
522
|
const laneFilter = laneFilters[stage.key];
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
523
|
+
let cards = allCards;
|
|
524
|
+
if (laneFilter?.field) {
|
|
525
|
+
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
|
|
526
|
+
}
|
|
527
|
+
if (laneFilter?.query?.trim()) {
|
|
528
|
+
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
529
|
+
}
|
|
376
530
|
const droppableAllowed = !activeId ||
|
|
377
531
|
stage.key === activeStage ||
|
|
378
532
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
379
|
-
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter,
|
|
533
|
+
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
534
|
+
field: f?.field,
|
|
535
|
+
values: f?.values,
|
|
536
|
+
text: f?.text,
|
|
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));
|
|
380
538
|
}) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
|
|
381
539
|
}
|
|
382
|
-
|
|
540
|
+
/**
|
|
541
|
+
* A per-data-type glyph for the Filtros panel rows (and their popover header):
|
|
542
|
+
* Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
|
|
543
|
+
* for value pickers, ToggleLeft for booleans, Type for free text.
|
|
544
|
+
*/
|
|
545
|
+
function filterTypeIcon(filterType, isStage) {
|
|
546
|
+
if (isStage)
|
|
547
|
+
return _jsx(CircleDot, { className: "h-3.5 w-3.5" });
|
|
548
|
+
switch (filterType) {
|
|
549
|
+
case 'number_range':
|
|
550
|
+
return _jsx(Hash, { className: "h-3.5 w-3.5" });
|
|
551
|
+
case 'date_range':
|
|
552
|
+
return _jsx(Calendar, { className: "h-3.5 w-3.5" });
|
|
553
|
+
case 'boolean':
|
|
554
|
+
return _jsx(ToggleLeft, { className: "h-3.5 w-3.5" });
|
|
555
|
+
case 'select':
|
|
556
|
+
case 'dynamic_select':
|
|
557
|
+
case 'facet':
|
|
558
|
+
return _jsx(Tag, { className: "h-3.5 w-3.5" });
|
|
559
|
+
default:
|
|
560
|
+
return _jsx(Type, { className: "h-3.5 w-3.5" });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function SheetFilterRow({ field, isStage, }) {
|
|
564
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
565
|
+
return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
|
|
566
|
+
}
|
|
567
|
+
function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
383
568
|
const { t } = useTranslation();
|
|
384
569
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
|
|
385
570
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
386
571
|
isDark,
|
|
387
572
|
});
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
laneFilter
|
|
391
|
-
|
|
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()));
|
|
577
|
+
const queryActive = !!laneFilter?.query?.trim();
|
|
578
|
+
const laneActive = funnelActive || queryActive;
|
|
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 ?? '';
|
|
585
|
+
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
586
|
+
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
587
|
+
// it survives collapse and combines with the funnel.
|
|
588
|
+
const [searchOpen, setSearchOpen] = useState(queryActive);
|
|
589
|
+
const searchRef = useRef(null);
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
if (searchOpen)
|
|
592
|
+
searchRef.current?.focus();
|
|
593
|
+
}, [searchOpen]);
|
|
594
|
+
const funnelValue = laneFilter?.field
|
|
595
|
+
? {
|
|
596
|
+
field: laneFilter.field,
|
|
597
|
+
values: laneFilter.values,
|
|
598
|
+
text: laneFilter.text,
|
|
599
|
+
}
|
|
600
|
+
: undefined;
|
|
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: {
|
|
392
602
|
opacity: dimmed ? 0.45 : 1,
|
|
393
603
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
394
604
|
outlineOffset: 2,
|
|
395
|
-
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }),
|
|
605
|
+
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: `flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${laneActive || searchOpen ? 'opacity-100' : 'opacity-0'}`, children: [_jsx("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
606
|
+
defaultValue: 'Buscar en la columna',
|
|
607
|
+
}), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
|
|
608
|
+
if (e.key === 'Escape') {
|
|
609
|
+
onQueryChange('');
|
|
610
|
+
setSearchOpen(false);
|
|
611
|
+
}
|
|
612
|
+
}, onBlur: () => {
|
|
613
|
+
if (!laneFilter?.query?.trim())
|
|
614
|
+
setSearchOpen(false);
|
|
615
|
+
}, placeholder: t('kanban.searchLanePlaceholder', {
|
|
616
|
+
defaultValue: 'Buscar tarjetas...',
|
|
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', {
|
|
396
618
|
defaultValue: 'Limpiar',
|
|
397
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 })] }));
|
|
398
620
|
}
|
|
@@ -403,33 +625,54 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
403
625
|
const { t } = useTranslation();
|
|
404
626
|
const [open, setOpen] = useState(false);
|
|
405
627
|
const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
|
|
406
|
-
const [
|
|
628
|
+
const [values, setValues] = useState(value?.values ?? []);
|
|
629
|
+
const [text, setText] = useState(value?.text ?? '');
|
|
407
630
|
// Re-seed the draft from the committed filter each time the popover opens.
|
|
408
631
|
useEffect(() => {
|
|
409
632
|
if (open) {
|
|
410
633
|
setField(value?.field ?? fields[0]?.key ?? '');
|
|
411
|
-
|
|
634
|
+
setValues(value?.values ?? []);
|
|
635
|
+
setText(value?.text ?? '');
|
|
412
636
|
}
|
|
413
637
|
}, [open, value, fields]);
|
|
414
638
|
if (fields.length === 0)
|
|
415
639
|
return null;
|
|
416
|
-
const active = !!(value &&
|
|
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]);
|
|
417
649
|
const apply = () => {
|
|
418
|
-
if (field &&
|
|
419
|
-
onChange({ field,
|
|
650
|
+
if (field && values.length > 0)
|
|
651
|
+
onChange({ field, values });
|
|
652
|
+
else if (field && text.trim())
|
|
653
|
+
onChange({ field, text: text.trim() });
|
|
420
654
|
else
|
|
421
655
|
onChange(null);
|
|
422
656
|
setOpen(false);
|
|
423
657
|
};
|
|
424
|
-
|
|
658
|
+
const clear = () => {
|
|
659
|
+
onChange(null);
|
|
660
|
+
setOpen(false);
|
|
661
|
+
};
|
|
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', {
|
|
425
663
|
defaultValue: 'Filtrar columna',
|
|
426
|
-
}), 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) => {
|
|
665
|
+
setField(f);
|
|
666
|
+
// Reset the value when switching fields — a value picked
|
|
667
|
+
// for one field is meaningless for another.
|
|
668
|
+
setValues([]);
|
|
669
|
+
setText('');
|
|
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) => {
|
|
427
671
|
if (e.key === 'Enter')
|
|
428
672
|
apply();
|
|
429
|
-
}, placeholder: t('kanban.filterValue', {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
|
|
673
|
+
}, placeholder: t('kanban.filterValue', {
|
|
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})` : ''] })] })] })] }));
|
|
433
676
|
}
|
|
434
677
|
function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
|
|
435
678
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAWnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BA02BnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -24,6 +24,7 @@ import { Progress } from './dialogs/_primitives';
|
|
|
24
24
|
import { useMetadataCache } from './metadata-cache';
|
|
25
25
|
import { useApi, useCurrentBranch } from './api-context';
|
|
26
26
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
|
|
27
|
+
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
|
|
27
28
|
import { OptionsContext } from './options-context';
|
|
28
29
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
29
30
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
|
|
@@ -456,16 +457,28 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
456
457
|
setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }));
|
|
457
458
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
|
458
459
|
}, []);
|
|
460
|
+
// Same facet loader machinery the board uses, so a text column filters
|
|
461
|
+
// identically in the table header and the kanban Sheet (the host's
|
|
462
|
+
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
463
|
+
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
464
|
+
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
|
|
465
|
+
const { getFacetLoader } = useFacetLoaders(facetsBase);
|
|
459
466
|
const columnFilterConfigs = useMemo(() => {
|
|
460
467
|
const map = new Map();
|
|
461
468
|
if (!metadata)
|
|
462
469
|
return map;
|
|
470
|
+
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
471
|
+
label: s.label,
|
|
472
|
+
value: s.key,
|
|
473
|
+
color: s.color,
|
|
474
|
+
}));
|
|
475
|
+
const groupBy = metadata.group_by;
|
|
463
476
|
// Explicit `metadata.filters` wins. When the backend does not emit
|
|
464
477
|
// them, derive a filter chip from every column flagged
|
|
465
478
|
// `filterable: true` — keeps the kernel API minimal (one flag on the
|
|
466
479
|
// column) while still rendering the FilterableColumnHeader.
|
|
467
480
|
for (const f of metadata.filters ?? []) {
|
|
468
|
-
|
|
481
|
+
let fType = f.type;
|
|
469
482
|
let options = [];
|
|
470
483
|
if (f.options && f.options.length > 0) {
|
|
471
484
|
options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }));
|
|
@@ -473,6 +486,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
473
486
|
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
474
487
|
options = filterOptionsMap.get(f.searchEndpoint) || [];
|
|
475
488
|
}
|
|
489
|
+
// (A) A stage column with no options of its own inherits the
|
|
490
|
+
// pipeline stages (with their colors) as a real select.
|
|
491
|
+
if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) {
|
|
492
|
+
fType = 'select';
|
|
493
|
+
options = stageOptions;
|
|
494
|
+
}
|
|
495
|
+
// (B) A plain text filter becomes a facet value-picker when a facets
|
|
496
|
+
// endpoint is available (degrades to "Contiene..." if it yields nothing).
|
|
497
|
+
let loadOptions;
|
|
498
|
+
if (fType === 'text' && facetsBase) {
|
|
499
|
+
const loader = getFacetLoader(f.column || f.key);
|
|
500
|
+
if (loader) {
|
|
501
|
+
fType = 'facet';
|
|
502
|
+
loadOptions = loader;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
476
505
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
477
506
|
continue;
|
|
478
507
|
map.set(f.key, {
|
|
@@ -483,6 +512,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
483
512
|
onFilterChange: handleDynamicFilterChange,
|
|
484
513
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
485
514
|
searchEndpoint: f.searchEndpoint,
|
|
515
|
+
loadOptions,
|
|
486
516
|
});
|
|
487
517
|
}
|
|
488
518
|
for (const c of metadata.columns ?? []) {
|
|
@@ -500,8 +530,12 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
500
530
|
// - number / number_range / numeric → number range
|
|
501
531
|
// - date → date range picker (start/end calendar)
|
|
502
532
|
// - everything else (text, email, phone, tags…) → text contains
|
|
533
|
+
// (A) Stage column with no options of its own → colored select.
|
|
534
|
+
const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0;
|
|
503
535
|
let filterType;
|
|
504
|
-
if (
|
|
536
|
+
if (isStageColumn)
|
|
537
|
+
filterType = 'select';
|
|
538
|
+
else if (c.filterType)
|
|
505
539
|
filterType = c.filterType;
|
|
506
540
|
else if (isRelation && hasEndpoint)
|
|
507
541
|
filterType = 'dynamic_select';
|
|
@@ -515,7 +549,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
515
549
|
filterType = 'date_range';
|
|
516
550
|
else
|
|
517
551
|
filterType = 'text';
|
|
518
|
-
|
|
552
|
+
let options = hasStaticOptions
|
|
519
553
|
? c.options.map(o => ({
|
|
520
554
|
label: o.label,
|
|
521
555
|
value: String(o.value),
|
|
@@ -525,6 +559,18 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
525
559
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
|
|
526
560
|
? filterOptionsMap.get(c.searchEndpoint) || []
|
|
527
561
|
: [];
|
|
562
|
+
if (isStageColumn)
|
|
563
|
+
options = stageOptions;
|
|
564
|
+
// (B) Upgrade a plain text filter to a facet value-picker unless it's
|
|
565
|
+
// a long-text/body column (too many unique values to enumerate).
|
|
566
|
+
let loadOptions;
|
|
567
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
568
|
+
const loader = getFacetLoader(c.key);
|
|
569
|
+
if (loader) {
|
|
570
|
+
filterType = 'facet';
|
|
571
|
+
loadOptions = loader;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
528
574
|
map.set(c.key, {
|
|
529
575
|
filterType,
|
|
530
576
|
filterKey: c.key,
|
|
@@ -533,10 +579,11 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
533
579
|
onFilterChange: handleDynamicFilterChange,
|
|
534
580
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
535
581
|
searchEndpoint: c.searchEndpoint,
|
|
582
|
+
loadOptions,
|
|
536
583
|
});
|
|
537
584
|
}
|
|
538
585
|
return map;
|
|
539
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
|
|
586
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
|
|
540
587
|
const columns = useMemo(() => {
|
|
541
588
|
if (!viewMetadata)
|
|
542
589
|
return [];
|
|
@@ -6,6 +6,25 @@ export interface UseDynamicFiltersOptions {
|
|
|
6
6
|
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
7
7
|
*/
|
|
8
8
|
defaultFilters?: Record<string, any>;
|
|
9
|
+
/**
|
|
10
|
+
* Model key — the fallback base for the per-field `facets` endpoint when no
|
|
11
|
+
* `endpoint` is given. Omit all of `endpoint`/`model`/`facetsEndpoint` to keep
|
|
12
|
+
* text filters as bare "Contiene..." boxes.
|
|
13
|
+
*/
|
|
14
|
+
model?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The org-scoped LIST endpoint (e.g. `/data/<model>/me`). The facets base is
|
|
17
|
+
* derived from it EXACTLY like DynamicTable derives the aggregate endpoint —
|
|
18
|
+
* `<endpoint>/facets` — so the board and its table sibling hit the same route
|
|
19
|
+
* (the backend registers both `/data/:model/facets` and
|
|
20
|
+
* `/data/:model/me/facets`).
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Explicit `facets` endpoint override — wins over `endpoint`/`model`. The
|
|
25
|
+
* loader appends `?field=<key>&q=<text>&limit=50`.
|
|
26
|
+
*/
|
|
27
|
+
facetsEndpoint?: string;
|
|
9
28
|
}
|
|
10
29
|
export interface UseDynamicFiltersResult {
|
|
11
30
|
/** Per-field selected values, `filterKey → values[]` (empty = inactive). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"
|
|
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"}
|