@asteby/metacore-runtime-react 23.1.0 → 23.3.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 +56 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +146 -31
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +2 -53
- package/dist/dynamic-kanban.d.ts +19 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +114 -8
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +132 -32
- package/dist/use-infinite-scroll.d.ts +27 -0
- package/dist/use-infinite-scroll.d.ts.map +1 -0
- package/dist/use-infinite-scroll.js +60 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +217 -40
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +6 -102
- package/src/dynamic-kanban.tsx +168 -8
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +149 -10
- package/src/use-infinite-scroll.ts +94 -0
package/dist/dynamic-columns.js
CHANGED
|
@@ -19,9 +19,10 @@ import * as icons from 'lucide-react';
|
|
|
19
19
|
import { MoreHorizontal } from 'lucide-react';
|
|
20
20
|
import { Avatar, AvatarFallback, AvatarImage, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@asteby/metacore-ui';
|
|
21
21
|
import { DataTableColumnHeader, FilterableColumnHeader, } from '@asteby/metacore-ui/data-table';
|
|
22
|
-
import { generateBadgeStyles, getInitials,
|
|
22
|
+
import { generateBadgeStyles, getInitials, relationChipStyles, } from '@asteby/metacore-ui/lib';
|
|
23
23
|
import { Progress } from './dialogs/_primitives';
|
|
24
24
|
import { humanizeToken } from './dynamic-columns-helpers';
|
|
25
|
+
import { OptionBadge, RelationThumbnail, statusColorFor, useIsDarkTheme, } from './display-value';
|
|
25
26
|
import { OptionsContext } from './options-context';
|
|
26
27
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon';
|
|
27
28
|
import { CollectionCell } from './collection-cell';
|
|
@@ -88,22 +89,6 @@ export const formatAggregateTotal = (col, value, currency, locale) => {
|
|
|
88
89
|
? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }
|
|
89
90
|
: {}, locale);
|
|
90
91
|
};
|
|
91
|
-
/**
|
|
92
|
-
* Semantic status → badge color. Used by the `status` cell when no explicit
|
|
93
|
-
* `options` color is declared. Generic, value-driven mapping.
|
|
94
|
-
*/
|
|
95
|
-
const statusColorFor = (value) => {
|
|
96
|
-
const v = value.toLowerCase();
|
|
97
|
-
if (['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
|
|
98
|
-
.includes(v))
|
|
99
|
-
return '#22c55e';
|
|
100
|
-
if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
|
|
101
|
-
return '#eab308';
|
|
102
|
-
if (['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
|
|
103
|
-
.includes(v))
|
|
104
|
-
return '#ef4444';
|
|
105
|
-
return '#6b7280';
|
|
106
|
-
};
|
|
107
92
|
/** Copyable monospaced text cell (code/IDs/hashes). */
|
|
108
93
|
const CodeCell = ({ text, maxLength }) => {
|
|
109
94
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -195,23 +180,6 @@ const getValueFromPathVariants = (obj, path) => {
|
|
|
195
180
|
}
|
|
196
181
|
return undefined;
|
|
197
182
|
};
|
|
198
|
-
const useIsDarkTheme = () => {
|
|
199
|
-
const [isDark, setIsDark] = React.useState(() => typeof document !== 'undefined' &&
|
|
200
|
-
document.documentElement.classList.contains('dark'));
|
|
201
|
-
React.useEffect(() => {
|
|
202
|
-
if (typeof document === 'undefined')
|
|
203
|
-
return;
|
|
204
|
-
const sync = () => setIsDark(document.documentElement.classList.contains('dark'));
|
|
205
|
-
sync();
|
|
206
|
-
const observer = new MutationObserver(sync);
|
|
207
|
-
observer.observe(document.documentElement, {
|
|
208
|
-
attributes: true,
|
|
209
|
-
attributeFilter: ['class'],
|
|
210
|
-
});
|
|
211
|
-
return () => observer.disconnect();
|
|
212
|
-
}, []);
|
|
213
|
-
return isDark;
|
|
214
|
-
};
|
|
215
183
|
const renderRelationBadges = (items, col) => {
|
|
216
184
|
if (!Array.isArray(items) || items.length === 0) {
|
|
217
185
|
return _jsx("span", { className: "text-muted-foreground", children: "-" });
|
|
@@ -240,25 +208,6 @@ const renderRelationBadges = (items, col) => {
|
|
|
240
208
|
return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1", children: [iconValue && (_jsx(DynamicIcon, { name: iconValue, className: "h-3 w-3" })), _jsx("span", { children: label })] }, `${col.key}-${idx}`));
|
|
241
209
|
}) }));
|
|
242
210
|
};
|
|
243
|
-
/**
|
|
244
|
-
* Tiny square thumbnail for a resolved relation/option that carries an `image`
|
|
245
|
-
* (brand logo, product photo, customer/user avatar). Uses the same Avatar
|
|
246
|
-
* primitive as the `avatar`/`creator` cells so a broken/loading image
|
|
247
|
-
* gracefully falls back to the record's initials. Sized small (the box is an
|
|
248
|
-
* inline style so an addon-arbitrary Tailwind class never gets dropped by a
|
|
249
|
-
* consuming app's class scan). Rendered inline alongside a label — never alone.
|
|
250
|
-
*/
|
|
251
|
-
const RelationThumbnail = ({ src, alt, getImageUrl, size = 18 }) => (_jsxs(Avatar, { className: "shrink-0 rounded-sm ring-1 ring-border/40", style: { width: size, height: size }, children: [_jsx(AvatarImage, { src: getImageUrl ? getImageUrl(src) : src, alt: alt, className: "object-cover" }), _jsx(AvatarFallback, { className: "rounded-sm bg-primary/10 text-[8px] font-bold text-primary", children: getInitials(alt) })] }));
|
|
252
|
-
const OptionBadge = ({ option, getImageUrl }) => {
|
|
253
|
-
const isDark = useIsDarkTheme();
|
|
254
|
-
// Explicit backend color wins; otherwise derive a stable, cohesive color
|
|
255
|
-
// from the option's value (fallback label) so "dead" gray badges come
|
|
256
|
-
// alive. Inline style (hex-derived) so it works regardless of the host's
|
|
257
|
-
// tailwind safelist — addon-arbitrary classes aren't in the host scan.
|
|
258
|
-
const colorSource = option.color || optionColor(option.value || option.label);
|
|
259
|
-
const colorStyles = generateBadgeStyles(colorSource, { isDark });
|
|
260
|
-
return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.image ? (_jsx(RelationThumbnail, { src: option.image, alt: option.label, getImageUrl: getImageUrl, size: 16 })) : (option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" })), _jsx("span", { children: option.label })] }));
|
|
261
|
-
};
|
|
262
211
|
const BadgeWithEndpointOptions = ({ endpoint, value, getImageUrl }) => {
|
|
263
212
|
const { optionsMap } = React.useContext(OptionsContext);
|
|
264
213
|
const options = optionsMap.get(endpoint) || [];
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -32,6 +32,16 @@ export declare function isTransitionAllowed(transitions: StageTransition[] | und
|
|
|
32
32
|
* resolves, and so the previous grouping can be restored on failure.
|
|
33
33
|
*/
|
|
34
34
|
export declare function applyOptimisticMove(grouped: Map<string, any[]>, cardId: string | number, fromStage: string, toStage: string, groupByKey: string): Map<string, any[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
37
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
38
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
39
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
40
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyLaneTotalsOnMove<T extends {
|
|
43
|
+
total: number | null;
|
|
44
|
+
}>(pagination: Record<string, T>, fromStage: string, toStage: string): Record<string, T>;
|
|
35
45
|
/**
|
|
36
46
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
37
47
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -88,10 +98,16 @@ export interface DynamicKanbanProps {
|
|
|
88
98
|
*/
|
|
89
99
|
onAction?: (action: string, row: any) => void;
|
|
90
100
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
101
|
+
* Size of the INITIAL board page (one request, grouped into lanes). Each
|
|
102
|
+
* lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
|
|
103
|
+
* to 50 — enough to fill the visible lanes without loading the whole board.
|
|
93
104
|
*/
|
|
94
105
|
pageSize?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Page size for a lane's incremental top-up fetch (scoped by
|
|
108
|
+
* `f_<group_by>=<stage>`). Defaults to 25.
|
|
109
|
+
*/
|
|
110
|
+
lanePageSize?: number;
|
|
95
111
|
/** IANA timezone for datetime card fields (org config). */
|
|
96
112
|
timeZone?: string;
|
|
97
113
|
/** ISO 4217 currency for money card fields (org config). */
|
|
@@ -102,5 +118,5 @@ export interface DynamicKanbanProps {
|
|
|
102
118
|
*/
|
|
103
119
|
defaultFilters?: Record<string, any>;
|
|
104
120
|
}
|
|
105
|
-
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
|
|
121
|
+
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, lanePageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
|
|
106
122
|
//# 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;AAwD9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAwD9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;AASvB,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAIhB,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,CAAA;AAMvD;;;;;;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;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EACpE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,EAC7B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAWnB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,IAAI,EAAE,GAAG,EACT,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACzE,OAAO,CAUT;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACxD,MAAM,CAIR;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;AAwDD,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;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,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,QAAa,EACb,YAAiB,EACjB,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBAknBpB"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -10,6 +10,7 @@ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
|
10
10
|
import { useApi } from './api-context';
|
|
11
11
|
import { useDynamicFilters } from './use-dynamic-filters';
|
|
12
12
|
import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
|
|
13
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
|
|
13
14
|
import { useMetadataCache } from './metadata-cache';
|
|
14
15
|
import { ActivityValueRenderer } from './activity-value-renderer';
|
|
15
16
|
import { DynamicIcon } from './dynamic-icon';
|
|
@@ -112,6 +113,25 @@ export function applyOptimisticMove(grouped, cardId, fromStage, toStage, groupBy
|
|
|
112
113
|
next.set(toStage, toRows);
|
|
113
114
|
return next;
|
|
114
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
118
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
119
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
120
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
121
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
122
|
+
*/
|
|
123
|
+
export function applyLaneTotalsOnMove(pagination, fromStage, toStage) {
|
|
124
|
+
const next = { ...pagination };
|
|
125
|
+
const bump = (key, delta) => {
|
|
126
|
+
const st = next[key];
|
|
127
|
+
if (st && st.total != null) {
|
|
128
|
+
next[key] = { ...st, total: Math.max(0, st.total + delta) };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
bump(fromStage, -1);
|
|
132
|
+
bump(toStage, +1);
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
115
135
|
/**
|
|
116
136
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
117
137
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -195,7 +215,7 @@ function useIsDarkTheme() {
|
|
|
195
215
|
}, []);
|
|
196
216
|
return isDark;
|
|
197
217
|
}
|
|
198
|
-
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize =
|
|
218
|
+
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone, currency, defaultFilters, }) {
|
|
199
219
|
const { t, i18n } = useTranslation();
|
|
200
220
|
const api = useApi();
|
|
201
221
|
const isDark = useIsDarkTheme();
|
|
@@ -205,6 +225,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
205
225
|
const [records, setRecords] = useState([]);
|
|
206
226
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
207
227
|
const [loadingData, setLoadingData] = useState(true);
|
|
228
|
+
// Per-stage incremental pagination for infinite scroll. The initial board
|
|
229
|
+
// page (grouped into lanes) is fetched once; each lane then tops up its OWN
|
|
230
|
+
// stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
|
|
231
|
+
// shared `records`. `total` is the stage's server count when the response
|
|
232
|
+
// meta carries it. Reset whenever the filters/search change.
|
|
233
|
+
const [lanePagination, setLanePagination] = useState({});
|
|
208
234
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
209
235
|
const [activeId, setActiveId] = useState(null);
|
|
210
236
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
@@ -247,7 +273,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
247
273
|
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
248
274
|
// identically to its table sibling.
|
|
249
275
|
const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
|
|
250
|
-
// ----
|
|
276
|
+
// ---- initial board page (one request, grouped into lanes) ----
|
|
277
|
+
// Resets the per-lane pagination so every lane restarts its incremental
|
|
278
|
+
// top-up from scratch — called on mount, refresh, and any filter/search
|
|
279
|
+
// change (fetchData's identity changes with filterParams).
|
|
251
280
|
const fetchData = useCallback(async () => {
|
|
252
281
|
if (!metadata)
|
|
253
282
|
return;
|
|
@@ -265,7 +294,64 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
265
294
|
finally {
|
|
266
295
|
setLoadingData(false);
|
|
267
296
|
}
|
|
297
|
+
setLanePagination({});
|
|
268
298
|
}, [api, endpoint, model, metadata, pageSize, filterParams]);
|
|
299
|
+
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
300
|
+
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
301
|
+
// filterParams (the stage scope wins over any global group_by filter).
|
|
302
|
+
const groupByKey = metadata?.group_by || '';
|
|
303
|
+
const loadMoreLane = useCallback(async (stageKey) => {
|
|
304
|
+
if (!metadata || !groupByKey)
|
|
305
|
+
return;
|
|
306
|
+
const current = lanePagination[stageKey];
|
|
307
|
+
if (current?.loading || current?.done)
|
|
308
|
+
return;
|
|
309
|
+
const nextPage = current?.nextPage ?? 1;
|
|
310
|
+
setLanePagination((p) => ({
|
|
311
|
+
...p,
|
|
312
|
+
[stageKey]: {
|
|
313
|
+
nextPage,
|
|
314
|
+
total: current?.total ?? null,
|
|
315
|
+
loading: true,
|
|
316
|
+
done: false,
|
|
317
|
+
},
|
|
318
|
+
}));
|
|
319
|
+
try {
|
|
320
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
321
|
+
params: {
|
|
322
|
+
...filterParams,
|
|
323
|
+
page: nextPage,
|
|
324
|
+
per_page: lanePageSize,
|
|
325
|
+
[`f_${groupByKey}`]: stageKey,
|
|
326
|
+
},
|
|
327
|
+
}));
|
|
328
|
+
const rows = res.data.success ? res.data.data || [] : [];
|
|
329
|
+
const total = res.data.meta?.total ?? res.data.meta?.count ?? null;
|
|
330
|
+
setRecords((prev) => dedupeById(prev, rows));
|
|
331
|
+
setLanePagination((p) => ({
|
|
332
|
+
...p,
|
|
333
|
+
[stageKey]: {
|
|
334
|
+
nextPage: nextPage + 1,
|
|
335
|
+
total,
|
|
336
|
+
loading: false,
|
|
337
|
+
// Exhausted when the server returned a short page.
|
|
338
|
+
done: rows.length < lanePageSize,
|
|
339
|
+
},
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
console.error(`Error al cargar más tarjetas de ${stageKey}`, err);
|
|
344
|
+
setLanePagination((p) => ({
|
|
345
|
+
...p,
|
|
346
|
+
[stageKey]: {
|
|
347
|
+
nextPage,
|
|
348
|
+
total: current?.total ?? null,
|
|
349
|
+
loading: false,
|
|
350
|
+
done: false,
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
}, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination]);
|
|
269
355
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
270
356
|
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
271
357
|
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
@@ -348,7 +434,6 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
348
434
|
});
|
|
349
435
|
}, []);
|
|
350
436
|
const stages = useMemo(() => (metadata ? deriveStages(metadata) : []), [metadata]);
|
|
351
|
-
const groupByKey = metadata?.group_by || '';
|
|
352
437
|
const transitions = metadata?.transitions;
|
|
353
438
|
const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
|
|
354
439
|
const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
|
|
@@ -405,7 +490,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
405
490
|
}
|
|
406
491
|
// OPTIMISTIC: move the card in local state immediately.
|
|
407
492
|
const prevRecords = records;
|
|
493
|
+
const prevPagination = lanePagination;
|
|
408
494
|
setRecords((rs) => rs.map((r) => String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r));
|
|
495
|
+
// Keep the server totals consistent with the moved card so a lane's
|
|
496
|
+
// `count/total` header stays truthful with partial lanes: one leaves
|
|
497
|
+
// the source stage, one joins the destination.
|
|
498
|
+
setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage));
|
|
409
499
|
try {
|
|
410
500
|
const base = endpoint || `/data/${model}`;
|
|
411
501
|
// `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
|
|
@@ -422,6 +512,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
422
512
|
catch (err) {
|
|
423
513
|
// REVERT + toast on failure.
|
|
424
514
|
setRecords(prevRecords);
|
|
515
|
+
setLanePagination(prevPagination);
|
|
425
516
|
toast.error(t('kanban.moveFailed', {
|
|
426
517
|
defaultValue: 'No se pudo mover la tarjeta',
|
|
427
518
|
}) +
|
|
@@ -429,7 +520,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
429
520
|
? `: ${err.response.data.message}`
|
|
430
521
|
: ''));
|
|
431
522
|
}
|
|
432
|
-
}, [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions]);
|
|
523
|
+
}, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
|
|
433
524
|
if (loading) {
|
|
434
525
|
return (_jsx("div", { className: "flex gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "w-[300px] shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
|
|
435
526
|
}
|
|
@@ -474,7 +565,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
474
565
|
const droppableAllowed = !activeId ||
|
|
475
566
|
stage.key === activeStage ||
|
|
476
567
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
477
|
-
|
|
568
|
+
// Infinite scroll is per declared stage; the synthetic
|
|
569
|
+
// "unassigned" lane can't be stage-scoped, so it never tops up.
|
|
570
|
+
const laneState = lanePagination[stage.key];
|
|
571
|
+
const isUnassigned = stage.key === UNASSIGNED_LANE;
|
|
572
|
+
const laneHasMore = !isUnassigned && !laneState?.done;
|
|
573
|
+
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
478
574
|
field: f?.field,
|
|
479
575
|
values: f?.values,
|
|
480
576
|
text: f?.text,
|
|
@@ -508,9 +604,15 @@ function SheetFilterRow({ field, isStage, }) {
|
|
|
508
604
|
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
509
605
|
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 }));
|
|
510
606
|
}
|
|
511
|
-
function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
607
|
+
function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
512
608
|
const { t } = useTranslation();
|
|
513
609
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
|
|
610
|
+
// Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
|
|
611
|
+
// container; a load in flight or an exhausted stage disables it.
|
|
612
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
613
|
+
onLoadMore,
|
|
614
|
+
disabled: !hasMore || loadingMore,
|
|
615
|
+
});
|
|
514
616
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
515
617
|
isDark,
|
|
516
618
|
});
|
|
@@ -546,7 +648,11 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
546
648
|
opacity: dimmed ? 0.45 : 1,
|
|
547
649
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
548
650
|
outlineOffset: 2,
|
|
549
|
-
}, "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
|
|
651
|
+
}, "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
|
|
652
|
+
? `${count}/${totalCount}`
|
|
653
|
+
: serverTotal != null
|
|
654
|
+
? `${count}/${serverTotal}`
|
|
655
|
+
: count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative 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', {
|
|
550
656
|
defaultValue: 'Buscar en la columna',
|
|
551
657
|
}), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _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) => {
|
|
552
658
|
if (e.key === 'Escape') {
|
|
@@ -560,7 +666,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
560
666
|
defaultValue: 'Buscar tarjetas...',
|
|
561
667
|
}), 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', {
|
|
562
668
|
defaultValue: 'Limpiar',
|
|
563
|
-
}), children: _jsx(X, { className: "h-3 w-3" }) })] })),
|
|
669
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsxs("div", { ref: rootRef, className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: [children, loadingMore && (_jsx(Skeleton, { className: "h-16 w-full shrink-0", "data-testid": "lane-loading-more" })), hasMore && (_jsx("div", { ref: sentinelRef, className: "h-1 w-full shrink-0", "aria-hidden": true }))] })] }));
|
|
564
670
|
}
|
|
565
671
|
// LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
|
|
566
672
|
// ONLY this lane's cards (client-side, in the parent). Draft state lives here so
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,aAAa,EAAkB,MAAM,SAAS,CAAA;AAE5D,MAAM,WAAW,0BAA0B;IACvC,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,4EAA4E;IAC5E,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAC9B,+CAA+C;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE;;;OAGG;IACH,OAAO,EAAE,KAAK,CAAC,YAAY,CAAA;CAC9B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,GACZ,EAAE,0BAA0B,GAAG,iBAAiB,
|
|
1
|
+
{"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,aAAa,EAAkB,MAAM,SAAS,CAAA;AAE5D,MAAM,WAAW,0BAA0B;IACvC,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,4EAA4E;IAC5E,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAC9B,+CAA+C;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE;;;OAGG;IACH,OAAO,EAAE,KAAK,CAAC,YAAY,CAAA;CAC9B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,GACZ,EAAE,0BAA0B,GAAG,iBAAiB,CAqHhD"}
|
|
@@ -89,16 +89,18 @@ export function useDynamicRowActions({ model, endpoint, metadata, onAction, onRe
|
|
|
89
89
|
try {
|
|
90
90
|
const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`;
|
|
91
91
|
const res = await api.delete(deleteEndpoint);
|
|
92
|
+
// CRUD estándar: no usar res.data.message (el endpoint dinámico
|
|
93
|
+
// devuelve texto en inglés que se filtraría al toast). String localizado.
|
|
92
94
|
if (res.data.success) {
|
|
93
|
-
toast.success(
|
|
95
|
+
toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' }));
|
|
94
96
|
onRefresh();
|
|
95
97
|
}
|
|
96
98
|
else
|
|
97
|
-
toast.error(
|
|
99
|
+
toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }));
|
|
98
100
|
}
|
|
99
101
|
catch (error) {
|
|
100
102
|
console.error('Error al eliminar', error);
|
|
101
|
-
toast.error('
|
|
103
|
+
toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }));
|
|
102
104
|
}
|
|
103
105
|
finally {
|
|
104
106
|
setIsDeleting(false);
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface DynamicTableProps {
|
|
|
36
36
|
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
37
37
|
*/
|
|
38
38
|
currency?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Opt into infinite scroll: instead of the classic pager, rows accumulate as
|
|
41
|
+
* the user scrolls (a sentinel at the bottom fetches + appends the next
|
|
42
|
+
* page, deduped by id, respecting the active filters/search). Changing any
|
|
43
|
+
* filter/sort/search resets to page 1. Default false — existing hosts keep
|
|
44
|
+
* the classic pagination untouched.
|
|
45
|
+
*/
|
|
46
|
+
infiniteScroll?: boolean;
|
|
39
47
|
}
|
|
40
|
-
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, }: DynamicTableProps): import("react").JSX.Element;
|
|
48
|
+
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, infiniteScroll, }: DynamicTableProps): import("react").JSX.Element;
|
|
41
49
|
//# sourceMappingURL=dynamic-table.d.ts.map
|
|
@@ -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;AAanF,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;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;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,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAyiCnB"}
|