@asteby/metacore-runtime-react 20.1.7 → 21.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 +25 -0
- package/dist/dynamic-kanban.d.ts +6 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +42 -15
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/use-dynamic-filters.d.ts +38 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -0
- package/dist/use-dynamic-filters.js +226 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +52 -1
- package/src/dynamic-kanban.tsx +99 -9
- package/src/index.ts +5 -0
- package/src/use-dynamic-filters.ts +272 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 21.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 84aeaf2: feat(kanban): dynamic column filters on DynamicKanban
|
|
8
|
+
|
|
9
|
+
The board now filters like its DynamicTable sibling. A filter bar above the
|
|
10
|
+
lanes exposes a global search box plus one chip per filterable field (from
|
|
11
|
+
`metadata.filters[]` and every `filterable` column), driving the SAME
|
|
12
|
+
server-side `f_<key>=<op>:<value>` / `search` params — so a model's table and
|
|
13
|
+
kanban views filter identically.
|
|
14
|
+
- **ui**: extracted the per-column filter popover into a new, TanStack-agnostic
|
|
15
|
+
`ColumnFilterControl` (select/boolean/dynamic_select/text/number_range/
|
|
16
|
+
date_range). `FilterableColumnHeader` now delegates its filter UI to it
|
|
17
|
+
(behavior unchanged); it also powers the kanban's labeled filter chips.
|
|
18
|
+
- **runtime-react**: new `useDynamicFilters(metadata)` hook owning the filter
|
|
19
|
+
state, option prefetch, config derivation and param serialization (factored
|
|
20
|
+
out of DynamicTable's inline logic). `DynamicKanban` consumes it and gains a
|
|
21
|
+
`defaultFilters` prop for parity with DynamicTable.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [84aeaf2]
|
|
26
|
+
- @asteby/metacore-ui@2.7.0
|
|
27
|
+
|
|
3
28
|
## 20.1.7
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -68,6 +68,11 @@ export interface DynamicKanbanProps {
|
|
|
68
68
|
timeZone?: string;
|
|
69
69
|
/** ISO 4217 currency for money card fields (org config). */
|
|
70
70
|
currency?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Static equality filters always applied to the board (never shown as a
|
|
73
|
+
* removable chip). Same contract as DynamicTable's `defaultFilters`.
|
|
74
|
+
*/
|
|
75
|
+
defaultFilters?: Record<string, any>;
|
|
71
76
|
}
|
|
72
|
-
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, timeZone, currency, }: DynamicKanbanProps): React.JSX.Element;
|
|
77
|
+
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
|
|
73
78
|
//# sourceMappingURL=dynamic-kanban.d.ts.map
|
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAuC9B,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;AA8BD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACvC;AAED,wBAAgB,aAAa,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,EACR,QAAc,EACd,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBAsXpB"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -2,11 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, 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 { MoreHorizontal } from 'lucide-react';
|
|
5
|
+
import { MoreHorizontal, Search, X } from 'lucide-react';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
|
-
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
7
|
+
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
8
|
+
import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
|
|
8
9
|
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
9
10
|
import { useApi } from './api-context';
|
|
11
|
+
import { useDynamicFilters } from './use-dynamic-filters';
|
|
10
12
|
import { useMetadataCache } from './metadata-cache';
|
|
11
13
|
import { ActivityValueRenderer } from './activity-value-renderer';
|
|
12
14
|
import { DynamicIcon } from './dynamic-icon';
|
|
@@ -146,7 +148,7 @@ function useIsDarkTheme() {
|
|
|
146
148
|
}, []);
|
|
147
149
|
return isDark;
|
|
148
150
|
}
|
|
149
|
-
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 200, timeZone, currency, }) {
|
|
151
|
+
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 200, timeZone, currency, defaultFilters, }) {
|
|
150
152
|
const { t, i18n } = useTranslation();
|
|
151
153
|
const api = useApi();
|
|
152
154
|
const isDark = useIsDarkTheme();
|
|
@@ -194,6 +196,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
194
196
|
};
|
|
195
197
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
196
198
|
}, [model]);
|
|
199
|
+
// Shared metadata-driven filter engine — the SAME configs, option prefetch
|
|
200
|
+
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
201
|
+
// identically to its table sibling.
|
|
202
|
+
const { globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, clearAll, } = useDynamicFilters(metadata, { defaultFilters });
|
|
197
203
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
198
204
|
const fetchData = useCallback(async () => {
|
|
199
205
|
if (!metadata)
|
|
@@ -201,7 +207,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
201
207
|
setLoadingData(true);
|
|
202
208
|
try {
|
|
203
209
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
204
|
-
params: { page: 1, per_page: pageSize },
|
|
210
|
+
params: { page: 1, per_page: pageSize, ...filterParams },
|
|
205
211
|
}));
|
|
206
212
|
if (res.data.success)
|
|
207
213
|
setRecords(res.data.data || []);
|
|
@@ -212,12 +218,33 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
212
218
|
finally {
|
|
213
219
|
setLoadingData(false);
|
|
214
220
|
}
|
|
215
|
-
}, [api, endpoint, model, metadata, pageSize]);
|
|
221
|
+
}, [api, endpoint, model, metadata, pageSize, filterParams]);
|
|
222
|
+
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
223
|
+
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
224
|
+
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
225
|
+
// typing in the search box doesn't fire a request per keystroke.
|
|
216
226
|
useEffect(() => {
|
|
217
|
-
if (metadata)
|
|
227
|
+
if (!metadata)
|
|
228
|
+
return;
|
|
229
|
+
const handle = setTimeout(() => {
|
|
218
230
|
void fetchData();
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
}, 200);
|
|
232
|
+
return () => clearTimeout(handle);
|
|
233
|
+
}, [fetchData, metadata, refreshTrigger]);
|
|
234
|
+
// Filterable fields for the toolbar, in metadata order (explicit filters
|
|
235
|
+
// first, then filterable columns), each labeled from its metadata source.
|
|
236
|
+
const filterFields = useMemo(() => {
|
|
237
|
+
if (!metadata)
|
|
238
|
+
return [];
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
241
|
+
const f = metadata.filters?.find((x) => x.key === key);
|
|
242
|
+
const c = metadata.columns.find((x) => x.key === key);
|
|
243
|
+
const rawLabel = f?.label || c?.label || key;
|
|
244
|
+
out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}, [metadata, columnFilterConfigs, t]);
|
|
221
248
|
const stages = useMemo(() => (metadata ? deriveStages(metadata) : []), [metadata]);
|
|
222
249
|
const groupByKey = metadata?.group_by || '';
|
|
223
250
|
const transitions = metadata?.transitions;
|
|
@@ -318,13 +345,13 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
318
345
|
order: Number.MAX_SAFE_INTEGER,
|
|
319
346
|
});
|
|
320
347
|
}
|
|
321
|
-
return (_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) => {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
348
|
+
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.map((field) => (_jsx(ColumnFilterControl, { showLabel: true, label: field.label, 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 }, field.key))), activeFilterCount > 0 && (_jsxs(Button, { variant: "ghost", size: "sm", className: "h-8 gap-1 text-xs text-muted-foreground", onClick: clearAll, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearFilters', { defaultValue: 'Limpiar' }), ` (${activeFilterCount})`] }))] }), _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) => {
|
|
349
|
+
const cards = grouped.get(stage.key) ?? [];
|
|
350
|
+
const droppableAllowed = !activeId ||
|
|
351
|
+
stage.key === activeStage ||
|
|
352
|
+
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
353
|
+
return (_jsx(KanbanLane, { stage: stage, count: cards.length, 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));
|
|
354
|
+
}) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
|
|
328
355
|
}
|
|
329
356
|
function KanbanLane({ stage, count, isDark, dimmed, disabled, children }) {
|
|
330
357
|
const { t } = useTranslation();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './options-context';
|
|
|
3
3
|
export * from './dynamic-table';
|
|
4
4
|
export { DynamicKanban, type DynamicKanbanProps, deriveStages, groupByStage, isTransitionAllowed, applyOptimisticMove, selectCardColumns, UNASSIGNED_LANE, } from './dynamic-kanban';
|
|
5
5
|
export { DynamicView, resolveViewRenderer, readViewFromSearch, resolveActiveView, type DynamicViewProps, } from './dynamic-view';
|
|
6
|
+
export { useDynamicFilters, type UseDynamicFiltersOptions, type UseDynamicFiltersResult, } from './use-dynamic-filters';
|
|
6
7
|
export * from './dynamic-form';
|
|
7
8
|
export { ActionModalDispatcher, type ActionModalProps, } from './action-modal-dispatcher';
|
|
8
9
|
export { ModelActionToolbar, useModelActions, type ModelActionToolbarProps, type ActionPlacement, } from './model-action-toolbar';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from './options-context';
|
|
|
8
8
|
export * from './dynamic-table';
|
|
9
9
|
export { DynamicKanban, deriveStages, groupByStage, isTransitionAllowed, applyOptimisticMove, selectCardColumns, UNASSIGNED_LANE, } from './dynamic-kanban';
|
|
10
10
|
export { DynamicView, resolveViewRenderer, readViewFromSearch, resolveActiveView, } from './dynamic-view';
|
|
11
|
+
export { useDynamicFilters, } from './use-dynamic-filters';
|
|
11
12
|
export * from './dynamic-form';
|
|
12
13
|
export { ActionModalDispatcher, } from './action-modal-dispatcher';
|
|
13
14
|
export { ModelActionToolbar, useModelActions, } from './model-action-toolbar';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ColumnFilterConfig } from './dynamic-columns-shim';
|
|
2
|
+
import type { TableMetadata } from './types';
|
|
3
|
+
export interface UseDynamicFiltersOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Static equality filters always applied (never shown as removable chips).
|
|
6
|
+
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
7
|
+
*/
|
|
8
|
+
defaultFilters?: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
export interface UseDynamicFiltersResult {
|
|
11
|
+
/** Per-field selected values, `filterKey → values[]` (empty = inactive). */
|
|
12
|
+
dynamicFilters: Record<string, string[]>;
|
|
13
|
+
/** Free-text search term (the toolbar search box). */
|
|
14
|
+
globalFilter: string;
|
|
15
|
+
setGlobalFilter: (v: string) => void;
|
|
16
|
+
/** Config for each filterable field, keyed by the metadata filter/column key. */
|
|
17
|
+
columnFilterConfigs: Map<string, ColumnFilterConfig>;
|
|
18
|
+
/**
|
|
19
|
+
* The serialized query params (`f_<key>`, `search`, `search_columns`) ready to
|
|
20
|
+
* spread into a `/data/:model` request. Stable object identity while inputs
|
|
21
|
+
* are unchanged so a consumer can safely depend on it in a fetch effect.
|
|
22
|
+
*/
|
|
23
|
+
filterParams: Record<string, any>;
|
|
24
|
+
/** Apply a field's selection (replaces its values). */
|
|
25
|
+
handleDynamicFilterChange: (filterKey: string, values: string[]) => void;
|
|
26
|
+
/** Count of fields with an active selection + the search term. */
|
|
27
|
+
activeFilterCount: number;
|
|
28
|
+
/** Clear every field filter and the search term. */
|
|
29
|
+
clearAll: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads the model's filter metadata and returns the live filter state + the
|
|
33
|
+
* serialized request params. Pure of any view concern — DynamicTable adds its
|
|
34
|
+
* own sort/pagination/`columnFilters`; DynamicKanban spreads `filterParams`
|
|
35
|
+
* straight onto its single-page fetch.
|
|
36
|
+
*/
|
|
37
|
+
export declare function useDynamicFilters(metadata: TableMetadata | null, opts?: UseDynamicFiltersOptions): UseDynamicFiltersResult;
|
|
38
|
+
//# sourceMappingURL=use-dynamic-filters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"AAgBA,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;CACrC;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,CAqNzB"}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// useDynamicFilters — the metadata-driven filter engine shared by DynamicTable
|
|
2
|
+
// and DynamicKanban. It owns nothing view-specific: given a model's
|
|
3
|
+
// `TableMetadata`, it derives the per-field filter configs (from
|
|
4
|
+
// `metadata.filters[]` + every `metadata.columns[].filterable` column),
|
|
5
|
+
// prefetches the server-side option lists for relation/select filters, tracks
|
|
6
|
+
// the selected values + global search, and serializes them into the exact
|
|
7
|
+
// `f_<key>=<op>:<value>` / `search` query params the `/data/:model` endpoint
|
|
8
|
+
// already understands.
|
|
9
|
+
//
|
|
10
|
+
// DynamicTable historically inlined this; DynamicKanban now needs the SAME
|
|
11
|
+
// filtering (RFC: a board is just another view of the model), so the logic is
|
|
12
|
+
// factored here to guarantee both surfaces filter identically.
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { useApi } from './api-context';
|
|
15
|
+
import { DATE_CELL_TYPES } from './dynamic-columns';
|
|
16
|
+
import { getSearchableColumnKeys } from './column-visibility';
|
|
17
|
+
/**
|
|
18
|
+
* Reads the model's filter metadata and returns the live filter state + the
|
|
19
|
+
* serialized request params. Pure of any view concern — DynamicTable adds its
|
|
20
|
+
* own sort/pagination/`columnFilters`; DynamicKanban spreads `filterParams`
|
|
21
|
+
* straight onto its single-page fetch.
|
|
22
|
+
*/
|
|
23
|
+
export function useDynamicFilters(metadata, opts = {}) {
|
|
24
|
+
const { defaultFilters } = opts;
|
|
25
|
+
const api = useApi();
|
|
26
|
+
const [dynamicFilters, setDynamicFilters] = useState({});
|
|
27
|
+
const [globalFilter, setGlobalFilter] = useState('');
|
|
28
|
+
const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
|
|
29
|
+
// Prefetch the option lists for relation/select filters (once per model). The
|
|
30
|
+
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
31
|
+
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
32
|
+
// kanban renders card cells through ActivityValueRenderer, not the cell
|
|
33
|
+
// options map, so we skip that half).
|
|
34
|
+
const prefetchedSig = useRef(null);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!metadata)
|
|
37
|
+
return;
|
|
38
|
+
const filterEndpoints = (metadata.filters || [])
|
|
39
|
+
.filter((f) => f.searchEndpoint &&
|
|
40
|
+
(f.type === 'select' ||
|
|
41
|
+
f.type === 'dynamic_select' ||
|
|
42
|
+
f.type === 'boolean'))
|
|
43
|
+
.map((f) => f.searchEndpoint);
|
|
44
|
+
const columnFilterEndpoints = (metadata.columns || [])
|
|
45
|
+
.filter((c) => c.filterable && c.searchEndpoint)
|
|
46
|
+
.map((c) => c.searchEndpoint);
|
|
47
|
+
const endpoints = Array.from(new Set([...filterEndpoints, ...columnFilterEndpoints]));
|
|
48
|
+
if (endpoints.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
// Dedup on the endpoint set, not model identity (TableMetadata carries no
|
|
51
|
+
// name) — the same board re-rendering shouldn't refetch its options.
|
|
52
|
+
const sig = endpoints.join('|');
|
|
53
|
+
if (prefetchedSig.current === sig)
|
|
54
|
+
return;
|
|
55
|
+
prefetchedSig.current = sig;
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
Promise.all(endpoints.map(async (ep) => {
|
|
58
|
+
try {
|
|
59
|
+
const res = await api.get(ep);
|
|
60
|
+
return {
|
|
61
|
+
endpoint: ep,
|
|
62
|
+
data: res.data?.success ? res.data.data : [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
console.error(`Failed to fetch filter options for ${ep}`, e);
|
|
67
|
+
return { endpoint: ep, data: [] };
|
|
68
|
+
}
|
|
69
|
+
})).then((results) => {
|
|
70
|
+
if (cancelled)
|
|
71
|
+
return;
|
|
72
|
+
const fMap = new Map();
|
|
73
|
+
for (const r of results) {
|
|
74
|
+
fMap.set(r.endpoint, (r.data || []).map((item) => ({
|
|
75
|
+
label: item.label || item.name || '',
|
|
76
|
+
value: String(item.value ?? item.id ?? ''),
|
|
77
|
+
icon: item.icon,
|
|
78
|
+
color: item.color || item.class,
|
|
79
|
+
})));
|
|
80
|
+
}
|
|
81
|
+
setFilterOptionsMap(fMap);
|
|
82
|
+
});
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [metadata]);
|
|
88
|
+
const handleDynamicFilterChange = useCallback((filterKey, values) => {
|
|
89
|
+
setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }));
|
|
90
|
+
}, []);
|
|
91
|
+
const clearAll = useCallback(() => {
|
|
92
|
+
setDynamicFilters({});
|
|
93
|
+
setGlobalFilter('');
|
|
94
|
+
}, []);
|
|
95
|
+
// Derive one ColumnFilterConfig per filterable field. Explicit
|
|
96
|
+
// `metadata.filters[]` wins; otherwise every `filterable` column yields a
|
|
97
|
+
// config with its UI inferred from the column shape. Copied 1:1 from
|
|
98
|
+
// DynamicTable so table and board build identical configs.
|
|
99
|
+
const columnFilterConfigs = useMemo(() => {
|
|
100
|
+
const map = new Map();
|
|
101
|
+
if (!metadata)
|
|
102
|
+
return map;
|
|
103
|
+
for (const f of metadata.filters ?? []) {
|
|
104
|
+
const fType = f.type;
|
|
105
|
+
let options = [];
|
|
106
|
+
if (f.options && f.options.length > 0) {
|
|
107
|
+
options = f.options.map((o) => ({
|
|
108
|
+
label: o.label,
|
|
109
|
+
value: String(o.value),
|
|
110
|
+
icon: o.icon,
|
|
111
|
+
color: o.color,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
115
|
+
options = filterOptionsMap.get(f.searchEndpoint) || [];
|
|
116
|
+
}
|
|
117
|
+
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
118
|
+
continue;
|
|
119
|
+
map.set(f.key, {
|
|
120
|
+
filterType: fType,
|
|
121
|
+
filterKey: f.column || f.key,
|
|
122
|
+
options,
|
|
123
|
+
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
124
|
+
onFilterChange: handleDynamicFilterChange,
|
|
125
|
+
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
126
|
+
searchEndpoint: f.searchEndpoint,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const c of metadata.columns ?? []) {
|
|
130
|
+
if (!c.filterable || map.has(c.key))
|
|
131
|
+
continue;
|
|
132
|
+
const hasStaticOptions = (c.options?.length ?? 0) > 0;
|
|
133
|
+
const hasEndpoint = !!c.searchEndpoint;
|
|
134
|
+
const isRelation = !!c.ref || c.filterType === 'dynamic_select';
|
|
135
|
+
let filterType;
|
|
136
|
+
if (c.filterType)
|
|
137
|
+
filterType = c.filterType;
|
|
138
|
+
else if (isRelation && hasEndpoint)
|
|
139
|
+
filterType = 'dynamic_select';
|
|
140
|
+
else if (hasStaticOptions || hasEndpoint)
|
|
141
|
+
filterType = 'select';
|
|
142
|
+
else if (c.type === 'boolean')
|
|
143
|
+
filterType = 'boolean';
|
|
144
|
+
else if (c.type === 'number')
|
|
145
|
+
filterType = 'number_range';
|
|
146
|
+
else if (DATE_CELL_TYPES.includes(c.type))
|
|
147
|
+
filterType = 'date_range';
|
|
148
|
+
else
|
|
149
|
+
filterType = 'text';
|
|
150
|
+
const options = hasStaticOptions
|
|
151
|
+
? c.options.map((o) => ({
|
|
152
|
+
label: o.label,
|
|
153
|
+
value: String(o.value),
|
|
154
|
+
icon: o.icon,
|
|
155
|
+
color: o.color,
|
|
156
|
+
}))
|
|
157
|
+
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
|
|
158
|
+
? filterOptionsMap.get(c.searchEndpoint) || []
|
|
159
|
+
: [];
|
|
160
|
+
map.set(c.key, {
|
|
161
|
+
filterType,
|
|
162
|
+
filterKey: c.key,
|
|
163
|
+
options,
|
|
164
|
+
selectedValues: dynamicFilters[c.key] || [],
|
|
165
|
+
onFilterChange: handleDynamicFilterChange,
|
|
166
|
+
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
167
|
+
searchEndpoint: c.searchEndpoint,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return map;
|
|
171
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
|
|
172
|
+
const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
|
|
173
|
+
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
174
|
+
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
175
|
+
const filterParams = useMemo(() => {
|
|
176
|
+
const params = {};
|
|
177
|
+
if (globalFilter) {
|
|
178
|
+
if (searchableKeys === null) {
|
|
179
|
+
params.search = globalFilter;
|
|
180
|
+
}
|
|
181
|
+
else if (searchableKeys.length > 0) {
|
|
182
|
+
params.search = globalFilter;
|
|
183
|
+
params.search_columns = searchableKeys.join(',');
|
|
184
|
+
}
|
|
185
|
+
// searchableKeys === [] → no searchable column, skip the search param.
|
|
186
|
+
}
|
|
187
|
+
if (defaultFilters) {
|
|
188
|
+
Object.entries(defaultFilters).forEach(([key, value]) => {
|
|
189
|
+
params[`f_${key}`] = value;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
Object.entries(dynamicFilters).forEach(([key, values]) => {
|
|
193
|
+
if (values.length === 0)
|
|
194
|
+
return;
|
|
195
|
+
const gteVal = values.find((v) => v.startsWith('GTE:'));
|
|
196
|
+
const lteVal = values.find((v) => v.startsWith('LTE:'));
|
|
197
|
+
if (gteVal || lteVal) {
|
|
198
|
+
const min = gteVal ? gteVal.replace('GTE:', '') : '';
|
|
199
|
+
const max = lteVal ? lteVal.replace('LTE:', '') : '';
|
|
200
|
+
params[`f_${key}`] = `RANGE:${min},${max}`;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (values.length === 1)
|
|
204
|
+
params[`f_${key}`] = values[0];
|
|
205
|
+
else
|
|
206
|
+
params[`f_${key}`] = `IN:${values.join(',')}`;
|
|
207
|
+
});
|
|
208
|
+
return params;
|
|
209
|
+
}, [globalFilter, searchableKeys, defaultFilters, dynamicFilters]);
|
|
210
|
+
const activeFilterCount = useMemo(() => {
|
|
211
|
+
let n = Object.values(dynamicFilters).filter((v) => v.length > 0).length;
|
|
212
|
+
if (globalFilter)
|
|
213
|
+
n += 1;
|
|
214
|
+
return n;
|
|
215
|
+
}, [dynamicFilters, globalFilter]);
|
|
216
|
+
return {
|
|
217
|
+
dynamicFilters,
|
|
218
|
+
globalFilter,
|
|
219
|
+
setGlobalFilter,
|
|
220
|
+
columnFilterConfigs,
|
|
221
|
+
filterParams,
|
|
222
|
+
handleDynamicFilterChange,
|
|
223
|
+
activeFilterCount,
|
|
224
|
+
clearAll,
|
|
225
|
+
};
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.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.7.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.
|
|
71
|
+
"@asteby/metacore-ui": "2.7.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// and the destination payload is `{ <group_by>: <dest> }`; an invalid
|
|
12
12
|
// transition is rejected before any PUT.
|
|
13
13
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
-
import { cleanup, render, screen } from '@testing-library/react'
|
|
14
|
+
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
15
15
|
|
|
16
16
|
// react-i18next: identity translator returning the defaultValue so card field
|
|
17
17
|
// labels and lane labels surface verbatim. The `t`/`i18n` references are STABLE
|
|
@@ -276,3 +276,54 @@ describe('optimistic drag contract', () => {
|
|
|
276
276
|
// handler returns early — no applyOptimisticMove, no PUT.
|
|
277
277
|
})
|
|
278
278
|
})
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// 4. Filter bar — the DynamicKanban parity with DynamicTable column filters.
|
|
282
|
+
// The bar renders a search box + one chip per filterable field, and any
|
|
283
|
+
// control refetches the board server-side through useDynamicFilters
|
|
284
|
+
// (debounced), carrying the same `f_<key>` / `search` params.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
describe('DynamicKanban filter bar', () => {
|
|
288
|
+
it('renders a search box and a chip for each filterable field', async () => {
|
|
289
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
290
|
+
render(
|
|
291
|
+
<ApiProvider client={fakeApi()}>
|
|
292
|
+
<DynamicKanban model="issue" />
|
|
293
|
+
</ApiProvider>,
|
|
294
|
+
)
|
|
295
|
+
// search box
|
|
296
|
+
expect(await screen.findByPlaceholderText('Buscar...')).toBeTruthy()
|
|
297
|
+
// the only `filterable: true` column is `stage` → a "Stage" filter chip.
|
|
298
|
+
// (Lane headers use the STAGE labels Backlog/In Progress/… — never "Stage".)
|
|
299
|
+
expect(screen.getByText('Stage')).toBeTruthy()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('typing in the search box refetches the board with the search param', async () => {
|
|
303
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
304
|
+
const get = vi.fn(async (url: string) => {
|
|
305
|
+
if (url.startsWith('/metadata/table/'))
|
|
306
|
+
return { data: { success: true, data: meta() } }
|
|
307
|
+
return { data: { success: true, data: CARDS } }
|
|
308
|
+
})
|
|
309
|
+
render(
|
|
310
|
+
<ApiProvider client={fakeApi({ get })}>
|
|
311
|
+
<DynamicKanban model="issue" />
|
|
312
|
+
</ApiProvider>,
|
|
313
|
+
)
|
|
314
|
+
const box = await screen.findByPlaceholderText('Buscar...')
|
|
315
|
+
fireEvent.change(box, { target: { value: 'login' } })
|
|
316
|
+
await waitFor(() =>
|
|
317
|
+
expect(get).toHaveBeenCalledWith(
|
|
318
|
+
'/data/issue',
|
|
319
|
+
expect.objectContaining({
|
|
320
|
+
params: expect.objectContaining({
|
|
321
|
+
search: 'login',
|
|
322
|
+
// `title` is the only `searchable: true` column in the fixture.
|
|
323
|
+
search_columns: 'title',
|
|
324
|
+
}),
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
})
|
|
329
|
+
})
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type DragStartEvent,
|
|
39
39
|
type DragEndEvent,
|
|
40
40
|
} from '@dnd-kit/core'
|
|
41
|
-
import { MoreHorizontal } from 'lucide-react'
|
|
41
|
+
import { MoreHorizontal, Search, X } from 'lucide-react'
|
|
42
42
|
import { toast } from 'sonner'
|
|
43
43
|
import {
|
|
44
44
|
Badge,
|
|
@@ -49,10 +49,13 @@ import {
|
|
|
49
49
|
DropdownMenuContent,
|
|
50
50
|
DropdownMenuItem,
|
|
51
51
|
DropdownMenuTrigger,
|
|
52
|
+
Input,
|
|
52
53
|
Skeleton,
|
|
53
54
|
} from '@asteby/metacore-ui/primitives'
|
|
55
|
+
import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
54
56
|
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
55
57
|
import { useApi } from './api-context'
|
|
58
|
+
import { useDynamicFilters } from './use-dynamic-filters'
|
|
56
59
|
import { useMetadataCache } from './metadata-cache'
|
|
57
60
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
58
61
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -262,6 +265,11 @@ export interface DynamicKanbanProps {
|
|
|
262
265
|
timeZone?: string
|
|
263
266
|
/** ISO 4217 currency for money card fields (org config). */
|
|
264
267
|
currency?: string
|
|
268
|
+
/**
|
|
269
|
+
* Static equality filters always applied to the board (never shown as a
|
|
270
|
+
* removable chip). Same contract as DynamicTable's `defaultFilters`.
|
|
271
|
+
*/
|
|
272
|
+
defaultFilters?: Record<string, any>
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
export function DynamicKanban({
|
|
@@ -273,6 +281,7 @@ export function DynamicKanban({
|
|
|
273
281
|
pageSize = 200,
|
|
274
282
|
timeZone,
|
|
275
283
|
currency,
|
|
284
|
+
defaultFilters,
|
|
276
285
|
}: DynamicKanbanProps) {
|
|
277
286
|
const { t, i18n } = useTranslation()
|
|
278
287
|
const api = useApi()
|
|
@@ -326,13 +335,25 @@ export function DynamicKanban({
|
|
|
326
335
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
336
|
}, [model])
|
|
328
337
|
|
|
338
|
+
// Shared metadata-driven filter engine — the SAME configs, option prefetch
|
|
339
|
+
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
340
|
+
// identically to its table sibling.
|
|
341
|
+
const {
|
|
342
|
+
globalFilter,
|
|
343
|
+
setGlobalFilter,
|
|
344
|
+
columnFilterConfigs,
|
|
345
|
+
filterParams,
|
|
346
|
+
activeFilterCount,
|
|
347
|
+
clearAll,
|
|
348
|
+
} = useDynamicFilters(metadata, { defaultFilters })
|
|
349
|
+
|
|
329
350
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
330
351
|
const fetchData = useCallback(async () => {
|
|
331
352
|
if (!metadata) return
|
|
332
353
|
setLoadingData(true)
|
|
333
354
|
try {
|
|
334
355
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
335
|
-
params: { page: 1, per_page: pageSize },
|
|
356
|
+
params: { page: 1, per_page: pageSize, ...filterParams },
|
|
336
357
|
})) as { data: ApiResponse<any[]> }
|
|
337
358
|
if (res.data.success) setRecords(res.data.data || [])
|
|
338
359
|
} catch (err) {
|
|
@@ -340,12 +361,37 @@ export function DynamicKanban({
|
|
|
340
361
|
} finally {
|
|
341
362
|
setLoadingData(false)
|
|
342
363
|
}
|
|
343
|
-
}, [api, endpoint, model, metadata, pageSize])
|
|
364
|
+
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
344
365
|
|
|
366
|
+
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
367
|
+
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
368
|
+
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
369
|
+
// typing in the search box doesn't fire a request per keystroke.
|
|
345
370
|
useEffect(() => {
|
|
346
|
-
if (metadata)
|
|
347
|
-
|
|
348
|
-
|
|
371
|
+
if (!metadata) return
|
|
372
|
+
const handle = setTimeout(() => {
|
|
373
|
+
void fetchData()
|
|
374
|
+
}, 200)
|
|
375
|
+
return () => clearTimeout(handle)
|
|
376
|
+
}, [fetchData, metadata, refreshTrigger])
|
|
377
|
+
|
|
378
|
+
// Filterable fields for the toolbar, in metadata order (explicit filters
|
|
379
|
+
// first, then filterable columns), each labeled from its metadata source.
|
|
380
|
+
const filterFields = useMemo(() => {
|
|
381
|
+
if (!metadata) return []
|
|
382
|
+
const out: {
|
|
383
|
+
key: string
|
|
384
|
+
label: string
|
|
385
|
+
config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
|
|
386
|
+
}[] = []
|
|
387
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
388
|
+
const f = metadata.filters?.find((x) => x.key === key)
|
|
389
|
+
const c = metadata.columns.find((x) => x.key === key)
|
|
390
|
+
const rawLabel = f?.label || c?.label || key
|
|
391
|
+
out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
|
|
392
|
+
}
|
|
393
|
+
return out
|
|
394
|
+
}, [metadata, columnFilterConfigs, t])
|
|
349
395
|
|
|
350
396
|
const stages = useMemo(
|
|
351
397
|
() => (metadata ? deriveStages(metadata) : []),
|
|
@@ -503,8 +549,51 @@ export function DynamicKanban({
|
|
|
503
549
|
}
|
|
504
550
|
|
|
505
551
|
return (
|
|
506
|
-
<
|
|
507
|
-
|
|
552
|
+
<div className="flex flex-col gap-3">
|
|
553
|
+
{/* Filter bar — global search + one chip per filterable field, the
|
|
554
|
+
SAME set the DynamicTable exposes in its column headers. Changing
|
|
555
|
+
any control refetches the board server-side (debounced) via the
|
|
556
|
+
shared useDynamicFilters engine. */}
|
|
557
|
+
<div className="flex flex-wrap items-center gap-2" data-testid="kanban-filters">
|
|
558
|
+
<div className="relative">
|
|
559
|
+
<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" />
|
|
560
|
+
<Input
|
|
561
|
+
value={globalFilter}
|
|
562
|
+
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
563
|
+
placeholder={t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' })}
|
|
564
|
+
className="h-8 w-52 pl-8 text-sm"
|
|
565
|
+
/>
|
|
566
|
+
</div>
|
|
567
|
+
{filterFields.map((field) => (
|
|
568
|
+
<ColumnFilterControl
|
|
569
|
+
key={field.key}
|
|
570
|
+
showLabel
|
|
571
|
+
label={field.label}
|
|
572
|
+
filterKey={field.config.filterKey}
|
|
573
|
+
filterType={field.config.filterType as ColumnFilterType}
|
|
574
|
+
filterOptions={field.config.options}
|
|
575
|
+
filterLoading={field.config.loading}
|
|
576
|
+
filterSearchEndpoint={field.config.searchEndpoint}
|
|
577
|
+
selectedValues={field.config.selectedValues}
|
|
578
|
+
onFilterChange={field.config.onFilterChange}
|
|
579
|
+
/>
|
|
580
|
+
))}
|
|
581
|
+
{activeFilterCount > 0 && (
|
|
582
|
+
<Button
|
|
583
|
+
variant="ghost"
|
|
584
|
+
size="sm"
|
|
585
|
+
className="h-8 gap-1 text-xs text-muted-foreground"
|
|
586
|
+
onClick={clearAll}
|
|
587
|
+
>
|
|
588
|
+
<X className="h-3.5 w-3.5" />
|
|
589
|
+
{t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
|
|
590
|
+
{` (${activeFilterCount})`}
|
|
591
|
+
</Button>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
596
|
+
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
508
597
|
{lanes.map((stage) => {
|
|
509
598
|
const cards = grouped.get(stage.key) ?? []
|
|
510
599
|
const droppableAllowed =
|
|
@@ -564,7 +653,8 @@ export function DynamicKanban({
|
|
|
564
653
|
</DragOverlay>
|
|
565
654
|
|
|
566
655
|
{rowActionDialogs}
|
|
567
|
-
|
|
656
|
+
</DndContext>
|
|
657
|
+
</div>
|
|
568
658
|
)
|
|
569
659
|
}
|
|
570
660
|
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,11 @@ export {
|
|
|
23
23
|
resolveActiveView,
|
|
24
24
|
type DynamicViewProps,
|
|
25
25
|
} from './dynamic-view'
|
|
26
|
+
export {
|
|
27
|
+
useDynamicFilters,
|
|
28
|
+
type UseDynamicFiltersOptions,
|
|
29
|
+
type UseDynamicFiltersResult,
|
|
30
|
+
} from './use-dynamic-filters'
|
|
26
31
|
export * from './dynamic-form'
|
|
27
32
|
export {
|
|
28
33
|
ActionModalDispatcher,
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// useDynamicFilters — the metadata-driven filter engine shared by DynamicTable
|
|
2
|
+
// and DynamicKanban. It owns nothing view-specific: given a model's
|
|
3
|
+
// `TableMetadata`, it derives the per-field filter configs (from
|
|
4
|
+
// `metadata.filters[]` + every `metadata.columns[].filterable` column),
|
|
5
|
+
// prefetches the server-side option lists for relation/select filters, tracks
|
|
6
|
+
// the selected values + global search, and serializes them into the exact
|
|
7
|
+
// `f_<key>=<op>:<value>` / `search` query params the `/data/:model` endpoint
|
|
8
|
+
// already understands.
|
|
9
|
+
//
|
|
10
|
+
// DynamicTable historically inlined this; DynamicKanban now needs the SAME
|
|
11
|
+
// filtering (RFC: a board is just another view of the model), so the logic is
|
|
12
|
+
// factored here to guarantee both surfaces filter identically.
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { useApi } from './api-context'
|
|
15
|
+
import { DATE_CELL_TYPES } from './dynamic-columns'
|
|
16
|
+
import { getSearchableColumnKeys } from './column-visibility'
|
|
17
|
+
import type { ColumnFilterConfig, FilterOption } from './dynamic-columns-shim'
|
|
18
|
+
import type { TableMetadata } from './types'
|
|
19
|
+
|
|
20
|
+
export interface UseDynamicFiltersOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Static equality filters always applied (never shown as removable chips).
|
|
23
|
+
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
24
|
+
*/
|
|
25
|
+
defaultFilters?: Record<string, any>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseDynamicFiltersResult {
|
|
29
|
+
/** Per-field selected values, `filterKey → values[]` (empty = inactive). */
|
|
30
|
+
dynamicFilters: Record<string, string[]>
|
|
31
|
+
/** Free-text search term (the toolbar search box). */
|
|
32
|
+
globalFilter: string
|
|
33
|
+
setGlobalFilter: (v: string) => void
|
|
34
|
+
/** Config for each filterable field, keyed by the metadata filter/column key. */
|
|
35
|
+
columnFilterConfigs: Map<string, ColumnFilterConfig>
|
|
36
|
+
/**
|
|
37
|
+
* The serialized query params (`f_<key>`, `search`, `search_columns`) ready to
|
|
38
|
+
* spread into a `/data/:model` request. Stable object identity while inputs
|
|
39
|
+
* are unchanged so a consumer can safely depend on it in a fetch effect.
|
|
40
|
+
*/
|
|
41
|
+
filterParams: Record<string, any>
|
|
42
|
+
/** Apply a field's selection (replaces its values). */
|
|
43
|
+
handleDynamicFilterChange: (filterKey: string, values: string[]) => void
|
|
44
|
+
/** Count of fields with an active selection + the search term. */
|
|
45
|
+
activeFilterCount: number
|
|
46
|
+
/** Clear every field filter and the search term. */
|
|
47
|
+
clearAll: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads the model's filter metadata and returns the live filter state + the
|
|
52
|
+
* serialized request params. Pure of any view concern — DynamicTable adds its
|
|
53
|
+
* own sort/pagination/`columnFilters`; DynamicKanban spreads `filterParams`
|
|
54
|
+
* straight onto its single-page fetch.
|
|
55
|
+
*/
|
|
56
|
+
export function useDynamicFilters(
|
|
57
|
+
metadata: TableMetadata | null,
|
|
58
|
+
opts: UseDynamicFiltersOptions = {},
|
|
59
|
+
): UseDynamicFiltersResult {
|
|
60
|
+
const { defaultFilters } = opts
|
|
61
|
+
const api = useApi()
|
|
62
|
+
|
|
63
|
+
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
64
|
+
const [globalFilter, setGlobalFilter] = useState('')
|
|
65
|
+
const [filterOptionsMap, setFilterOptionsMap] = useState<
|
|
66
|
+
Map<string, FilterOption[]>
|
|
67
|
+
>(new Map())
|
|
68
|
+
|
|
69
|
+
// Prefetch the option lists for relation/select filters (once per model). The
|
|
70
|
+
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
71
|
+
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
72
|
+
// kanban renders card cells through ActivityValueRenderer, not the cell
|
|
73
|
+
// options map, so we skip that half).
|
|
74
|
+
const prefetchedSig = useRef<string | null>(null)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!metadata) return
|
|
77
|
+
const filterEndpoints = (metadata.filters || [])
|
|
78
|
+
.filter(
|
|
79
|
+
(f) =>
|
|
80
|
+
f.searchEndpoint &&
|
|
81
|
+
(f.type === 'select' ||
|
|
82
|
+
f.type === 'dynamic_select' ||
|
|
83
|
+
f.type === 'boolean'),
|
|
84
|
+
)
|
|
85
|
+
.map((f) => f.searchEndpoint!)
|
|
86
|
+
const columnFilterEndpoints = (metadata.columns || [])
|
|
87
|
+
.filter((c) => c.filterable && c.searchEndpoint)
|
|
88
|
+
.map((c) => c.searchEndpoint!)
|
|
89
|
+
const endpoints = Array.from(
|
|
90
|
+
new Set([...filterEndpoints, ...columnFilterEndpoints]),
|
|
91
|
+
)
|
|
92
|
+
if (endpoints.length === 0) return
|
|
93
|
+
// Dedup on the endpoint set, not model identity (TableMetadata carries no
|
|
94
|
+
// name) — the same board re-rendering shouldn't refetch its options.
|
|
95
|
+
const sig = endpoints.join('|')
|
|
96
|
+
if (prefetchedSig.current === sig) return
|
|
97
|
+
prefetchedSig.current = sig
|
|
98
|
+
|
|
99
|
+
let cancelled = false
|
|
100
|
+
Promise.all(
|
|
101
|
+
endpoints.map(async (ep) => {
|
|
102
|
+
try {
|
|
103
|
+
const res = await api.get(ep)
|
|
104
|
+
return {
|
|
105
|
+
endpoint: ep,
|
|
106
|
+
data: res.data?.success ? res.data.data : [],
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(`Failed to fetch filter options for ${ep}`, e)
|
|
110
|
+
return { endpoint: ep, data: [] as any[] }
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
).then((results) => {
|
|
114
|
+
if (cancelled) return
|
|
115
|
+
const fMap = new Map<string, FilterOption[]>()
|
|
116
|
+
for (const r of results) {
|
|
117
|
+
fMap.set(
|
|
118
|
+
r.endpoint,
|
|
119
|
+
(r.data || []).map((item: any) => ({
|
|
120
|
+
label: item.label || item.name || '',
|
|
121
|
+
value: String(item.value ?? item.id ?? ''),
|
|
122
|
+
icon: item.icon,
|
|
123
|
+
color: item.color || item.class,
|
|
124
|
+
})),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
setFilterOptionsMap(fMap)
|
|
128
|
+
})
|
|
129
|
+
return () => {
|
|
130
|
+
cancelled = true
|
|
131
|
+
}
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
}, [metadata])
|
|
134
|
+
|
|
135
|
+
const handleDynamicFilterChange = useCallback(
|
|
136
|
+
(filterKey: string, values: string[]) => {
|
|
137
|
+
setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }))
|
|
138
|
+
},
|
|
139
|
+
[],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const clearAll = useCallback(() => {
|
|
143
|
+
setDynamicFilters({})
|
|
144
|
+
setGlobalFilter('')
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
// Derive one ColumnFilterConfig per filterable field. Explicit
|
|
148
|
+
// `metadata.filters[]` wins; otherwise every `filterable` column yields a
|
|
149
|
+
// config with its UI inferred from the column shape. Copied 1:1 from
|
|
150
|
+
// DynamicTable so table and board build identical configs.
|
|
151
|
+
const columnFilterConfigs = useMemo(() => {
|
|
152
|
+
const map = new Map<string, ColumnFilterConfig>()
|
|
153
|
+
if (!metadata) return map
|
|
154
|
+
for (const f of metadata.filters ?? []) {
|
|
155
|
+
const fType = f.type as ColumnFilterConfig['filterType']
|
|
156
|
+
let options: FilterOption[] = []
|
|
157
|
+
if (f.options && f.options.length > 0) {
|
|
158
|
+
options = f.options.map((o) => ({
|
|
159
|
+
label: o.label,
|
|
160
|
+
value: String(o.value),
|
|
161
|
+
icon: o.icon,
|
|
162
|
+
color: o.color,
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
166
|
+
options = filterOptionsMap.get(f.searchEndpoint) || []
|
|
167
|
+
}
|
|
168
|
+
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
169
|
+
map.set(f.key, {
|
|
170
|
+
filterType: fType,
|
|
171
|
+
filterKey: f.column || f.key,
|
|
172
|
+
options,
|
|
173
|
+
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
174
|
+
onFilterChange: handleDynamicFilterChange,
|
|
175
|
+
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
176
|
+
searchEndpoint: f.searchEndpoint,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
for (const c of metadata.columns ?? []) {
|
|
180
|
+
if (!c.filterable || map.has(c.key)) continue
|
|
181
|
+
const hasStaticOptions = (c.options?.length ?? 0) > 0
|
|
182
|
+
const hasEndpoint = !!c.searchEndpoint
|
|
183
|
+
const isRelation = !!c.ref || c.filterType === 'dynamic_select'
|
|
184
|
+
let filterType: ColumnFilterConfig['filterType']
|
|
185
|
+
if (c.filterType) filterType = c.filterType
|
|
186
|
+
else if (isRelation && hasEndpoint) filterType = 'dynamic_select'
|
|
187
|
+
else if (hasStaticOptions || hasEndpoint) filterType = 'select'
|
|
188
|
+
else if (c.type === 'boolean') filterType = 'boolean'
|
|
189
|
+
else if (c.type === 'number') filterType = 'number_range'
|
|
190
|
+
else if ((DATE_CELL_TYPES as readonly string[]).includes(c.type))
|
|
191
|
+
filterType = 'date_range'
|
|
192
|
+
else filterType = 'text'
|
|
193
|
+
|
|
194
|
+
const options = hasStaticOptions
|
|
195
|
+
? c.options!.map((o) => ({
|
|
196
|
+
label: o.label,
|
|
197
|
+
value: String(o.value),
|
|
198
|
+
icon: o.icon,
|
|
199
|
+
color: o.color,
|
|
200
|
+
}))
|
|
201
|
+
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
|
|
202
|
+
? filterOptionsMap.get(c.searchEndpoint!) || []
|
|
203
|
+
: []
|
|
204
|
+
map.set(c.key, {
|
|
205
|
+
filterType,
|
|
206
|
+
filterKey: c.key,
|
|
207
|
+
options,
|
|
208
|
+
selectedValues: dynamicFilters[c.key] || [],
|
|
209
|
+
onFilterChange: handleDynamicFilterChange,
|
|
210
|
+
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
211
|
+
searchEndpoint: c.searchEndpoint,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return map
|
|
215
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
|
|
216
|
+
|
|
217
|
+
const searchableKeys = useMemo(
|
|
218
|
+
() => (metadata ? getSearchableColumnKeys(metadata) : null),
|
|
219
|
+
[metadata],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
223
|
+
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
224
|
+
const filterParams = useMemo(() => {
|
|
225
|
+
const params: Record<string, any> = {}
|
|
226
|
+
if (globalFilter) {
|
|
227
|
+
if (searchableKeys === null) {
|
|
228
|
+
params.search = globalFilter
|
|
229
|
+
} else if (searchableKeys.length > 0) {
|
|
230
|
+
params.search = globalFilter
|
|
231
|
+
params.search_columns = searchableKeys.join(',')
|
|
232
|
+
}
|
|
233
|
+
// searchableKeys === [] → no searchable column, skip the search param.
|
|
234
|
+
}
|
|
235
|
+
if (defaultFilters) {
|
|
236
|
+
Object.entries(defaultFilters).forEach(([key, value]) => {
|
|
237
|
+
params[`f_${key}`] = value
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
Object.entries(dynamicFilters).forEach(([key, values]) => {
|
|
241
|
+
if (values.length === 0) return
|
|
242
|
+
const gteVal = values.find((v) => v.startsWith('GTE:'))
|
|
243
|
+
const lteVal = values.find((v) => v.startsWith('LTE:'))
|
|
244
|
+
if (gteVal || lteVal) {
|
|
245
|
+
const min = gteVal ? gteVal.replace('GTE:', '') : ''
|
|
246
|
+
const max = lteVal ? lteVal.replace('LTE:', '') : ''
|
|
247
|
+
params[`f_${key}`] = `RANGE:${min},${max}`
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (values.length === 1) params[`f_${key}`] = values[0]
|
|
251
|
+
else params[`f_${key}`] = `IN:${values.join(',')}`
|
|
252
|
+
})
|
|
253
|
+
return params
|
|
254
|
+
}, [globalFilter, searchableKeys, defaultFilters, dynamicFilters])
|
|
255
|
+
|
|
256
|
+
const activeFilterCount = useMemo(() => {
|
|
257
|
+
let n = Object.values(dynamicFilters).filter((v) => v.length > 0).length
|
|
258
|
+
if (globalFilter) n += 1
|
|
259
|
+
return n
|
|
260
|
+
}, [dynamicFilters, globalFilter])
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
dynamicFilters,
|
|
264
|
+
globalFilter,
|
|
265
|
+
setGlobalFilter,
|
|
266
|
+
columnFilterConfigs,
|
|
267
|
+
filterParams,
|
|
268
|
+
handleDynamicFilterChange,
|
|
269
|
+
activeFilterCount,
|
|
270
|
+
clearAll,
|
|
271
|
+
}
|
|
272
|
+
}
|