qantler-flaz-dataview 1.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/package.json +11 -0
- package/src/FlazDataView.css +4423 -0
- package/src/FlazDataView.jsx +2943 -0
- package/src/FlazDataViewList.jsx +787 -0
- package/src/MdtActiveFilterBar.jsx +263 -0
- package/src/MdtBulkSelectionBar.jsx +91 -0
- package/src/MdtColumnPickerPopup.jsx +59 -0
- package/src/MdtColumnVisibilityList.jsx +38 -0
- package/src/MdtCreateViewModal.jsx +280 -0
- package/src/MdtDatetimeValueEditor.jsx +259 -0
- package/src/MdtDeleteViewConfirmModal.jsx +83 -0
- package/src/MdtDisplayPopup.jsx +170 -0
- package/src/MdtEmptyState.jsx +20 -0
- package/src/MdtFilterColumnPicker.jsx +66 -0
- package/src/MdtFilterOperatorDropdown.jsx +58 -0
- package/src/MdtFilterSection.jsx +73 -0
- package/src/MdtFilterToolbar.jsx +81 -0
- package/src/MdtInlineField.css +221 -0
- package/src/MdtInlineField.jsx +187 -0
- package/src/MdtInlineFieldRow.jsx +71 -0
- package/src/MdtNoDataIllustration.jsx +64 -0
- package/src/MdtPopupChecklist.jsx +70 -0
- package/src/MdtRowActionsMenu.jsx +281 -0
- package/src/MdtSidebarNav.jsx +26 -0
- package/src/MdtTabsRow.jsx +354 -0
- package/src/MdtTabsSection.jsx +35 -0
- package/src/MdtTruncateWithTooltip.jsx +82 -0
- package/src/MdtValueEditorPopup.jsx +950 -0
- package/src/filterDisplayUtils.jsx +96 -0
- package/src/mdtClientSideFilter.js +68 -0
- package/src/mdtColumnEditUtils.js +403 -0
- package/src/mdtDateFilterUtils.js +135 -0
- package/src/mdtExportUtils.js +234 -0
- package/src/mdtFilterUtils.js +56 -0
- package/src/mdtInlineFieldDisplay.js +66 -0
- package/src/mdtMobilePopupPosition.js +107 -0
- package/src/mdtMultiSelectDisplay.jsx +279 -0
- package/src/mdtSavedViewFilterMatch.js +154 -0
- package/src/mdtTabChangeUtils.js +12 -0
- package/src/mdtTabStatusRules.js +111 -0
- package/src/mdtTableConstants.js +2 -0
- package/src/mdtTooltipFormat.js +44 -0
- package/src/renderInlineFieldValue.jsx +183 -0
|
@@ -0,0 +1,2943 @@
|
|
|
1
|
+
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import {
|
|
4
|
+
ArrowDownWideNarrow,
|
|
5
|
+
ArrowUpNarrowWide,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
Eraser,
|
|
8
|
+
ListFilter,
|
|
9
|
+
Menu,
|
|
10
|
+
ChevronsLeft,
|
|
11
|
+
Layout,
|
|
12
|
+
Check,
|
|
13
|
+
X,
|
|
14
|
+
MoreHorizontal,
|
|
15
|
+
SlidersHorizontal,
|
|
16
|
+
Download,
|
|
17
|
+
Search,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import MdtRowActionsMenu from "./MdtRowActionsMenu";
|
|
20
|
+
import DeleteConfirmModal from "../../custom/DeleteConfirmModal";
|
|
21
|
+
import MdtCreateViewModal from "./MdtCreateViewModal";
|
|
22
|
+
import MdtActiveFilterBar from "./MdtActiveFilterBar";
|
|
23
|
+
import MdtDisplayPopup from "./MdtDisplayPopup";
|
|
24
|
+
import MdtBulkSelectionBar from "./MdtBulkSelectionBar";
|
|
25
|
+
import MdtTabsSection from "./MdtTabsSection";
|
|
26
|
+
import MdtSidebarNav from "./MdtSidebarNav";
|
|
27
|
+
import { resolveFilterConfig, getFilterableColumns } from "./mdtFilterUtils";
|
|
28
|
+
import { getBulkFieldsFromColumns } from "./bulkBarUtils";
|
|
29
|
+
import "./FlazDataView.css";
|
|
30
|
+
import { renderColumnIcon, renderColumnDefIcon, resolveFilterColumnIcon } from "../../ui/tableColumnIcons";
|
|
31
|
+
import FlazDataViewList from "./FlazDataViewList";
|
|
32
|
+
import MdtEmptyState from "./MdtEmptyState";
|
|
33
|
+
import MdtValueEditorPopup from "./MdtValueEditorPopup";
|
|
34
|
+
import {
|
|
35
|
+
EDIT_MODE,
|
|
36
|
+
getRowFieldEditValue,
|
|
37
|
+
isColumnCellEditable,
|
|
38
|
+
normalizeEditApplyValue,
|
|
39
|
+
computeCellEditPopupPosition,
|
|
40
|
+
refineCellEditPopupPosition,
|
|
41
|
+
buildBulkEditPayload,
|
|
42
|
+
getColumnFieldVariant,
|
|
43
|
+
getColumnSelectOptions,
|
|
44
|
+
isColumnSelectMulti,
|
|
45
|
+
resolveEditColumnConfig,
|
|
46
|
+
isColumnDisplayHideable,
|
|
47
|
+
} from "./mdtColumnEditUtils";
|
|
48
|
+
import {
|
|
49
|
+
MdtMultiSelectCellDisplay,
|
|
50
|
+
getColumnRowIconName,
|
|
51
|
+
hasCustomColumnCell,
|
|
52
|
+
hasStatusValueRenderer,
|
|
53
|
+
renderStatusValueDisplay,
|
|
54
|
+
shouldUseMultiSelectSummary,
|
|
55
|
+
} from "./mdtMultiSelectDisplay";
|
|
56
|
+
import MdtTruncateWithTooltip from "./MdtTruncateWithTooltip";
|
|
57
|
+
import {
|
|
58
|
+
syncAnchoredPopupTop,
|
|
59
|
+
clearMobilePopupTop,
|
|
60
|
+
isMobilePopupLayout,
|
|
61
|
+
} from "./mdtMobilePopupPosition";
|
|
62
|
+
import { isSameMdtTab } from "./mdtTabChangeUtils";
|
|
63
|
+
import {
|
|
64
|
+
DEFAULT_DATE_FILTER_OPERATOR,
|
|
65
|
+
isDateLikeFilterColumn,
|
|
66
|
+
isFilterValueSet,
|
|
67
|
+
} from "./mdtDateFilterUtils";
|
|
68
|
+
import { applyClientSideFilters } from "./mdtClientSideFilter";
|
|
69
|
+
import { getColumnTooltipText } from "./mdtTooltipFormat";
|
|
70
|
+
import apiService from "../../../actions/api";
|
|
71
|
+
import API from "../../../helpers/endpoint";
|
|
72
|
+
|
|
73
|
+
const DEFAULT_VIEW_MODE = "table";
|
|
74
|
+
|
|
75
|
+
const FlazDataView = ({
|
|
76
|
+
title = "",
|
|
77
|
+
titleIcon = null,
|
|
78
|
+
countText = "",
|
|
79
|
+
/** Total count badge shown beside the module title */
|
|
80
|
+
totalCount = null,
|
|
81
|
+
rows = [],
|
|
82
|
+
columns = [],
|
|
83
|
+
onExport,
|
|
84
|
+
onPrimaryAction,
|
|
85
|
+
primaryActionLabel = "New",
|
|
86
|
+
/**
|
|
87
|
+
* Header toolbar action visibility. All default to true.
|
|
88
|
+
* showNew — primary action (e.g. "New"); still hidden when hideHeader is true.
|
|
89
|
+
* showExport — export icon button; renders only when onExport is also provided.
|
|
90
|
+
*/
|
|
91
|
+
headerActions = {},
|
|
92
|
+
onRowClick,
|
|
93
|
+
isLoading = false,
|
|
94
|
+
emptyStateTitle = "No data found",
|
|
95
|
+
skeletonRows = 12,
|
|
96
|
+
onTableScroll,
|
|
97
|
+
onSort,
|
|
98
|
+
sortConfig = null,
|
|
99
|
+
/**
|
|
100
|
+
* Default sort when sortConfig is not provided (uncontrolled).
|
|
101
|
+
* e.g. { column: "applicationId", direction: "desc" }
|
|
102
|
+
*/
|
|
103
|
+
defaultSort = null,
|
|
104
|
+
tabs = [],
|
|
105
|
+
activeTab = null,
|
|
106
|
+
onTabChange,
|
|
107
|
+
hideHeader = false, // hides the title/icon row only — filter+layout always shown
|
|
108
|
+
/** Optional non-clickable breadcrumb labels for header (e.g. Admin > Master Data). */
|
|
109
|
+
headerBreadcrumbs = null,
|
|
110
|
+
/** Optional trailing breadcrumb dropdown (e.g. Languages + count). */
|
|
111
|
+
headerBreadcrumbMenu = null,
|
|
112
|
+
/** Mobile breadcrumb: show compact "..." (non-clickable) + current page only. */
|
|
113
|
+
enableCompactBreadcrumbEllipsis = false,
|
|
114
|
+
/** Renders layout/filter/display/new toolbar into #mdt-breadcrumb-toolbar-host (global breadcrumb). */
|
|
115
|
+
toolbarInBreadcrumb = false,
|
|
116
|
+
/** Breadcrumb toolbar host element id (default: mdt-breadcrumb-toolbar-host). */
|
|
117
|
+
breadcrumbToolbarHostId = "mdt-breadcrumb-toolbar-host",
|
|
118
|
+
/** Bump when the breadcrumb toolbar host mounts (e.g. tab switch) to re-bind the portal. */
|
|
119
|
+
toolbarHostSeed = 0,
|
|
120
|
+
onFilterChange,
|
|
121
|
+
/** Filter UI labels/visibility — see mdtFilterUtils DEFAULT_FILTER_CONFIG */
|
|
122
|
+
filterConfig = {},
|
|
123
|
+
/** Tabs row — counts, saved-view edit, more button label, className */
|
|
124
|
+
tabsConfig = {},
|
|
125
|
+
/** Called when user creates a saved view from the Create View modal. */
|
|
126
|
+
onCreateView,
|
|
127
|
+
/** @deprecated Use onCreateView instead */
|
|
128
|
+
onSaveFiltersAs,
|
|
129
|
+
/** Bump to refresh live saved-view tab counts from API (e.g. after list mutations). */
|
|
130
|
+
savedViewsRefreshKey = 0,
|
|
131
|
+
/** When true, the first saved view is NOT auto-selected on initial load. Use for admin/config modules. */
|
|
132
|
+
disableAutoSelectFirstView = false,
|
|
133
|
+
/** Extra query params to append to saved view counts API call (e.g. "clientId=129"). */
|
|
134
|
+
savedViewCountsExtraParams = "",
|
|
135
|
+
/** Parent receives `() => refresh saved-view counts` (e.g. after inline edit). */
|
|
136
|
+
onBindSavedViewCountsRefresh,
|
|
137
|
+
rowKey = "applicationId",
|
|
138
|
+
/** Table view: column pinned to the left when scrolling horizontally (defaults to rowKey). */
|
|
139
|
+
tableStickyColumnKey = null,
|
|
140
|
+
/**
|
|
141
|
+
* List view: columns always shown on the left (main ID + secondary line).
|
|
142
|
+
* Remaining visible columns render as chips on the right.
|
|
143
|
+
*/
|
|
144
|
+
listLeftColumnKeys = null,
|
|
145
|
+
/** List view primary column (defaults to rowKey). */
|
|
146
|
+
listMainColumnKey = null,
|
|
147
|
+
/**
|
|
148
|
+
* List secondary line keys after main ID. null = all listLeftColumnKeys except main.
|
|
149
|
+
* [] = no secondary line. e.g. ["jobOpeningName", "candidateName"].
|
|
150
|
+
*/
|
|
151
|
+
listLeftSecondaryKeys = null,
|
|
152
|
+
/** Joins listLeftSecondaryKeys, e.g. " - " for "Opening - Candidate". */
|
|
153
|
+
listLeftSecondarySeparator = " - ",
|
|
154
|
+
/**
|
|
155
|
+
* Columns always visible — cannot be hidden (defaults to rowKey).
|
|
156
|
+
* Per-column: displayHideable: false (or hideable: false) also prevents hiding.
|
|
157
|
+
*/
|
|
158
|
+
displayLockedColumnKeys = null,
|
|
159
|
+
onRowEdit,
|
|
160
|
+
onRowDownload,
|
|
161
|
+
onRowDelete,
|
|
162
|
+
/** Extra ⋯ menu items: [{ id?, label, icon?, onClick(rowId, row), disabled?, hidden? }] */
|
|
163
|
+
rowMenuActions = [],
|
|
164
|
+
deleteConfirmConfig = {},
|
|
165
|
+
/** Optional per-row delete guard, e.g. (row) => row.isDeletable !== false */
|
|
166
|
+
isRowDeletable = null,
|
|
167
|
+
/** When true, shows row checkboxes and the bottom bulk selection bar. */
|
|
168
|
+
enableBulkSelection = false,
|
|
169
|
+
/**
|
|
170
|
+
* Bulk bar pills — full override. If omitted, built from columns with bulkEditable: true.
|
|
171
|
+
*/
|
|
172
|
+
bulkBarFields,
|
|
173
|
+
/** Allow-list of accessorKeys for bulk pills (used when bulkBarFields is omitted). */
|
|
174
|
+
bulkEditableKeys = null,
|
|
175
|
+
onBulkBarFieldClick,
|
|
176
|
+
onBulkDelete,
|
|
177
|
+
bulkBarRightAction = null,
|
|
178
|
+
/** When set, list view starts grouped by this column; `null` after choosing "None" shows a flat list. */
|
|
179
|
+
defaultListGroupByKey = null,
|
|
180
|
+
/**
|
|
181
|
+
* Table cell inline edit. Fires when user applies a value from the cell editor popup.
|
|
182
|
+
* Only columns with `editable: true`.
|
|
183
|
+
* Column config: editVariant ("text"|"select"|"date"|"number"|"boolean"), editMulti, …
|
|
184
|
+
* editValueKey, editPlaceholder, editRenderIcon (falls back to filter* props).
|
|
185
|
+
*/
|
|
186
|
+
onCellEdit,
|
|
187
|
+
onCellEditStart,
|
|
188
|
+
/**
|
|
189
|
+
* Bulk bar apply — same editor UI; uses bulk* column config when set.
|
|
190
|
+
* bulkVariant, bulkMulti, bulkSelectOptions, bulkPlaceholder, bulkRenderIcon
|
|
191
|
+
* (falls back to edit* then filter*). Returns JSON via buildBulkEditPayload.
|
|
192
|
+
*/
|
|
193
|
+
onBulkEdit,
|
|
194
|
+
/** When false, shows sidebar expand control (pass from page Redux/connect). */
|
|
195
|
+
menuToggle,
|
|
196
|
+
/** Called when user clicks sidebar expand (e.g. props.toggleMenu from Job Applications). */
|
|
197
|
+
onSidebarToggle,
|
|
198
|
+
/** When true, keeps sidebar toggle visible even when sidebar is already expanded. */
|
|
199
|
+
showSidebarToggleAlways = false,
|
|
200
|
+
/** Optional master-data style left sidebar config: { items, activeKey, onItemClick, width }. */
|
|
201
|
+
sidebarNavConfig = null,
|
|
202
|
+
/** Optional per-page width lock for table columns (prevents width jump after data load). */
|
|
203
|
+
tableLayoutFixed = true,
|
|
204
|
+
/**
|
|
205
|
+
* When true, filter value/operator popups use position:fixed + viewport anchoring
|
|
206
|
+
* (for nested layouts where overflow:hidden would clip absolute popups).
|
|
207
|
+
*/
|
|
208
|
+
anchoredFilterPopups = false,
|
|
209
|
+
/** When set, locks table/list mode and hides the layout switch (e.g. embedded candidate tabs). */
|
|
210
|
+
lockedViewMode = null,
|
|
211
|
+
}) => {
|
|
212
|
+
const resolvedHeaderActions = {
|
|
213
|
+
showNew: true,
|
|
214
|
+
showExport: true,
|
|
215
|
+
showDisplay: true,
|
|
216
|
+
showFilter: true,
|
|
217
|
+
showLayoutSwitch: true,
|
|
218
|
+
...headerActions,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const resolvedFilterConfig = React.useMemo(
|
|
222
|
+
() => resolveFilterConfig(filterConfig),
|
|
223
|
+
[filterConfig],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const storageScopeKey = React.useMemo(() => {
|
|
227
|
+
const titlePart = String(title || rowKey || "flaz-data-view")
|
|
228
|
+
.trim()
|
|
229
|
+
.toLowerCase()
|
|
230
|
+
.replace(/\s+/g, "-");
|
|
231
|
+
return `mdt:view-mode:${titlePart}`;
|
|
232
|
+
}, [rowKey, title]);
|
|
233
|
+
|
|
234
|
+
const savedViewPageKey = React.useMemo(
|
|
235
|
+
() => String(title || rowKey || "flaz-data-view").trim().toLowerCase(),
|
|
236
|
+
[rowKey, title],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const resolveViewMode = React.useCallback(
|
|
240
|
+
(mode) => (mode === "list" ? "list" : DEFAULT_VIEW_MODE),
|
|
241
|
+
[],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const getStoredViewMode = React.useCallback(() => {
|
|
245
|
+
if (lockedViewMode) return resolveViewMode(lockedViewMode);
|
|
246
|
+
if (typeof window === "undefined") return DEFAULT_VIEW_MODE;
|
|
247
|
+
const stored = window.localStorage.getItem(storageScopeKey);
|
|
248
|
+
return stored === "list" ? "list" : DEFAULT_VIEW_MODE;
|
|
249
|
+
}, [lockedViewMode, resolveViewMode, storageScopeKey]);
|
|
250
|
+
|
|
251
|
+
const [viewMode, setViewModeState] = useState(() => getStoredViewMode());
|
|
252
|
+
|
|
253
|
+
const setViewMode = React.useCallback(
|
|
254
|
+
(nextMode) => {
|
|
255
|
+
if (lockedViewMode) return;
|
|
256
|
+
const resolvedMode = resolveViewMode(nextMode);
|
|
257
|
+
setViewModeState(resolvedMode);
|
|
258
|
+
if (typeof window !== "undefined") {
|
|
259
|
+
window.localStorage.setItem(storageScopeKey, resolvedMode);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
[lockedViewMode, resolveViewMode, storageScopeKey],
|
|
263
|
+
);
|
|
264
|
+
const [isFilterBarOpen, setIsFilterBarOpen] = useState(false);
|
|
265
|
+
const [activeFilters, setActiveFiltersState] = useState([]);
|
|
266
|
+
|
|
267
|
+
const resolveInitialSort = () => {
|
|
268
|
+
if (sortConfig?.column) {
|
|
269
|
+
return {
|
|
270
|
+
column: sortConfig.column,
|
|
271
|
+
direction: sortConfig.direction === "desc" ? "desc" : "asc",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (defaultSort?.column) {
|
|
275
|
+
return {
|
|
276
|
+
column: defaultSort.column,
|
|
277
|
+
direction: defaultSort.direction === "desc" ? "desc" : "asc",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return { column: null, direction: null };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const [openPopup, setOpenPopup] = useState(null);
|
|
284
|
+
const [internalSortState, setInternalSortState] = useState(resolveInitialSort);
|
|
285
|
+
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
|
286
|
+
const [showHeaderBreadcrumbMenu, setShowHeaderBreadcrumbMenu] = useState(false);
|
|
287
|
+
const [filterSearch, setFilterSearch] = useState("");
|
|
288
|
+
const [showInnerFilterDropdown, setShowInnerFilterDropdown] = useState(false);
|
|
289
|
+
const [innerFilterSearch, setInnerFilterSearch] = useState("");
|
|
290
|
+
const [replacingFilterId, setReplacingFilterId] = useState(null);
|
|
291
|
+
const [replaceSearch, setReplaceSearch] = useState("");
|
|
292
|
+
const [valueEditId, setValueEditId] = useState(null);
|
|
293
|
+
const [valueInputText, setValueInputText] = useState("");
|
|
294
|
+
const [valueSelectSearch, setValueSelectSearch] = useState("");
|
|
295
|
+
// calendar picker state
|
|
296
|
+
const today = new Date();
|
|
297
|
+
const [calYear, setCalYear] = useState(today.getFullYear());
|
|
298
|
+
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
|
299
|
+
|
|
300
|
+
// Display popup state
|
|
301
|
+
const [showDisplayPopup, setShowDisplayPopup] = useState(false);
|
|
302
|
+
const [displayPropsOpen, setDisplayPropsOpen] = useState(true);
|
|
303
|
+
const [groupByOpen, setGroupByOpen] = useState(true);
|
|
304
|
+
const [orderByOpen, setOrderByOpen] = useState(true);
|
|
305
|
+
// Track which columns are visible (default: all visible)
|
|
306
|
+
const [hiddenColumns, setHiddenColumns] = useState([]);
|
|
307
|
+
const [groupByKey, setGroupByKey] = useState(
|
|
308
|
+
() => (defaultListGroupByKey != null ? defaultListGroupByKey : null),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const popupRef = useRef(null);
|
|
312
|
+
const filterDropdownRef = useRef(null);
|
|
313
|
+
const innerFilterRef = useRef(null);
|
|
314
|
+
const filterBarRef = useRef(null);
|
|
315
|
+
const chipReplaceRef = useRef(null);
|
|
316
|
+
const chipValueRef = useRef(null);
|
|
317
|
+
const displayPopupRef = useRef(null);
|
|
318
|
+
const headerBreadcrumbMenuRef = useRef(null);
|
|
319
|
+
const tableWrapRef = useRef(null);
|
|
320
|
+
const listWrapRef = useRef(null);
|
|
321
|
+
const [showCreateViewModal, setShowCreateViewModal] = useState(false);
|
|
322
|
+
const [editingViewId, setEditingViewId] = useState(null);
|
|
323
|
+
const modalFilterSnapshotRef = useRef(null);
|
|
324
|
+
const [autoSkeletonRows, setAutoSkeletonRows] = useState(skeletonRows);
|
|
325
|
+
const [openRowMenuKey, setOpenRowMenuKey] = useState(null);
|
|
326
|
+
const [openRowMenuRow, setOpenRowMenuRow] = useState(null);
|
|
327
|
+
const [rowMenuPosition, setRowMenuPosition] = useState(null);
|
|
328
|
+
const [tableHasHScroll, setTableHasHScroll] = useState(false);
|
|
329
|
+
const [savedViews, setSavedViews] = useState([]);
|
|
330
|
+
const [activeSavedViewId, setActiveSavedViewId] = useState(null);
|
|
331
|
+
const [selectedRowIds, setSelectedRowIds] = useState(() => new Set());
|
|
332
|
+
const selectAllRef = useRef(null);
|
|
333
|
+
const [cellEdit, setCellEdit] = useState(null);
|
|
334
|
+
const [cellEditPopupPos, setCellEditPopupPos] = useState(null);
|
|
335
|
+
const [cellEditDraftValue, setCellEditDraftValue] = useState(null);
|
|
336
|
+
const cellEditAnchorRef = useRef(null);
|
|
337
|
+
const cellEditPopupRef = useRef(null);
|
|
338
|
+
const liveCellEditColumn = React.useMemo(() => {
|
|
339
|
+
if (!cellEdit?.column) return null;
|
|
340
|
+
const key = cellEdit.columnKey ?? cellEdit.column.accessorKey;
|
|
341
|
+
return columns.find((col) => col?.accessorKey === key) ?? cellEdit.column;
|
|
342
|
+
}, [cellEdit, columns]);
|
|
343
|
+
const [bulkEdit, setBulkEdit] = useState(null);
|
|
344
|
+
const [bulkEditPopupPos, setBulkEditPopupPos] = useState(null);
|
|
345
|
+
const [bulkEditDraftValue, setBulkEditDraftValue] = useState(null);
|
|
346
|
+
const bulkEditPopupRef = useRef(null);
|
|
347
|
+
const bulkEditAnchorRef = useRef(null);
|
|
348
|
+
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
|
349
|
+
const [locallyDeletedIds, setLocallyDeletedIds] = useState(() => new Set());
|
|
350
|
+
const columnsRef = useRef(columns);
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
columnsRef.current = columns;
|
|
354
|
+
}, [columns]);
|
|
355
|
+
|
|
356
|
+
const deserializeSavedViewFilterValue = useCallback((column, value) => {
|
|
357
|
+
if (value == null) {
|
|
358
|
+
return isColumnSelectMulti(column, EDIT_MODE.FILTER) ? [] : "";
|
|
359
|
+
}
|
|
360
|
+
if (getColumnFieldVariant(column, EDIT_MODE.FILTER) === "select") {
|
|
361
|
+
const options = getColumnSelectOptions(column, EDIT_MODE.FILTER);
|
|
362
|
+
const rawValues = Array.isArray(value)
|
|
363
|
+
? value
|
|
364
|
+
: String(value)
|
|
365
|
+
.split(",")
|
|
366
|
+
.map((v) => v.trim())
|
|
367
|
+
.filter(Boolean);
|
|
368
|
+
const normalizedValues = rawValues.map((raw) => {
|
|
369
|
+
const match = options.find(
|
|
370
|
+
(opt) =>
|
|
371
|
+
String(opt.value).trim().toLowerCase() === String(raw).trim().toLowerCase() ||
|
|
372
|
+
String(opt.label).trim().toLowerCase() === String(raw).trim().toLowerCase(),
|
|
373
|
+
);
|
|
374
|
+
return match ? String(match.value) : String(raw);
|
|
375
|
+
});
|
|
376
|
+
if (normalizedValues.length > 1) {
|
|
377
|
+
return normalizedValues;
|
|
378
|
+
}
|
|
379
|
+
return normalizedValues[0] ?? "";
|
|
380
|
+
}
|
|
381
|
+
if (isColumnSelectMulti(column, EDIT_MODE.FILTER)) {
|
|
382
|
+
return String(value)
|
|
383
|
+
.split(",")
|
|
384
|
+
.map((v) => v.trim())
|
|
385
|
+
.filter(Boolean);
|
|
386
|
+
}
|
|
387
|
+
// Always split comma-separated strings into arrays so saved view
|
|
388
|
+
// multi-value filters are displayed and applied like manual filters.
|
|
389
|
+
if (typeof value === "string" && value.includes(",")) {
|
|
390
|
+
return String(value)
|
|
391
|
+
.split(",")
|
|
392
|
+
.map((v) => v.trim())
|
|
393
|
+
.filter(Boolean);
|
|
394
|
+
}
|
|
395
|
+
return value;
|
|
396
|
+
}, []);
|
|
397
|
+
|
|
398
|
+
const normalizeSavedViewFilters = useCallback(
|
|
399
|
+
(filters = []) => {
|
|
400
|
+
const next = [];
|
|
401
|
+
|
|
402
|
+
filters.forEach((filter, index) => {
|
|
403
|
+
const key = filter?.key ?? filter?.id;
|
|
404
|
+
const column = columnsRef.current.find(
|
|
405
|
+
(col) => col?.accessorKey === key,
|
|
406
|
+
);
|
|
407
|
+
const normalized = {
|
|
408
|
+
id: key || `saved-filter-${index}`,
|
|
409
|
+
key,
|
|
410
|
+
label: filter?.label ?? column?.header ?? key ?? "Filter",
|
|
411
|
+
icon: filter?.icon ?? column?.icon ?? column?.filterIcon ?? null,
|
|
412
|
+
value: deserializeSavedViewFilterValue(column, filter?.value),
|
|
413
|
+
operator: filter?.operator,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const existing = next.find((item) => item.key === normalized.key);
|
|
417
|
+
if (!existing) {
|
|
418
|
+
next.push(normalized);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const selectVariant = getColumnFieldVariant(column, EDIT_MODE.FILTER) === "select";
|
|
423
|
+
if (!selectVariant) {
|
|
424
|
+
existing.value = normalized.value;
|
|
425
|
+
existing.label = normalized.label;
|
|
426
|
+
existing.icon = normalized.icon;
|
|
427
|
+
existing.operator = normalized.operator ?? existing.operator;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const existingValues = Array.isArray(existing.value)
|
|
432
|
+
? existing.value
|
|
433
|
+
: existing.value != null && existing.value !== ""
|
|
434
|
+
? [existing.value]
|
|
435
|
+
: [];
|
|
436
|
+
const incomingValues = Array.isArray(normalized.value)
|
|
437
|
+
? normalized.value
|
|
438
|
+
: normalized.value != null && normalized.value !== ""
|
|
439
|
+
? [normalized.value]
|
|
440
|
+
: [];
|
|
441
|
+
const mergedValues = Array.from(
|
|
442
|
+
new Set([...existingValues, ...incomingValues].map((v) => String(v).trim()).filter(Boolean)),
|
|
443
|
+
);
|
|
444
|
+
existing.value = mergedValues.length > 1 ? mergedValues : (mergedValues[0] ?? "");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return next;
|
|
448
|
+
},
|
|
449
|
+
[deserializeSavedViewFilterValue],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const normalizeSavedView = useCallback(
|
|
453
|
+
(view) => ({
|
|
454
|
+
id: view?.id ?? view?.savedViewId ?? null,
|
|
455
|
+
pageKey: view?.pageKey ?? savedViewPageKey,
|
|
456
|
+
name: view?.name ?? "Untitled",
|
|
457
|
+
description: view?.description ?? "",
|
|
458
|
+
viewMode: view?.viewMode === "list" ? "list" : "table",
|
|
459
|
+
filters: Array.isArray(view?.filters)
|
|
460
|
+
? normalizeSavedViewFilters(view.filters)
|
|
461
|
+
: [],
|
|
462
|
+
hiddenColumns: Array.isArray(view?.hiddenColumns) ? view.hiddenColumns : [],
|
|
463
|
+
groupByKey: view?.groupByKey ?? null,
|
|
464
|
+
sort: view?.sort ?? null,
|
|
465
|
+
count: null,
|
|
466
|
+
createdBy: view?.createdBy ?? null,
|
|
467
|
+
createdDate: view?.createdDate ?? null,
|
|
468
|
+
modifiedBy: view?.modifiedBy ?? null,
|
|
469
|
+
modifiedDate: view?.modifiedDate ?? null,
|
|
470
|
+
isPublic: Boolean(view?.isPublic),
|
|
471
|
+
isSystem: Boolean(view?.isSystem),
|
|
472
|
+
}),
|
|
473
|
+
[normalizeSavedViewFilters, savedViewPageKey],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const showRowMenuColumn = Boolean(
|
|
477
|
+
onRowEdit || onRowDownload || onRowDelete || rowMenuActions.length > 0,
|
|
478
|
+
);
|
|
479
|
+
const ROW_MENU_COL_WIDTH = 48;
|
|
480
|
+
|
|
481
|
+
const visibleColumns = columns.filter(
|
|
482
|
+
(column) => column?.accessorKey && column.accessorKey !== "action",
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const filterableColumns = React.useMemo(
|
|
486
|
+
() => getFilterableColumns(visibleColumns),
|
|
487
|
+
[visibleColumns],
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const lockedColumnKeys = displayLockedColumnKeys ?? [rowKey];
|
|
491
|
+
const lockedColumnSet = React.useMemo(
|
|
492
|
+
() => new Set(lockedColumnKeys),
|
|
493
|
+
[lockedColumnKeys],
|
|
494
|
+
);
|
|
495
|
+
const resolvedListLeftKeys = listLeftColumnKeys ?? [
|
|
496
|
+
rowKey,
|
|
497
|
+
"jobOpeningName",
|
|
498
|
+
"candidateName",
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
const resolvedBulkBarFields = React.useMemo(() => {
|
|
502
|
+
if (bulkBarFields !== undefined) return bulkBarFields;
|
|
503
|
+
return getBulkFieldsFromColumns(visibleColumns, bulkEditableKeys);
|
|
504
|
+
}, [bulkBarFields, visibleColumns, bulkEditableKeys]);
|
|
505
|
+
const groupByColumns = visibleColumns.filter(
|
|
506
|
+
(col) => col.accessorKey !== rowKey,
|
|
507
|
+
);
|
|
508
|
+
// Columns rendered — locked columns (e.g. Application ID) always stay visible
|
|
509
|
+
const renderedColumns = React.useMemo(
|
|
510
|
+
() =>
|
|
511
|
+
visibleColumns.filter(
|
|
512
|
+
(col) =>
|
|
513
|
+
lockedColumnSet.has(col.accessorKey) ||
|
|
514
|
+
!hiddenColumns.includes(col.accessorKey),
|
|
515
|
+
),
|
|
516
|
+
[visibleColumns, hiddenColumns, lockedColumnSet],
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const displayPropertyColumns = React.useMemo(
|
|
520
|
+
() =>
|
|
521
|
+
visibleColumns.filter(
|
|
522
|
+
(col) =>
|
|
523
|
+
!lockedColumnSet.has(col.accessorKey) && isColumnDisplayHideable(col),
|
|
524
|
+
),
|
|
525
|
+
[visibleColumns, lockedColumnSet],
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const filterValueIsSet = (filter) => isFilterValueSet(filter);
|
|
529
|
+
|
|
530
|
+
const normalizeOutgoingFilters = (filters) =>
|
|
531
|
+
filters.map((f) => ({
|
|
532
|
+
...f,
|
|
533
|
+
key: f.key || f.id,
|
|
534
|
+
value: f.value,
|
|
535
|
+
}));
|
|
536
|
+
|
|
537
|
+
const withSavedViewMeta = useCallback(
|
|
538
|
+
(meta = {}) =>
|
|
539
|
+
activeSavedViewId != null
|
|
540
|
+
? { ...meta, savedViewId: meta.savedViewId ?? activeSavedViewId }
|
|
541
|
+
: meta,
|
|
542
|
+
[activeSavedViewId],
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const emitFilterChangeImmediate = useCallback(
|
|
546
|
+
(filters, meta = {}) => {
|
|
547
|
+
if (onFilterChange) {
|
|
548
|
+
onFilterChange(
|
|
549
|
+
normalizeOutgoingFilters(filters.filter((f) => filterValueIsSet(f))),
|
|
550
|
+
withSavedViewMeta(meta),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
[onFilterChange, withSavedViewMeta],
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const emitFilterChange = useCallback(
|
|
558
|
+
(filters, meta = {}) => {
|
|
559
|
+
if (!onFilterChange) return;
|
|
560
|
+
const nextFilters = filters.filter((f) => filterValueIsSet(f));
|
|
561
|
+
onFilterChange(
|
|
562
|
+
normalizeOutgoingFilters(nextFilters),
|
|
563
|
+
withSavedViewMeta(meta),
|
|
564
|
+
);
|
|
565
|
+
},
|
|
566
|
+
[onFilterChange, withSavedViewMeta],
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const cloneFilters = (filters) =>
|
|
570
|
+
filters.map((f) => ({
|
|
571
|
+
...f,
|
|
572
|
+
value: Array.isArray(f.value) ? [...f.value] : f.value,
|
|
573
|
+
}));
|
|
574
|
+
|
|
575
|
+
const captureModalFilterSnapshot = () => {
|
|
576
|
+
modalFilterSnapshotRef.current = cloneFilters(activeFilters);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const restoreModalFilterSnapshot = () => {
|
|
580
|
+
if (!modalFilterSnapshotRef.current) return;
|
|
581
|
+
const restored = cloneFilters(modalFilterSnapshotRef.current);
|
|
582
|
+
setActiveFiltersState(restored);
|
|
583
|
+
setIsFilterBarOpen(restored.length > 0);
|
|
584
|
+
modalFilterSnapshotRef.current = null;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const clearModalFilterSnapshot = () => {
|
|
588
|
+
modalFilterSnapshotRef.current = null;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const setActiveFilters = (nextFilters, { notify = false } = {}) => {
|
|
592
|
+
setActiveFiltersState(nextFilters);
|
|
593
|
+
const shouldNotify = notify && !showCreateViewModal;
|
|
594
|
+
if (shouldNotify) {
|
|
595
|
+
emitFilterChange(nextFilters);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const updateFilterValue = (filterId, value) => {
|
|
600
|
+
const nextFilters = activeFilters.map((f) =>
|
|
601
|
+
f.id === filterId ? { ...f, value } : f,
|
|
602
|
+
);
|
|
603
|
+
setActiveFilters(nextFilters, { notify: true });
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const updateFilterOperator = (filterId, operator) => {
|
|
607
|
+
setActiveFiltersState((prev) =>
|
|
608
|
+
prev.map((f) =>
|
|
609
|
+
f.id === filterId ? { ...f, operator, value: null } : f,
|
|
610
|
+
),
|
|
611
|
+
);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// Sort state — shared by column-header popup and Display → Order By
|
|
615
|
+
const sortState = sortConfig || internalSortState;
|
|
616
|
+
const setSortState = (s) => {
|
|
617
|
+
if (onSort) onSort(s.column, s.direction);
|
|
618
|
+
else setInternalSortState(s);
|
|
619
|
+
};
|
|
620
|
+
const orderDirEffective = sortState.direction === "desc" ? "desc" : "asc";
|
|
621
|
+
|
|
622
|
+
const toggleColumnVisibility = useCallback(
|
|
623
|
+
(accessorKey) => {
|
|
624
|
+
if (lockedColumnSet.has(accessorKey)) return;
|
|
625
|
+
const col = visibleColumns.find((c) => c.accessorKey === accessorKey);
|
|
626
|
+
if (col && !isColumnDisplayHideable(col)) return;
|
|
627
|
+
setHiddenColumns((prev) =>
|
|
628
|
+
prev.includes(accessorKey)
|
|
629
|
+
? prev.filter((k) => k !== accessorKey)
|
|
630
|
+
: [...prev, accessorKey],
|
|
631
|
+
);
|
|
632
|
+
},
|
|
633
|
+
[lockedColumnSet, visibleColumns],
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
useEffect(() => {
|
|
637
|
+
if (sortConfig) return;
|
|
638
|
+
if (!defaultSort?.column || !onSort) return;
|
|
639
|
+
onSort(
|
|
640
|
+
defaultSort.column,
|
|
641
|
+
defaultSort.direction === "desc" ? "desc" : "asc",
|
|
642
|
+
);
|
|
643
|
+
// Notify parent of initial default sort once (uncontrolled mode).
|
|
644
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
645
|
+
}, []);
|
|
646
|
+
|
|
647
|
+
useEffect(() => {
|
|
648
|
+
setViewModeState(getStoredViewMode());
|
|
649
|
+
}, [getStoredViewMode]);
|
|
650
|
+
|
|
651
|
+
const applySavedViewCounts = useCallback(
|
|
652
|
+
async (viewsSnapshot = null) => {
|
|
653
|
+
if (!savedViewPageKey) return viewsSnapshot ?? null;
|
|
654
|
+
try {
|
|
655
|
+
let url = `${API.GET_SAVED_VIEW_COUNTS}?pageKey=${encodeURIComponent(savedViewPageKey)}`;
|
|
656
|
+
if (savedViewCountsExtraParams) {
|
|
657
|
+
url += `&${savedViewCountsExtraParams}`;
|
|
658
|
+
}
|
|
659
|
+
const response = await apiService.GET(url);
|
|
660
|
+
const counts = Array.isArray(response?.data) ? response.data : [];
|
|
661
|
+
const countById = new Map(
|
|
662
|
+
counts.map((entry) => [String(entry.id), Number(entry.count)]),
|
|
663
|
+
);
|
|
664
|
+
setSavedViews((prev) => {
|
|
665
|
+
const base = viewsSnapshot ?? prev;
|
|
666
|
+
if (!base.length) return base;
|
|
667
|
+
return base.map((view) => {
|
|
668
|
+
const viewId = String(view.id);
|
|
669
|
+
return {
|
|
670
|
+
...view,
|
|
671
|
+
count: countById.has(viewId) ? countById.get(viewId) : view.count,
|
|
672
|
+
};
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.error("Failed to load saved view counts", error);
|
|
677
|
+
}
|
|
678
|
+
return viewsSnapshot ?? null;
|
|
679
|
+
},
|
|
680
|
+
[savedViewPageKey, savedViewCountsExtraParams],
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
let cancelled = false;
|
|
685
|
+
|
|
686
|
+
const loadSavedViews = async () => {
|
|
687
|
+
setSavedViews([]);
|
|
688
|
+
setActiveSavedViewId(null);
|
|
689
|
+
setEditingViewId(null);
|
|
690
|
+
try {
|
|
691
|
+
const response = await apiService.GET(
|
|
692
|
+
`${API.GET_SAVED_VIEWS}?pageKey=${encodeURIComponent(savedViewPageKey)}`,
|
|
693
|
+
);
|
|
694
|
+
if (cancelled) return;
|
|
695
|
+
const nextViews = Array.isArray(response?.data)
|
|
696
|
+
? response.data.map(normalizeSavedView)
|
|
697
|
+
: [];
|
|
698
|
+
setSavedViews(nextViews);
|
|
699
|
+
if (nextViews.length > 0) {
|
|
700
|
+
await applySavedViewCounts(nextViews);
|
|
701
|
+
}
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (cancelled) return;
|
|
704
|
+
console.error("Failed to load saved views", error);
|
|
705
|
+
setSavedViews([]);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
if (savedViewPageKey) {
|
|
710
|
+
loadSavedViews();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return () => {
|
|
714
|
+
cancelled = true;
|
|
715
|
+
};
|
|
716
|
+
}, [normalizeSavedView, savedViewPageKey, applySavedViewCounts]);
|
|
717
|
+
|
|
718
|
+
const initialAutoSelectDoneRef = React.useRef(false);
|
|
719
|
+
const lastSavedViewsRefreshKeyRef = React.useRef(savedViewsRefreshKey);
|
|
720
|
+
|
|
721
|
+
// Reset auto-select when page changes
|
|
722
|
+
useEffect(() => {
|
|
723
|
+
initialAutoSelectDoneRef.current = false;
|
|
724
|
+
}, [savedViewPageKey]);
|
|
725
|
+
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
onBindSavedViewCountsRefresh?.(() => applySavedViewCounts());
|
|
728
|
+
return () => onBindSavedViewCountsRefresh?.(null);
|
|
729
|
+
}, [applySavedViewCounts, onBindSavedViewCountsRefresh]);
|
|
730
|
+
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
if (savedViews.length === 0) return;
|
|
733
|
+
if (lastSavedViewsRefreshKeyRef.current === savedViewsRefreshKey) return;
|
|
734
|
+
lastSavedViewsRefreshKeyRef.current = savedViewsRefreshKey;
|
|
735
|
+
if (savedViewsRefreshKey <= 0) return;
|
|
736
|
+
applySavedViewCounts();
|
|
737
|
+
}, [savedViewsRefreshKey, savedViews.length, applySavedViewCounts]);
|
|
738
|
+
|
|
739
|
+
const setOrderFromDisplay = useCallback(
|
|
740
|
+
(column, direction, { notifyParent = true } = {}) => {
|
|
741
|
+
if (!column) {
|
|
742
|
+
if (notifyParent && onSort) {
|
|
743
|
+
onSort(null, null);
|
|
744
|
+
} else if (!sortConfig) {
|
|
745
|
+
setInternalSortState({ column: null, direction: null });
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const dir = direction === "desc" ? "desc" : "asc";
|
|
750
|
+
if (notifyParent && onSort) {
|
|
751
|
+
onSort(column, dir);
|
|
752
|
+
} else if (!sortConfig) {
|
|
753
|
+
setInternalSortState({ column, direction: dir });
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
[onSort, sortConfig],
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const getRowId = (row) => {
|
|
760
|
+
const id = row[rowKey] ?? row.id ?? row.applicationId;
|
|
761
|
+
return id == null || id === "" ? null : String(id);
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const sortedRows = React.useMemo(() => {
|
|
765
|
+
const sourceRows = onFilterChange
|
|
766
|
+
? rows
|
|
767
|
+
: applyClientSideFilters(rows, activeFilters, columns);
|
|
768
|
+
|
|
769
|
+
const base = (() => {
|
|
770
|
+
if (!sortState.column || !sortState.direction || onSort) return sourceRows;
|
|
771
|
+
return [...sourceRows].sort((a, b) => {
|
|
772
|
+
let aVal = a[sortState.column];
|
|
773
|
+
let bVal = b[sortState.column];
|
|
774
|
+
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
|
|
775
|
+
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
|
|
776
|
+
if (aVal === bVal) return 0;
|
|
777
|
+
if (aVal === null || aVal === undefined) return 1;
|
|
778
|
+
if (bVal === null || bVal === undefined) return -1;
|
|
779
|
+
const result = aVal < bVal ? -1 : 1;
|
|
780
|
+
return sortState.direction === "asc" ? result : -result;
|
|
781
|
+
});
|
|
782
|
+
})();
|
|
783
|
+
if (locallyDeletedIds.size === 0) return base;
|
|
784
|
+
return base.filter((row) => {
|
|
785
|
+
const id = getRowId(row);
|
|
786
|
+
return id == null || !locallyDeletedIds.has(id);
|
|
787
|
+
});
|
|
788
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
789
|
+
}, [rows, sortState, onSort, locallyDeletedIds, onFilterChange, activeFilters, columns]);
|
|
790
|
+
|
|
791
|
+
const displayedTotalCount = React.useMemo(() => {
|
|
792
|
+
const hasAnyFilterValue = activeFilters.some((f) => filterValueIsSet(f));
|
|
793
|
+
if (hasAnyFilterValue) {
|
|
794
|
+
return sortedRows.length;
|
|
795
|
+
}
|
|
796
|
+
return totalCount != null ? totalCount : sortedRows.length;
|
|
797
|
+
}, [activeFilters, sortedRows.length, totalCount]);
|
|
798
|
+
|
|
799
|
+
const isInitialLoading = isLoading && sortedRows.length === 0;
|
|
800
|
+
const isLoadingMore = isLoading && sortedRows.length > 0;
|
|
801
|
+
|
|
802
|
+
const visibleRowIds = React.useMemo(
|
|
803
|
+
() =>
|
|
804
|
+
isInitialLoading
|
|
805
|
+
? []
|
|
806
|
+
: sortedRows.map(getRowId).filter((id) => id != null),
|
|
807
|
+
// getRowId is stable enough via rowKey; sortedRows/isLoading drive updates
|
|
808
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
809
|
+
[sortedRows, isInitialLoading, rowKey],
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const rowCanDelete = useCallback(
|
|
813
|
+
(row) => (typeof isRowDeletable === "function" ? isRowDeletable(row) : true),
|
|
814
|
+
[isRowDeletable],
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
const selectableVisibleRowIds = visibleRowIds;
|
|
818
|
+
|
|
819
|
+
const selectedCount = selectedRowIds.size;
|
|
820
|
+
const hasSelection = selectedCount > 0;
|
|
821
|
+
const allVisibleSelected =
|
|
822
|
+
selectableVisibleRowIds.length > 0 &&
|
|
823
|
+
selectableVisibleRowIds.every((id) => selectedRowIds.has(id));
|
|
824
|
+
const someVisibleSelected = selectableVisibleRowIds.some((id) =>
|
|
825
|
+
selectedRowIds.has(id),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
const el = selectAllRef.current;
|
|
830
|
+
if (!el) return;
|
|
831
|
+
el.indeterminate = someVisibleSelected && !allVisibleSelected;
|
|
832
|
+
}, [someVisibleSelected, allVisibleSelected]);
|
|
833
|
+
|
|
834
|
+
const toggleRowSelection = (rowId, e) => {
|
|
835
|
+
e?.stopPropagation();
|
|
836
|
+
if (!rowId) return;
|
|
837
|
+
setSelectedRowIds((prev) => {
|
|
838
|
+
const next = new Set(prev);
|
|
839
|
+
if (next.has(rowId)) next.delete(rowId);
|
|
840
|
+
else next.add(rowId);
|
|
841
|
+
return next;
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const toggleSelectAllVisible = (e) => {
|
|
846
|
+
e?.stopPropagation();
|
|
847
|
+
setSelectedRowIds((prev) => {
|
|
848
|
+
const next = new Set(prev);
|
|
849
|
+
if (allVisibleSelected || someVisibleSelected) {
|
|
850
|
+
selectableVisibleRowIds.forEach((id) => next.delete(id));
|
|
851
|
+
} else {
|
|
852
|
+
selectableVisibleRowIds.forEach((id) => next.add(id));
|
|
853
|
+
}
|
|
854
|
+
return next;
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const toggleGroupSelection = (rowIds, e) => {
|
|
859
|
+
e?.stopPropagation();
|
|
860
|
+
const ids = rowIds.map((id) => String(id)).filter(Boolean);
|
|
861
|
+
if (!ids.length) return;
|
|
862
|
+
const allInGroupSelected = ids.every((id) => selectedRowIds.has(id));
|
|
863
|
+
setSelectedRowIds((prev) => {
|
|
864
|
+
const next = new Set(prev);
|
|
865
|
+
if (allInGroupSelected) {
|
|
866
|
+
ids.forEach((id) => next.delete(id));
|
|
867
|
+
} else {
|
|
868
|
+
ids.forEach((id) => next.add(id));
|
|
869
|
+
}
|
|
870
|
+
return next;
|
|
871
|
+
});
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
const clearRowSelection = () => setSelectedRowIds(new Set());
|
|
875
|
+
|
|
876
|
+
useEffect(() => {
|
|
877
|
+
if (!enableBulkSelection) setSelectedRowIds(new Set());
|
|
878
|
+
}, [enableBulkSelection]);
|
|
879
|
+
|
|
880
|
+
const isRowSelected = (rowId) => rowId != null && selectedRowIds.has(rowId);
|
|
881
|
+
|
|
882
|
+
// Close dropdowns/popups on outside click
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
const handleClickOutside = (e) => {
|
|
885
|
+
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target)) {
|
|
886
|
+
setShowFilterDropdown(false);
|
|
887
|
+
setFilterSearch("");
|
|
888
|
+
}
|
|
889
|
+
if (
|
|
890
|
+
replacingFilterId &&
|
|
891
|
+
!e.target.closest(".mdt-chip-replace-dropdown") &&
|
|
892
|
+
!e.target.closest(".mdt-chip-label-btn")
|
|
893
|
+
) {
|
|
894
|
+
setReplacingFilterId(null);
|
|
895
|
+
setReplaceSearch("");
|
|
896
|
+
}
|
|
897
|
+
if (
|
|
898
|
+
valueEditId &&
|
|
899
|
+
!e.target.closest(".mdt-value-popup") &&
|
|
900
|
+
!e.target.closest(".mdt-chip-value") &&
|
|
901
|
+
!e.target.closest(".mdt-chip-value-wrap") &&
|
|
902
|
+
!e.target.closest(".mdt-filter-op-dropdown") &&
|
|
903
|
+
!e.target.closest(".mdt-chip-op-btn")
|
|
904
|
+
) {
|
|
905
|
+
setValueEditId(null);
|
|
906
|
+
setValueInputText("");
|
|
907
|
+
setValueSelectSearch("");
|
|
908
|
+
}
|
|
909
|
+
if (
|
|
910
|
+
showInnerFilterDropdown &&
|
|
911
|
+
!e.target.closest(".mdt-filter-button-wrap")
|
|
912
|
+
) {
|
|
913
|
+
setShowInnerFilterDropdown(false);
|
|
914
|
+
setInnerFilterSearch("");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (displayPopupRef.current && !displayPopupRef.current.contains(e.target)) {
|
|
918
|
+
setShowDisplayPopup(false);
|
|
919
|
+
}
|
|
920
|
+
if (
|
|
921
|
+
showHeaderBreadcrumbMenu &&
|
|
922
|
+
headerBreadcrumbMenuRef.current &&
|
|
923
|
+
!headerBreadcrumbMenuRef.current.contains(e.target)
|
|
924
|
+
) {
|
|
925
|
+
setShowHeaderBreadcrumbMenu(false);
|
|
926
|
+
}
|
|
927
|
+
if (
|
|
928
|
+
openRowMenuKey &&
|
|
929
|
+
!e.target.closest(".mdt-row-menu-trigger") &&
|
|
930
|
+
!e.target.closest(".mdt-row-menu-popup")
|
|
931
|
+
) {
|
|
932
|
+
setOpenRowMenuKey(null);
|
|
933
|
+
setOpenRowMenuRow(null);
|
|
934
|
+
setRowMenuPosition(null);
|
|
935
|
+
}
|
|
936
|
+
if (
|
|
937
|
+
openPopup &&
|
|
938
|
+
popupRef.current &&
|
|
939
|
+
!popupRef.current.contains(e.target) &&
|
|
940
|
+
!e.target.closest(".mdt-header-cell")
|
|
941
|
+
) {
|
|
942
|
+
setOpenPopup(null);
|
|
943
|
+
}
|
|
944
|
+
if (
|
|
945
|
+
cellEdit &&
|
|
946
|
+
cellEditPopupRef.current &&
|
|
947
|
+
!cellEditPopupRef.current.contains(e.target) &&
|
|
948
|
+
cellEditAnchorRef.current &&
|
|
949
|
+
!cellEditAnchorRef.current.contains(e.target)
|
|
950
|
+
) {
|
|
951
|
+
setCellEdit(null);
|
|
952
|
+
setCellEditDraftValue(null);
|
|
953
|
+
setCellEditPopupPos(null);
|
|
954
|
+
}
|
|
955
|
+
if (
|
|
956
|
+
bulkEdit &&
|
|
957
|
+
bulkEditPopupRef.current &&
|
|
958
|
+
!bulkEditPopupRef.current.contains(e.target) &&
|
|
959
|
+
!e.target.closest(".mdt-bulk-field-btn")
|
|
960
|
+
) {
|
|
961
|
+
setBulkEdit(null);
|
|
962
|
+
setBulkEditPopupPos(null);
|
|
963
|
+
setBulkEditDraftValue(null);
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
if (
|
|
967
|
+
openPopup ||
|
|
968
|
+
showHeaderBreadcrumbMenu ||
|
|
969
|
+
showFilterDropdown ||
|
|
970
|
+
showInnerFilterDropdown ||
|
|
971
|
+
!!replacingFilterId ||
|
|
972
|
+
showDisplayPopup ||
|
|
973
|
+
!!valueEditId ||
|
|
974
|
+
openRowMenuKey ||
|
|
975
|
+
cellEdit ||
|
|
976
|
+
bulkEdit
|
|
977
|
+
) {
|
|
978
|
+
document.addEventListener("mousedown", handleClickOutside, true);
|
|
979
|
+
}
|
|
980
|
+
return () => document.removeEventListener("mousedown", handleClickOutside, true);
|
|
981
|
+
}, [openPopup, showHeaderBreadcrumbMenu, showFilterDropdown, showInnerFilterDropdown, showDisplayPopup, replacingFilterId, valueEditId, openRowMenuKey, cellEdit, bulkEdit]);
|
|
982
|
+
|
|
983
|
+
const closeFilterChipPopups = useCallback(() => {
|
|
984
|
+
setValueEditId(null);
|
|
985
|
+
setValueInputText("");
|
|
986
|
+
setValueSelectSearch("");
|
|
987
|
+
setReplacingFilterId(null);
|
|
988
|
+
setReplaceSearch("");
|
|
989
|
+
setShowInnerFilterDropdown(false);
|
|
990
|
+
setInnerFilterSearch("");
|
|
991
|
+
}, []);
|
|
992
|
+
|
|
993
|
+
const applySavedView = useCallback(
|
|
994
|
+
(view, { notify = true } = {}) => {
|
|
995
|
+
closeFilterChipPopups();
|
|
996
|
+
|
|
997
|
+
if (!view) {
|
|
998
|
+
setActiveSavedViewId(null);
|
|
999
|
+
setActiveFilters([], { notify });
|
|
1000
|
+
setIsFilterBarOpen(false);
|
|
1001
|
+
setHiddenColumns([]);
|
|
1002
|
+
setGroupByKey(null);
|
|
1003
|
+
setOrderFromDisplay(null, null, { notifyParent: notify });
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
setActiveSavedViewId(view.id);
|
|
1008
|
+
const nextFilters = (view.filters || []).map((f, index) => ({
|
|
1009
|
+
...f,
|
|
1010
|
+
id: f.id || `${f.key}-${view.id}-${index}`,
|
|
1011
|
+
}));
|
|
1012
|
+
// Update filter state without emitting (emitFilterChangeImmediate below handles notification)
|
|
1013
|
+
setActiveFilters(nextFilters);
|
|
1014
|
+
setIsFilterBarOpen(nextFilters.length > 0);
|
|
1015
|
+
if (notify) {
|
|
1016
|
+
emitFilterChangeImmediate(nextFilters, {
|
|
1017
|
+
sort: view.sort ?? null,
|
|
1018
|
+
groupByKey: view.groupByKey ?? null,
|
|
1019
|
+
viewMode: view.viewMode === "list" ? "list" : "table",
|
|
1020
|
+
savedViewId: view.id,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
setHiddenColumns(
|
|
1024
|
+
(view.hiddenColumns || []).filter((k) => !lockedColumnSet.has(k)),
|
|
1025
|
+
);
|
|
1026
|
+
setGroupByKey(view.groupByKey ?? null);
|
|
1027
|
+
if (view.sort?.column) {
|
|
1028
|
+
setOrderFromDisplay(view.sort.column, view.sort.direction || "asc", {
|
|
1029
|
+
notifyParent: false,
|
|
1030
|
+
});
|
|
1031
|
+
} else {
|
|
1032
|
+
setOrderFromDisplay(null, null, { notifyParent: false });
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
[closeFilterChipPopups, setOrderFromDisplay, emitFilterChangeImmediate, lockedColumnSet],
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
const resolvedActiveTabId = activeSavedViewId
|
|
1039
|
+
? `view-${activeSavedViewId}`
|
|
1040
|
+
: activeTab;
|
|
1041
|
+
|
|
1042
|
+
const mergedTabs = React.useMemo(() => {
|
|
1043
|
+
const defaultTabOrder = new Map([
|
|
1044
|
+
["active", 0],
|
|
1045
|
+
["draft", 1],
|
|
1046
|
+
["closed", 2],
|
|
1047
|
+
["all", 3],
|
|
1048
|
+
]);
|
|
1049
|
+
|
|
1050
|
+
const nextTabs = [...tabs, ...savedViews.map((view) => ({
|
|
1051
|
+
id: `view-${view.id}`,
|
|
1052
|
+
label: view.name,
|
|
1053
|
+
count: view.count,
|
|
1054
|
+
isSavedView: true,
|
|
1055
|
+
isSystem: Boolean(view.isSystem),
|
|
1056
|
+
createdBy: view.createdBy ?? null,
|
|
1057
|
+
savedViewId: view.id,
|
|
1058
|
+
}))];
|
|
1059
|
+
|
|
1060
|
+
const deduped = [];
|
|
1061
|
+
const seen = new Set();
|
|
1062
|
+
nextTabs.forEach((tab) => {
|
|
1063
|
+
const key = String(tab?.label ?? tab?.id ?? "").trim().toLowerCase();
|
|
1064
|
+
if (!key) return;
|
|
1065
|
+
const existingIndex = deduped.findIndex(
|
|
1066
|
+
(item) => String(item?.label ?? item?.id ?? "").trim().toLowerCase() === key,
|
|
1067
|
+
);
|
|
1068
|
+
if (existingIndex >= 0) {
|
|
1069
|
+
const existing = deduped[existingIndex];
|
|
1070
|
+
if (tab.isSavedView && !existing.isSavedView) {
|
|
1071
|
+
deduped[existingIndex] = tab;
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (seen.has(key)) return;
|
|
1076
|
+
seen.add(key);
|
|
1077
|
+
deduped.push(tab);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
const withActiveCount = deduped.map((tab) => {
|
|
1081
|
+
const isActive =
|
|
1082
|
+
isSameMdtTab(resolvedActiveTabId, tab.id) ||
|
|
1083
|
+
(activeSavedViewId != null && String(tab.savedViewId) === String(activeSavedViewId)) ||
|
|
1084
|
+
(activeSavedViewId == null && tab.isSavedView);
|
|
1085
|
+
if (isActive && displayedTotalCount != null) {
|
|
1086
|
+
return { ...tab, count: displayedTotalCount };
|
|
1087
|
+
}
|
|
1088
|
+
return tab;
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
return withActiveCount.sort((a, b) => {
|
|
1092
|
+
const aKey = String(a?.label ?? a?.id ?? "").trim().toLowerCase();
|
|
1093
|
+
const bKey = String(b?.label ?? b?.id ?? "").trim().toLowerCase();
|
|
1094
|
+
const aIsDefault = defaultTabOrder.has(aKey);
|
|
1095
|
+
const bIsDefault = defaultTabOrder.has(bKey);
|
|
1096
|
+
if (aIsDefault && bIsDefault) {
|
|
1097
|
+
return defaultTabOrder.get(aKey) - defaultTabOrder.get(bKey);
|
|
1098
|
+
}
|
|
1099
|
+
if (aIsDefault) return -1;
|
|
1100
|
+
if (bIsDefault) return 1;
|
|
1101
|
+
if (a.isSavedView && !b.isSavedView) return -1;
|
|
1102
|
+
if (!a.isSavedView && b.isSavedView) return 1;
|
|
1103
|
+
return String(a.label ?? "").localeCompare(String(b.label ?? ""));
|
|
1104
|
+
});
|
|
1105
|
+
}, [tabs, savedViews, resolvedActiveTabId, activeSavedViewId, displayedTotalCount]);
|
|
1106
|
+
|
|
1107
|
+
// Auto-select the first saved view on initial page load when no active tab/view exists
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
if (disableAutoSelectFirstView) return;
|
|
1110
|
+
if (savedViews.length === 0) return;
|
|
1111
|
+
if (initialAutoSelectDoneRef.current) return;
|
|
1112
|
+
if (activeSavedViewId) return;
|
|
1113
|
+
const tabExists = activeTab && mergedTabs.some((t) => t.id === activeTab);
|
|
1114
|
+
if (tabExists) return;
|
|
1115
|
+
initialAutoSelectDoneRef.current = true;
|
|
1116
|
+
const firstView = savedViews[0];
|
|
1117
|
+
if (firstView) {
|
|
1118
|
+
applySavedView(firstView, { notify: true });
|
|
1119
|
+
}
|
|
1120
|
+
}, [savedViews, activeSavedViewId, activeTab, mergedTabs, applySavedView, disableAutoSelectFirstView]);
|
|
1121
|
+
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
setSelectedRowIds(new Set());
|
|
1124
|
+
}, [resolvedActiveTabId]);
|
|
1125
|
+
|
|
1126
|
+
useEffect(() => {
|
|
1127
|
+
if (tableWrapRef.current) {
|
|
1128
|
+
tableWrapRef.current.scrollTop = 0;
|
|
1129
|
+
}
|
|
1130
|
+
if (listWrapRef.current) {
|
|
1131
|
+
listWrapRef.current.scrollTop = 0;
|
|
1132
|
+
}
|
|
1133
|
+
}, [sortConfig?.column, sortConfig?.direction, resolvedActiveTabId]);
|
|
1134
|
+
|
|
1135
|
+
const listGroupHeadPrefix = React.useMemo(() => {
|
|
1136
|
+
if (!mergedTabs.length) return "All";
|
|
1137
|
+
const tab =
|
|
1138
|
+
mergedTabs.find((t) => isSameMdtTab(resolvedActiveTabId, t.id)) ??
|
|
1139
|
+
mergedTabs.find((t) => isSameMdtTab(t.id, "all")) ??
|
|
1140
|
+
mergedTabs[0];
|
|
1141
|
+
return tab?.label ?? "All";
|
|
1142
|
+
}, [mergedTabs, resolvedActiveTabId]);
|
|
1143
|
+
|
|
1144
|
+
const listGroupHeadCount = React.useMemo(() => {
|
|
1145
|
+
if (mergedTabs.length) {
|
|
1146
|
+
const tab =
|
|
1147
|
+
mergedTabs.find((t) => isSameMdtTab(resolvedActiveTabId, t.id)) ??
|
|
1148
|
+
mergedTabs.find((t) => isSameMdtTab(t.id, "all")) ??
|
|
1149
|
+
mergedTabs[0];
|
|
1150
|
+
if (tab?.isSavedView) {
|
|
1151
|
+
if (tab.count != null && tab.count !== "") return tab.count;
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
if (tab?.count != null && tab.count !== "") {
|
|
1155
|
+
return tab.count;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (activeSavedViewId) return null;
|
|
1159
|
+
if (displayedTotalCount != null) return displayedTotalCount;
|
|
1160
|
+
return sortedRows.length;
|
|
1161
|
+
}, [
|
|
1162
|
+
mergedTabs,
|
|
1163
|
+
resolvedActiveTabId,
|
|
1164
|
+
activeSavedViewId,
|
|
1165
|
+
displayedTotalCount,
|
|
1166
|
+
sortedRows.length,
|
|
1167
|
+
]);
|
|
1168
|
+
|
|
1169
|
+
const dismissCreateViewModal = useCallback(() => {
|
|
1170
|
+
clearModalFilterSnapshot();
|
|
1171
|
+
setEditingViewId(null);
|
|
1172
|
+
setShowCreateViewModal(false);
|
|
1173
|
+
closeFilterChipPopups();
|
|
1174
|
+
}, [closeFilterChipPopups]);
|
|
1175
|
+
|
|
1176
|
+
const handleTabClick = useCallback(
|
|
1177
|
+
(tab) => {
|
|
1178
|
+
if (showCreateViewModal) {
|
|
1179
|
+
dismissCreateViewModal();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (tab.isSavedView) {
|
|
1183
|
+
const view = savedViews.find((v) => v.id === tab.savedViewId);
|
|
1184
|
+
if (view) applySavedView(view, { notify: true });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const leavingSavedView = Boolean(activeSavedViewId);
|
|
1189
|
+
if (leavingSavedView) {
|
|
1190
|
+
applySavedView(null, { notify: false });
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (!leavingSavedView && isSameMdtTab(activeTab, tab.id)) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (onTabChange) {
|
|
1198
|
+
onTabChange(tab.id, { clearFilters: leavingSavedView });
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
[
|
|
1202
|
+
savedViews,
|
|
1203
|
+
applySavedView,
|
|
1204
|
+
onTabChange,
|
|
1205
|
+
showCreateViewModal,
|
|
1206
|
+
dismissCreateViewModal,
|
|
1207
|
+
activeSavedViewId,
|
|
1208
|
+
activeTab,
|
|
1209
|
+
],
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const updateTableScrollState = useCallback(() => {
|
|
1213
|
+
const el = tableWrapRef.current;
|
|
1214
|
+
if (!el) return;
|
|
1215
|
+
setTableHasHScroll(el.scrollWidth > el.clientWidth + 1);
|
|
1216
|
+
}, []);
|
|
1217
|
+
|
|
1218
|
+
const rowMenuColClassName = tableHasHScroll
|
|
1219
|
+
? "mdt-col-row-menu mdt-col-row-menu--scroll"
|
|
1220
|
+
: "mdt-col-row-menu";
|
|
1221
|
+
|
|
1222
|
+
const resolvedStickyColumnKey = tableStickyColumnKey ?? rowKey;
|
|
1223
|
+
const stickyLeftColClassName = tableHasHScroll
|
|
1224
|
+
? "mdt-col-sticky-left mdt-col-sticky-left--scroll"
|
|
1225
|
+
: "mdt-col-sticky-left";
|
|
1226
|
+
|
|
1227
|
+
const isStickyLeftColumn = useCallback(
|
|
1228
|
+
(col) => col.accessorKey === resolvedStickyColumnKey,
|
|
1229
|
+
[resolvedStickyColumnKey],
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
const getTableCellClassName = (col, colIndex, { header = false } = {}) => {
|
|
1233
|
+
const cellEditable = !header && isColumnCellEditable(col, onCellEdit);
|
|
1234
|
+
return (
|
|
1235
|
+
[
|
|
1236
|
+
"mdt-col-data",
|
|
1237
|
+
!header && col.valueClassName,
|
|
1238
|
+
enableBulkSelection && colIndex === 0 ? "mdt-td-first-with-select" : "",
|
|
1239
|
+
enableBulkSelection && header && colIndex === 0
|
|
1240
|
+
? "mdt-th-first-with-select"
|
|
1241
|
+
: "",
|
|
1242
|
+
isStickyLeftColumn(col) ? stickyLeftColClassName : "",
|
|
1243
|
+
header && isStickyLeftColumn(col) ? "mdt-th-sticky-left" : "",
|
|
1244
|
+
!header && onCellEdit ? (cellEditable ? "mdt-td-editable" : "mdt-td-readonly") : "",
|
|
1245
|
+
]
|
|
1246
|
+
.filter(Boolean)
|
|
1247
|
+
.join(" ") || undefined
|
|
1248
|
+
);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// Auto-calculate skeleton rows + detect horizontal scroll for sticky menu column border
|
|
1252
|
+
useEffect(() => {
|
|
1253
|
+
const update = () => {
|
|
1254
|
+
const isList = viewMode === "list";
|
|
1255
|
+
const el = isList ? listWrapRef.current : tableWrapRef.current;
|
|
1256
|
+
const h = el?.clientHeight || 0;
|
|
1257
|
+
if (!h) return;
|
|
1258
|
+
const headerReserve = isList ? 48 : 44;
|
|
1259
|
+
const rowHeight = 48;
|
|
1260
|
+
const fit = Math.ceil(Math.max(h - headerReserve, 0) / rowHeight);
|
|
1261
|
+
setAutoSkeletonRows(Math.max(skeletonRows, fit));
|
|
1262
|
+
if (!isList) updateTableScrollState();
|
|
1263
|
+
};
|
|
1264
|
+
update();
|
|
1265
|
+
window.addEventListener("resize", update);
|
|
1266
|
+
|
|
1267
|
+
const el = viewMode === "list" ? listWrapRef.current : tableWrapRef.current;
|
|
1268
|
+
let ro;
|
|
1269
|
+
if (el) {
|
|
1270
|
+
ro = new ResizeObserver(update);
|
|
1271
|
+
ro.observe(el);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return () => {
|
|
1275
|
+
window.removeEventListener("resize", update);
|
|
1276
|
+
ro?.disconnect();
|
|
1277
|
+
};
|
|
1278
|
+
}, [
|
|
1279
|
+
skeletonRows,
|
|
1280
|
+
showRowMenuColumn,
|
|
1281
|
+
updateTableScrollState,
|
|
1282
|
+
renderedColumns.length,
|
|
1283
|
+
rows.length,
|
|
1284
|
+
isLoading,
|
|
1285
|
+
viewMode,
|
|
1286
|
+
]);
|
|
1287
|
+
|
|
1288
|
+
// Mobile / nested layouts: keep anchored popups inside the viewport
|
|
1289
|
+
useLayoutEffect(() => {
|
|
1290
|
+
const useAnchoredPopups = isMobilePopupLayout() || anchoredFilterPopups;
|
|
1291
|
+
if (!useAnchoredPopups) {
|
|
1292
|
+
clearMobilePopupTop();
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
let anchor = null;
|
|
1297
|
+
if (valueEditId && chipValueRef.current) {
|
|
1298
|
+
anchor = chipValueRef.current;
|
|
1299
|
+
} else if (replacingFilterId && chipReplaceRef.current) {
|
|
1300
|
+
anchor = chipReplaceRef.current;
|
|
1301
|
+
} else if (showInnerFilterDropdown && innerFilterRef.current) {
|
|
1302
|
+
anchor = innerFilterRef.current;
|
|
1303
|
+
} else if (showFilterDropdown && filterDropdownRef.current) {
|
|
1304
|
+
anchor = filterDropdownRef.current;
|
|
1305
|
+
} else if (showDisplayPopup && displayPopupRef.current) {
|
|
1306
|
+
anchor = displayPopupRef.current;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const sync = () => {
|
|
1310
|
+
if (anchor) {
|
|
1311
|
+
syncAnchoredPopupTop(anchor, { fullWidth: isMobilePopupLayout() });
|
|
1312
|
+
} else {
|
|
1313
|
+
clearMobilePopupTop();
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
sync();
|
|
1317
|
+
|
|
1318
|
+
const scrollTargets = [];
|
|
1319
|
+
if (anchor) {
|
|
1320
|
+
let node = anchor.parentElement;
|
|
1321
|
+
while (node && node !== document.body) {
|
|
1322
|
+
const { overflowY, overflow } = getComputedStyle(node);
|
|
1323
|
+
if (
|
|
1324
|
+
overflowY === "auto" ||
|
|
1325
|
+
overflowY === "scroll" ||
|
|
1326
|
+
overflow === "auto" ||
|
|
1327
|
+
overflow === "scroll"
|
|
1328
|
+
) {
|
|
1329
|
+
scrollTargets.push(node);
|
|
1330
|
+
}
|
|
1331
|
+
node = node.parentElement;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
window.addEventListener("resize", sync);
|
|
1336
|
+
scrollTargets.forEach((el) => el.addEventListener("scroll", sync, { passive: true }));
|
|
1337
|
+
return () => {
|
|
1338
|
+
window.removeEventListener("resize", sync);
|
|
1339
|
+
scrollTargets.forEach((el) => el.removeEventListener("scroll", sync));
|
|
1340
|
+
clearMobilePopupTop();
|
|
1341
|
+
};
|
|
1342
|
+
}, [
|
|
1343
|
+
anchoredFilterPopups,
|
|
1344
|
+
valueEditId,
|
|
1345
|
+
replacingFilterId,
|
|
1346
|
+
showInnerFilterDropdown,
|
|
1347
|
+
showFilterDropdown,
|
|
1348
|
+
showDisplayPopup,
|
|
1349
|
+
]);
|
|
1350
|
+
|
|
1351
|
+
// ── Filter helpers ──────────────────────────────────────────────────────────
|
|
1352
|
+
|
|
1353
|
+
const addFilter = (col) => {
|
|
1354
|
+
const isDuplicate = activeFilters.find((f) => f.key === col.accessorKey);
|
|
1355
|
+
const newId = `${col.accessorKey}-${Date.now()}`;
|
|
1356
|
+
if (!isDuplicate) {
|
|
1357
|
+
setActiveFiltersState((prev) => [
|
|
1358
|
+
...prev,
|
|
1359
|
+
{
|
|
1360
|
+
id: newId,
|
|
1361
|
+
key: col.accessorKey,
|
|
1362
|
+
label: col.header,
|
|
1363
|
+
icon: col.headerIcon || col.rowIcon || null,
|
|
1364
|
+
operator: isDateLikeFilterColumn(col)
|
|
1365
|
+
? DEFAULT_DATE_FILTER_OPERATOR
|
|
1366
|
+
: undefined,
|
|
1367
|
+
},
|
|
1368
|
+
]);
|
|
1369
|
+
setValueEditId(newId);
|
|
1370
|
+
setValueInputText("");
|
|
1371
|
+
setValueSelectSearch("");
|
|
1372
|
+
} else {
|
|
1373
|
+
setValueEditId(isDuplicate.id);
|
|
1374
|
+
setValueInputText(isDuplicate.value || "");
|
|
1375
|
+
setValueSelectSearch("");
|
|
1376
|
+
}
|
|
1377
|
+
setIsFilterBarOpen(true);
|
|
1378
|
+
setShowFilterDropdown(false);
|
|
1379
|
+
setShowInnerFilterDropdown(false);
|
|
1380
|
+
setFilterSearch("");
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const removeFilter = (filterId) => {
|
|
1384
|
+
const updated = activeFilters.filter((f) => f.id !== filterId);
|
|
1385
|
+
setActiveFilters(updated, { notify: true });
|
|
1386
|
+
if (updated.length === 0) setIsFilterBarOpen(false);
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
const clearAllFilters = () => {
|
|
1390
|
+
setActiveSavedViewId(null);
|
|
1391
|
+
setActiveFilters([], { notify: true });
|
|
1392
|
+
setIsFilterBarOpen(false);
|
|
1393
|
+
if (onTabChange) {
|
|
1394
|
+
onTabChange("all", { clearFilters: true });
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const onReplaceFilterKey = (filterId, col) => {
|
|
1399
|
+
setActiveFiltersState((prev) =>
|
|
1400
|
+
prev.map((f) =>
|
|
1401
|
+
f.id === filterId
|
|
1402
|
+
? {
|
|
1403
|
+
...f,
|
|
1404
|
+
key: col.accessorKey,
|
|
1405
|
+
label: col.header,
|
|
1406
|
+
icon: col.headerIcon || col.rowIcon || null,
|
|
1407
|
+
value: null,
|
|
1408
|
+
operator: isDateLikeFilterColumn(col)
|
|
1409
|
+
? DEFAULT_DATE_FILTER_OPERATOR
|
|
1410
|
+
: undefined,
|
|
1411
|
+
}
|
|
1412
|
+
: f,
|
|
1413
|
+
),
|
|
1414
|
+
);
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const closeFilterBarPopups = () => {
|
|
1418
|
+
setShowFilterDropdown(false);
|
|
1419
|
+
setShowInnerFilterDropdown(false);
|
|
1420
|
+
setOpenPopup(null);
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
const closeCellEdit = useCallback(() => {
|
|
1424
|
+
setCellEdit(null);
|
|
1425
|
+
setCellEditPopupPos(null);
|
|
1426
|
+
setCellEditDraftValue(null);
|
|
1427
|
+
}, []);
|
|
1428
|
+
|
|
1429
|
+
const closeBulkEdit = useCallback(() => {
|
|
1430
|
+
bulkEditAnchorRef.current = null;
|
|
1431
|
+
setBulkEdit(null);
|
|
1432
|
+
setBulkEditPopupPos(null);
|
|
1433
|
+
setBulkEditDraftValue(null);
|
|
1434
|
+
}, []);
|
|
1435
|
+
|
|
1436
|
+
const getSelectedRows = useCallback(
|
|
1437
|
+
() =>
|
|
1438
|
+
sortedRows.filter((row) => {
|
|
1439
|
+
const id = getRowId(row);
|
|
1440
|
+
return id != null && selectedRowIds.has(String(id));
|
|
1441
|
+
}),
|
|
1442
|
+
[sortedRows, selectedRowIds, rowKey],
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
const getDeletableSelectedRows = useCallback(
|
|
1446
|
+
() => getSelectedRows().filter((row) => rowCanDelete(row)),
|
|
1447
|
+
[getSelectedRows, rowCanDelete],
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
const deletableSelectedCount = React.useMemo(
|
|
1451
|
+
() => getDeletableSelectedRows().length,
|
|
1452
|
+
[getDeletableSelectedRows],
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
const skippedDeleteCount = Math.max(0, selectedCount - deletableSelectedCount);
|
|
1456
|
+
|
|
1457
|
+
const buildDefaultBulkDeleteMessage = useCallback(() => {
|
|
1458
|
+
if (deletableSelectedCount === 0) {
|
|
1459
|
+
return "None of the selected items can be deleted.";
|
|
1460
|
+
}
|
|
1461
|
+
if (skippedDeleteCount === 0) {
|
|
1462
|
+
return `Are you sure you want to delete ${deletableSelectedCount} selected item(s)? This action cannot be undone.`;
|
|
1463
|
+
}
|
|
1464
|
+
return `Delete ${deletableSelectedCount} of ${selectedCount} selected item(s)? ${skippedDeleteCount} item(s) are in use and will be skipped. This action cannot be undone.`;
|
|
1465
|
+
}, [deletableSelectedCount, skippedDeleteCount, selectedCount]);
|
|
1466
|
+
|
|
1467
|
+
const bulkDeleteCounts = React.useMemo(
|
|
1468
|
+
() => ({
|
|
1469
|
+
deletableCount: deletableSelectedCount,
|
|
1470
|
+
skippedCount: skippedDeleteCount,
|
|
1471
|
+
deletableRows: getDeletableSelectedRows(),
|
|
1472
|
+
}),
|
|
1473
|
+
[deletableSelectedCount, skippedDeleteCount, getDeletableSelectedRows],
|
|
1474
|
+
);
|
|
1475
|
+
|
|
1476
|
+
useLayoutEffect(() => {
|
|
1477
|
+
if (!cellEdit || !cellEditPopupRef.current) return;
|
|
1478
|
+
const popupRect = cellEditPopupRef.current.getBoundingClientRect();
|
|
1479
|
+
const next = refineCellEditPopupPosition(cellEdit.anchorRect, popupRect);
|
|
1480
|
+
if (next) {
|
|
1481
|
+
const measuredW = Math.ceil(popupRect.width);
|
|
1482
|
+
setCellEditPopupPos((prev) => ({
|
|
1483
|
+
top: next.top,
|
|
1484
|
+
left: next.left,
|
|
1485
|
+
width: Math.min(320, Math.max(measuredW, prev?.width ?? cellEdit.width)),
|
|
1486
|
+
}));
|
|
1487
|
+
}
|
|
1488
|
+
}, [cellEdit, cellEditDraftValue]);
|
|
1489
|
+
|
|
1490
|
+
useLayoutEffect(() => {
|
|
1491
|
+
if (!bulkEdit || !bulkEditPopupRef.current) return;
|
|
1492
|
+
const popupRect = bulkEditPopupRef.current.getBoundingClientRect();
|
|
1493
|
+
const next = refineCellEditPopupPosition(bulkEdit.anchorRect, popupRect);
|
|
1494
|
+
if (next) {
|
|
1495
|
+
const measuredW = Math.ceil(popupRect.width);
|
|
1496
|
+
setBulkEditPopupPos((prev) => ({
|
|
1497
|
+
top: next.top,
|
|
1498
|
+
left: next.left,
|
|
1499
|
+
width: Math.min(320, Math.max(measuredW, prev?.width ?? bulkEdit.width)),
|
|
1500
|
+
}));
|
|
1501
|
+
}
|
|
1502
|
+
}, [bulkEdit, bulkEditDraftValue]);
|
|
1503
|
+
|
|
1504
|
+
const openBulkEditPopup = useCallback(
|
|
1505
|
+
(field, e) => {
|
|
1506
|
+
if (!onBulkEdit || !field?.column) return;
|
|
1507
|
+
const anchorEl = e?.currentTarget;
|
|
1508
|
+
if (!anchorEl) return;
|
|
1509
|
+
|
|
1510
|
+
closeCellEdit();
|
|
1511
|
+
closeFilterChipPopups();
|
|
1512
|
+
closeRowMenu();
|
|
1513
|
+
|
|
1514
|
+
bulkEditAnchorRef.current = anchorEl;
|
|
1515
|
+
const anchor = anchorEl.getBoundingClientRect();
|
|
1516
|
+
const anchorRect = {
|
|
1517
|
+
top: anchor.top,
|
|
1518
|
+
left: anchor.left,
|
|
1519
|
+
right: anchor.right,
|
|
1520
|
+
bottom: anchor.bottom,
|
|
1521
|
+
width: anchor.width,
|
|
1522
|
+
height: anchor.height,
|
|
1523
|
+
};
|
|
1524
|
+
const position = computeCellEditPopupPosition(anchorRect, field.column, EDIT_MODE.BULK);
|
|
1525
|
+
setBulkEdit({
|
|
1526
|
+
fieldId: field.id,
|
|
1527
|
+
field,
|
|
1528
|
+
column: field.column,
|
|
1529
|
+
anchorRect,
|
|
1530
|
+
width: position.width,
|
|
1531
|
+
});
|
|
1532
|
+
setBulkEditPopupPos(position);
|
|
1533
|
+
setBulkEditDraftValue(null);
|
|
1534
|
+
},
|
|
1535
|
+
[onBulkEdit, closeCellEdit, closeFilterChipPopups],
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
const handleBulkFieldClick = useCallback(
|
|
1539
|
+
(fieldId, field, e) => {
|
|
1540
|
+
if (onBulkBarFieldClick) {
|
|
1541
|
+
onBulkBarFieldClick(fieldId, field, e);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
openBulkEditPopup(field, e);
|
|
1545
|
+
},
|
|
1546
|
+
[onBulkBarFieldClick, openBulkEditPopup],
|
|
1547
|
+
);
|
|
1548
|
+
|
|
1549
|
+
const handleBulkEditApply = useCallback(
|
|
1550
|
+
(nextValue) => {
|
|
1551
|
+
if (!bulkEdit || !onBulkEdit) return;
|
|
1552
|
+
const selectedRows = getSelectedRows();
|
|
1553
|
+
if (selectedRows.length === 0) {
|
|
1554
|
+
closeBulkEdit();
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const payload = buildBulkEditPayload({
|
|
1558
|
+
column: bulkEdit.column,
|
|
1559
|
+
columnKey: bulkEdit.fieldId,
|
|
1560
|
+
value: nextValue,
|
|
1561
|
+
rows: selectedRows,
|
|
1562
|
+
getRowId,
|
|
1563
|
+
});
|
|
1564
|
+
onBulkEdit(payload);
|
|
1565
|
+
closeBulkEdit();
|
|
1566
|
+
},
|
|
1567
|
+
[bulkEdit, onBulkEdit, getSelectedRows, closeBulkEdit],
|
|
1568
|
+
);
|
|
1569
|
+
|
|
1570
|
+
const handleCellClick = (e, column, row) => {
|
|
1571
|
+
if (!isColumnCellEditable(column, onCellEdit)) return;
|
|
1572
|
+
e.stopPropagation();
|
|
1573
|
+
onCellEditStart?.(column, row);
|
|
1574
|
+
const anchor =
|
|
1575
|
+
e.currentTarget.closest(".mdt-cell-editable-wrap, .mdt-list-chip-edit-anchor")
|
|
1576
|
+
?.getBoundingClientRect()
|
|
1577
|
+
?? e.currentTarget.getBoundingClientRect();
|
|
1578
|
+
const anchorRect = {
|
|
1579
|
+
top: anchor.top,
|
|
1580
|
+
left: anchor.left,
|
|
1581
|
+
right: anchor.right,
|
|
1582
|
+
bottom: anchor.bottom,
|
|
1583
|
+
width: anchor.width,
|
|
1584
|
+
height: anchor.height,
|
|
1585
|
+
};
|
|
1586
|
+
const position = computeCellEditPopupPosition(anchorRect, column, EDIT_MODE.INLINE);
|
|
1587
|
+
setCellEdit({
|
|
1588
|
+
rowId: getRowId(row),
|
|
1589
|
+
row,
|
|
1590
|
+
column,
|
|
1591
|
+
columnKey: column.accessorKey,
|
|
1592
|
+
anchorRect,
|
|
1593
|
+
width: position.width,
|
|
1594
|
+
});
|
|
1595
|
+
setCellEditPopupPos(position);
|
|
1596
|
+
setCellEditDraftValue(getRowFieldEditValue(column, row));
|
|
1597
|
+
closeBulkEdit();
|
|
1598
|
+
closeFilterChipPopups();
|
|
1599
|
+
setOpenPopup(null);
|
|
1600
|
+
closeRowMenu();
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
const handleCellEditApply = (nextValue) => {
|
|
1604
|
+
if (!cellEdit || !onCellEdit) return;
|
|
1605
|
+
const col = liveCellEditColumn ?? cellEdit.column;
|
|
1606
|
+
const resolved = resolveEditColumnConfig(col, EDIT_MODE.INLINE);
|
|
1607
|
+
const normalized = normalizeEditApplyValue(col, nextValue, EDIT_MODE.INLINE);
|
|
1608
|
+
const isMulti = isColumnSelectMulti(resolved, EDIT_MODE.INLINE);
|
|
1609
|
+
const previous = getRowFieldEditValue(col, cellEdit.row, EDIT_MODE.INLINE);
|
|
1610
|
+
onCellEdit({
|
|
1611
|
+
action: "inlineUpdate",
|
|
1612
|
+
rowId: cellEdit.rowId,
|
|
1613
|
+
row: cellEdit.row,
|
|
1614
|
+
columnKey: col.accessorKey,
|
|
1615
|
+
key: col.accessorKey,
|
|
1616
|
+
column: col,
|
|
1617
|
+
variant: getColumnFieldVariant(resolved, EDIT_MODE.INLINE),
|
|
1618
|
+
multi: isMulti,
|
|
1619
|
+
value: normalized,
|
|
1620
|
+
previousValue: previous,
|
|
1621
|
+
previousValueKey:
|
|
1622
|
+
col.editValueKey != null ? cellEdit.row[col.editValueKey] : undefined,
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
if (isMulti) {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
closeCellEdit();
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
const hasAnyFilterValue = activeFilters.some((f) => filterValueIsSet(f));
|
|
1632
|
+
|
|
1633
|
+
const availableForDropdown = (search) => {
|
|
1634
|
+
const q = search.toLowerCase();
|
|
1635
|
+
return filterableColumns.filter((col) => {
|
|
1636
|
+
if (!col.header.toLowerCase().includes(q)) return false;
|
|
1637
|
+
return !activeFilters.find((f) => f.key === col.accessorKey);
|
|
1638
|
+
});
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
const filterBarProps = {
|
|
1642
|
+
filterConfig: resolvedFilterConfig,
|
|
1643
|
+
filterableColumns,
|
|
1644
|
+
activeFilters,
|
|
1645
|
+
replacingFilterId,
|
|
1646
|
+
setReplacingFilterId,
|
|
1647
|
+
replaceSearch,
|
|
1648
|
+
setReplaceSearch,
|
|
1649
|
+
valueEditId,
|
|
1650
|
+
setValueEditId,
|
|
1651
|
+
valueInputText,
|
|
1652
|
+
setValueInputText,
|
|
1653
|
+
valueSelectSearch,
|
|
1654
|
+
setValueSelectSearch,
|
|
1655
|
+
showInnerFilterDropdown,
|
|
1656
|
+
setShowInnerFilterDropdown,
|
|
1657
|
+
innerFilterSearch,
|
|
1658
|
+
setInnerFilterSearch,
|
|
1659
|
+
calYear,
|
|
1660
|
+
calMonth,
|
|
1661
|
+
setCalYear,
|
|
1662
|
+
setCalMonth,
|
|
1663
|
+
today,
|
|
1664
|
+
chipReplaceRef,
|
|
1665
|
+
chipValueRef,
|
|
1666
|
+
innerFilterRef,
|
|
1667
|
+
onRemoveFilter: removeFilter,
|
|
1668
|
+
onClearAll: clearAllFilters,
|
|
1669
|
+
onAddFilter: addFilter,
|
|
1670
|
+
onUpdateFilterValue: updateFilterValue,
|
|
1671
|
+
onUpdateFilterOperator: updateFilterOperator,
|
|
1672
|
+
onReplaceFilterKey,
|
|
1673
|
+
availableForDropdown,
|
|
1674
|
+
onCloseOtherPopups: closeFilterBarPopups,
|
|
1675
|
+
onCloseColumnSortPopup: () => setOpenPopup(null),
|
|
1676
|
+
hasAnyFilterValue,
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
const displayPopupProps = {
|
|
1680
|
+
visibleColumns,
|
|
1681
|
+
displayPropertyColumns,
|
|
1682
|
+
hiddenColumns,
|
|
1683
|
+
onToggleColumn: toggleColumnVisibility,
|
|
1684
|
+
groupByColumns,
|
|
1685
|
+
groupByKey,
|
|
1686
|
+
onGroupByChange: setGroupByKey,
|
|
1687
|
+
sortState,
|
|
1688
|
+
orderDirEffective,
|
|
1689
|
+
onOrderFromDisplay: setOrderFromDisplay,
|
|
1690
|
+
displayPropsOpen,
|
|
1691
|
+
onToggleDisplayPropsOpen: () => setDisplayPropsOpen((v) => !v),
|
|
1692
|
+
orderByOpen,
|
|
1693
|
+
onToggleOrderByOpen: () => setOrderByOpen((v) => !v),
|
|
1694
|
+
groupByOpen,
|
|
1695
|
+
onToggleGroupByOpen: () => setGroupByOpen((v) => !v),
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
const filtersWithValues = activeFilters
|
|
1699
|
+
.filter((f) => filterValueIsSet(f))
|
|
1700
|
+
.map(({ id, key, label, icon, value, operator }) => ({
|
|
1701
|
+
id,
|
|
1702
|
+
key,
|
|
1703
|
+
label,
|
|
1704
|
+
icon,
|
|
1705
|
+
value,
|
|
1706
|
+
operator,
|
|
1707
|
+
}));
|
|
1708
|
+
|
|
1709
|
+
const serializeSavedViewFilterValue = (value) => {
|
|
1710
|
+
if (Array.isArray(value)) {
|
|
1711
|
+
return value.map((v) => String(v)).join(",");
|
|
1712
|
+
}
|
|
1713
|
+
if (value == null) return "";
|
|
1714
|
+
return String(value);
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
const editingView = React.useMemo(
|
|
1718
|
+
() => savedViews.find((v) => v.id === editingViewId) ?? null,
|
|
1719
|
+
[savedViews, editingViewId],
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
const buildViewPayload = (payload) => {
|
|
1723
|
+
const savedFilterSnapshot = activeFilters
|
|
1724
|
+
.map((f) => {
|
|
1725
|
+
const withValue = (payload.filters || filtersWithValues).find(
|
|
1726
|
+
(v) => v.key === f.key || v.id === f.id,
|
|
1727
|
+
);
|
|
1728
|
+
return {
|
|
1729
|
+
key: f.key,
|
|
1730
|
+
label: f.label,
|
|
1731
|
+
icon: f.icon ?? null,
|
|
1732
|
+
value: withValue?.value ?? f.value,
|
|
1733
|
+
operator: f.operator ?? withValue?.operator,
|
|
1734
|
+
};
|
|
1735
|
+
})
|
|
1736
|
+
.filter((f) => filterValueIsSet(f.value));
|
|
1737
|
+
|
|
1738
|
+
const dedupedFilters = savedFilterSnapshot.reduce((acc, filter) => {
|
|
1739
|
+
const existing = acc.find((item) => item.key === filter.key);
|
|
1740
|
+
if (!existing) {
|
|
1741
|
+
acc.push(filter);
|
|
1742
|
+
return acc;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const existingValues = Array.isArray(existing.value)
|
|
1746
|
+
? existing.value
|
|
1747
|
+
: existing.value != null && existing.value !== ""
|
|
1748
|
+
? [existing.value]
|
|
1749
|
+
: [];
|
|
1750
|
+
const incomingValues = Array.isArray(filter.value)
|
|
1751
|
+
? filter.value
|
|
1752
|
+
: filter.value != null && filter.value !== ""
|
|
1753
|
+
? [filter.value]
|
|
1754
|
+
: [];
|
|
1755
|
+
const mergedValues = Array.from(
|
|
1756
|
+
new Set([...existingValues, ...incomingValues].map((v) => String(v).trim()).filter(Boolean)),
|
|
1757
|
+
);
|
|
1758
|
+
existing.value = mergedValues.length > 1 ? mergedValues : (mergedValues[0] ?? "");
|
|
1759
|
+
existing.label = filter.label ?? existing.label;
|
|
1760
|
+
existing.icon = filter.icon ?? existing.icon;
|
|
1761
|
+
existing.operator = filter.operator ?? existing.operator;
|
|
1762
|
+
return acc;
|
|
1763
|
+
}, []);
|
|
1764
|
+
|
|
1765
|
+
return {
|
|
1766
|
+
pageKey: savedViewPageKey,
|
|
1767
|
+
name: payload.name?.trim() || "Untitled",
|
|
1768
|
+
description: payload.description?.trim() || "",
|
|
1769
|
+
viewMode: payload.viewMode === "list" ? "list" : "table",
|
|
1770
|
+
filters: dedupedFilters.map((f) => ({
|
|
1771
|
+
id: f.key ?? f.id,
|
|
1772
|
+
value: serializeSavedViewFilterValue(f.value),
|
|
1773
|
+
})),
|
|
1774
|
+
hiddenColumns: hiddenColumns.filter((k) => !lockedColumnSet.has(k)),
|
|
1775
|
+
groupByKey,
|
|
1776
|
+
sort: sortState.column
|
|
1777
|
+
? { column: sortState.column, direction: sortState.direction }
|
|
1778
|
+
: null,
|
|
1779
|
+
isPublic: Boolean(payload.isPublic),
|
|
1780
|
+
};
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
const handleOpenCreateViewModal = () => {
|
|
1784
|
+
captureModalFilterSnapshot();
|
|
1785
|
+
setEditingViewId(null);
|
|
1786
|
+
setIsFilterBarOpen(true);
|
|
1787
|
+
setShowDisplayPopup(false);
|
|
1788
|
+
setShowCreateViewModal(true);
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
const handleEditSavedView = (viewId) => {
|
|
1792
|
+
const view = savedViews.find((v) => v.id === viewId);
|
|
1793
|
+
if (!view) return;
|
|
1794
|
+
applySavedView(view, { notify: false });
|
|
1795
|
+
captureModalFilterSnapshot();
|
|
1796
|
+
setEditingViewId(viewId);
|
|
1797
|
+
setShowDisplayPopup(false);
|
|
1798
|
+
setShowCreateViewModal(true);
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
const handleCloseCreateViewModal = () => {
|
|
1802
|
+
restoreModalFilterSnapshot();
|
|
1803
|
+
closeFilterChipPopups();
|
|
1804
|
+
setEditingViewId(null);
|
|
1805
|
+
setShowCreateViewModal(false);
|
|
1806
|
+
};
|
|
1807
|
+
|
|
1808
|
+
const handleDeleteSavedView = useCallback(async () => {
|
|
1809
|
+
if (!editingViewId) return;
|
|
1810
|
+
try {
|
|
1811
|
+
await apiService.DELETE(API.DELETE_SAVED_VIEW(editingViewId));
|
|
1812
|
+
const deletingActive = activeSavedViewId === editingViewId;
|
|
1813
|
+
setSavedViews((prev) => prev.filter((v) => v.id !== editingViewId));
|
|
1814
|
+
clearModalFilterSnapshot();
|
|
1815
|
+
setEditingViewId(null);
|
|
1816
|
+
closeFilterChipPopups();
|
|
1817
|
+
setShowCreateViewModal(false);
|
|
1818
|
+
if (deletingActive) {
|
|
1819
|
+
applySavedView(null, { notify: false });
|
|
1820
|
+
const fallbackTabId = activeTab || tabs[0]?.id;
|
|
1821
|
+
if (fallbackTabId && onTabChange) {
|
|
1822
|
+
onTabChange(fallbackTabId, { clearFilters: true });
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
console.error("Failed to delete saved view", error);
|
|
1827
|
+
throw error;
|
|
1828
|
+
}
|
|
1829
|
+
}, [
|
|
1830
|
+
editingViewId,
|
|
1831
|
+
activeSavedViewId,
|
|
1832
|
+
activeTab,
|
|
1833
|
+
tabs,
|
|
1834
|
+
applySavedView,
|
|
1835
|
+
onTabChange,
|
|
1836
|
+
closeFilterChipPopups,
|
|
1837
|
+
]);
|
|
1838
|
+
|
|
1839
|
+
const handleSaveView = async (payload) => {
|
|
1840
|
+
const viewPayload = buildViewPayload(payload);
|
|
1841
|
+
try {
|
|
1842
|
+
const response = editingViewId
|
|
1843
|
+
? await apiService.PUT(API.PUT_SAVED_VIEW(editingViewId), viewPayload)
|
|
1844
|
+
: await apiService.POST(API.POST_SAVED_VIEW, viewPayload);
|
|
1845
|
+
const nextView = {
|
|
1846
|
+
...normalizeSavedView(response?.data ?? response),
|
|
1847
|
+
count: null,
|
|
1848
|
+
};
|
|
1849
|
+
setSavedViews((prev) =>
|
|
1850
|
+
editingViewId
|
|
1851
|
+
? prev.map((v) => (v.id === editingViewId ? nextView : v))
|
|
1852
|
+
: [...prev, nextView],
|
|
1853
|
+
);
|
|
1854
|
+
setActiveSavedViewId(nextView.id);
|
|
1855
|
+
applySavedView(nextView, { notify: true });
|
|
1856
|
+
clearModalFilterSnapshot();
|
|
1857
|
+
setEditingViewId(null);
|
|
1858
|
+
closeFilterChipPopups();
|
|
1859
|
+
setShowCreateViewModal(false);
|
|
1860
|
+
applySavedViewCounts();
|
|
1861
|
+
if (onCreateView) onCreateView(nextView);
|
|
1862
|
+
else if (onSaveFiltersAs && !editingViewId) {
|
|
1863
|
+
onSaveFiltersAs(nextView.filters, nextView.name);
|
|
1864
|
+
}
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
console.error("Failed to save view", error);
|
|
1867
|
+
throw error;
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// ── Sort helpers ─────────────────────────────────────────────────────────────
|
|
1872
|
+
|
|
1873
|
+
const handleHeaderClick = (accessorKey) => {
|
|
1874
|
+
setShowFilterDropdown(false);
|
|
1875
|
+
setShowDisplayPopup(false);
|
|
1876
|
+
setShowInnerFilterDropdown(false);
|
|
1877
|
+
setOpenPopup((prev) => (prev === accessorKey ? null : accessorKey));
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
const handleSort = (dir) => { setSortState({ column: openPopup, direction: dir }); setOpenPopup(null); };
|
|
1881
|
+
const handleClearSort = () => { setSortState({ column: null, direction: null }); setOpenPopup(null); };
|
|
1882
|
+
|
|
1883
|
+
const renderSortPopupContent = (column) => {
|
|
1884
|
+
const isDate =
|
|
1885
|
+
getColumnFieldVariant(column, EDIT_MODE.FILTER) === "date" ||
|
|
1886
|
+
column.headerIcon === "calendar" ||
|
|
1887
|
+
column.rowIcon === "calendar";
|
|
1888
|
+
return (
|
|
1889
|
+
<>
|
|
1890
|
+
<button type="button" className="mdt-sort-option" onClick={() => handleSort("asc")}>
|
|
1891
|
+
<ArrowUpNarrowWide size={12} className="mdt-sort-option-icon" />
|
|
1892
|
+
<span>{isDate ? "New → Old" : "A → Z"}</span>
|
|
1893
|
+
{sortState.column === column.accessorKey && sortState.direction === "asc" && (
|
|
1894
|
+
<Check size={11} className="mdt-sort-check" />
|
|
1895
|
+
)}
|
|
1896
|
+
</button>
|
|
1897
|
+
<button type="button" className="mdt-sort-option" onClick={() => handleSort("desc")}>
|
|
1898
|
+
<ArrowDownWideNarrow size={12} className="mdt-sort-option-icon" />
|
|
1899
|
+
<span>{isDate ? "Old → New" : "Z → A"}</span>
|
|
1900
|
+
{sortState.column === column.accessorKey && sortState.direction === "desc" && (
|
|
1901
|
+
<Check size={11} className="mdt-sort-check" />
|
|
1902
|
+
)}
|
|
1903
|
+
</button>
|
|
1904
|
+
{sortState.column === column.accessorKey && (
|
|
1905
|
+
<button type="button" className="mdt-sort-option mdt-sort-clear" onClick={handleClearSort}>
|
|
1906
|
+
<Eraser size={12} className="mdt-sort-option-icon" />
|
|
1907
|
+
<span>Clear sorting</span>
|
|
1908
|
+
</button>
|
|
1909
|
+
)}
|
|
1910
|
+
</>
|
|
1911
|
+
);
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
const SORT_COLOR = "oklch(0.4377 0.0066 230.87)";
|
|
1915
|
+
const getSortIcon = (accessorKey) => {
|
|
1916
|
+
const isSorted = sortState.column === accessorKey;
|
|
1917
|
+
if (!isSorted) return <ChevronDown className="mdt-header-chevron" size={14} />;
|
|
1918
|
+
const SortIcon = sortState.direction === "asc" ? ArrowUpNarrowWide : ArrowDownWideNarrow;
|
|
1919
|
+
return (
|
|
1920
|
+
<span className="mdt-header-icons-sorted">
|
|
1921
|
+
<SortIcon size={14} style={{ color: SORT_COLOR }} />
|
|
1922
|
+
<ChevronDown size={14} style={{ color: SORT_COLOR }} />
|
|
1923
|
+
</span>
|
|
1924
|
+
);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
// ── Cell renderer ─────────────────────────────────────────────────────────────
|
|
1928
|
+
|
|
1929
|
+
const getCellTooltipText = (column, row) => getColumnTooltipText(column, row);
|
|
1930
|
+
|
|
1931
|
+
const getTableEmptyPlaceholder = (column, row) => {
|
|
1932
|
+
if (column?.emptyPlaceholder === false) return "-";
|
|
1933
|
+
if (typeof column?.emptyPlaceholder === "function") {
|
|
1934
|
+
return column.emptyPlaceholder({ column, row });
|
|
1935
|
+
}
|
|
1936
|
+
if (typeof column?.emptyPlaceholder === "string" && column.emptyPlaceholder.trim() !== "") {
|
|
1937
|
+
return column.emptyPlaceholder;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const variant = getColumnFieldVariant(column, EDIT_MODE.INLINE);
|
|
1941
|
+
const header = String(column?.header || "Value").trim();
|
|
1942
|
+
|
|
1943
|
+
if (variant === "select" || variant === "boolean") {
|
|
1944
|
+
return column?.editable ? `Select ${header}` : header;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
return header || "Value";
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
const wrapCellTruncate = (content, tooltipText, { skip = false } = {}) => {
|
|
1951
|
+
if (skip) return content;
|
|
1952
|
+
return (
|
|
1953
|
+
<MdtTruncateWithTooltip title={tooltipText || undefined}>
|
|
1954
|
+
{content}
|
|
1955
|
+
</MdtTruncateWithTooltip>
|
|
1956
|
+
);
|
|
1957
|
+
};
|
|
1958
|
+
|
|
1959
|
+
const renderCell = (column, row, { editable = false, onCellActivate } = {}) => {
|
|
1960
|
+
const value = row?.[column.accessorKey];
|
|
1961
|
+
const useMultiSummary = shouldUseMultiSelectSummary(column);
|
|
1962
|
+
const hasCustomCell = hasCustomColumnCell(column);
|
|
1963
|
+
const tooltipText = getCellTooltipText(column, row);
|
|
1964
|
+
const isEmptyValue =
|
|
1965
|
+
value == null ||
|
|
1966
|
+
value === "" ||
|
|
1967
|
+
value === "-" ||
|
|
1968
|
+
(Array.isArray(value) && value.length === 0);
|
|
1969
|
+
const rowIconName = getColumnRowIconName(column, {
|
|
1970
|
+
hasCustomCell,
|
|
1971
|
+
isMultiSummary: useMultiSummary,
|
|
1972
|
+
});
|
|
1973
|
+
const rowIcon = rowIconName
|
|
1974
|
+
? renderColumnIcon(rowIconName, { className: "mdt-row-icon" })
|
|
1975
|
+
: null;
|
|
1976
|
+
let customCell;
|
|
1977
|
+
if (useMultiSummary) {
|
|
1978
|
+
customCell = <MdtMultiSelectCellDisplay row={row} column={column} />;
|
|
1979
|
+
} else if (typeof column.Cell === "function") {
|
|
1980
|
+
customCell = column.Cell({
|
|
1981
|
+
cell: { getValue: () => value },
|
|
1982
|
+
row: { original: row },
|
|
1983
|
+
});
|
|
1984
|
+
} else if (isEmptyValue) {
|
|
1985
|
+
customCell = getTableEmptyPlaceholder(column, row);
|
|
1986
|
+
} else if (hasStatusValueRenderer(column)) {
|
|
1987
|
+
customCell = renderStatusValueDisplay(value, column) ?? (value ?? "-");
|
|
1988
|
+
} else {
|
|
1989
|
+
customCell = value ?? "-";
|
|
1990
|
+
}
|
|
1991
|
+
let inner = useMultiSummary ? (
|
|
1992
|
+
customCell
|
|
1993
|
+
) : !rowIcon ? (
|
|
1994
|
+
customCell
|
|
1995
|
+
) : (
|
|
1996
|
+
<span className="mdt-cell-with-icon">
|
|
1997
|
+
{rowIcon}
|
|
1998
|
+
<span className="mdt-cell-with-icon-text">{customCell}</span>
|
|
1999
|
+
</span>
|
|
2000
|
+
);
|
|
2001
|
+
const skipTruncate =
|
|
2002
|
+
useMultiSummary
|
|
2003
|
+
|| (hasCustomCell && column.accessorKey !== "title" && !column.cellTruncatable);
|
|
2004
|
+
inner = wrapCellTruncate(inner, tooltipText, { skip: skipTruncate });
|
|
2005
|
+
if (!editable || !onCellActivate) return inner;
|
|
2006
|
+
return (
|
|
2007
|
+
<button
|
|
2008
|
+
type="button"
|
|
2009
|
+
className="mdt-cell-editable-trigger"
|
|
2010
|
+
onClick={(e) => onCellActivate(e, column, row)}
|
|
2011
|
+
title={`Edit ${column.header}`}
|
|
2012
|
+
>
|
|
2013
|
+
{inner}
|
|
2014
|
+
</button>
|
|
2015
|
+
);
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// ── Column sizing ────────────────────────────────────────────────────────────
|
|
2019
|
+
|
|
2020
|
+
const toPx = (v) => {
|
|
2021
|
+
if (v === null || v === undefined || v === "") return null;
|
|
2022
|
+
if (typeof v === "number") return `${v}px`;
|
|
2023
|
+
const t = String(v).trim();
|
|
2024
|
+
if (!t) return null;
|
|
2025
|
+
return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t;
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
const getColumnWidthPx = (col) => {
|
|
2029
|
+
const n = parseFloat(String(toPx(col.size)));
|
|
2030
|
+
return !isNaN(n) && n > 0 ? n : 120;
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Data columns: exact min width from col.size (px).
|
|
2035
|
+
* Last data column is width:auto so it takes leftover table space (menu col is separate).
|
|
2036
|
+
*/
|
|
2037
|
+
const getDataColumnWidthStyle = (col, colIndex) => {
|
|
2038
|
+
const px = getColumnWidthPx(col);
|
|
2039
|
+
const minW = `${px}px`;
|
|
2040
|
+
const isLastDataCol = colIndex === renderedColumns.length - 1;
|
|
2041
|
+
|
|
2042
|
+
if (isLastDataCol) {
|
|
2043
|
+
return { width: "auto", minWidth: minW };
|
|
2044
|
+
}
|
|
2045
|
+
return { width: minW, minWidth: minW, maxWidth: minW };
|
|
2046
|
+
};
|
|
2047
|
+
|
|
2048
|
+
const getColumnStyle = (col, colIndex) => getDataColumnWidthStyle(col, colIndex);
|
|
2049
|
+
|
|
2050
|
+
/** col element — data columns only */
|
|
2051
|
+
const getDataColWidth = (col, colIndex) => getDataColumnWidthStyle(col, colIndex);
|
|
2052
|
+
|
|
2053
|
+
const calcTableMinWidth = () => {
|
|
2054
|
+
let total = showRowMenuColumn ? ROW_MENU_COL_WIDTH : 0;
|
|
2055
|
+
renderedColumns.forEach((col) => {
|
|
2056
|
+
total += getColumnWidthPx(col);
|
|
2057
|
+
});
|
|
2058
|
+
return total > 0 ? `${total}px` : undefined;
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
/** Hard-coded 48px — only on options column th/td (not data columns) */
|
|
2062
|
+
const OPTIONS_COL_STYLE = {
|
|
2063
|
+
width: "48px",
|
|
2064
|
+
minWidth: "48px",
|
|
2065
|
+
maxWidth: "48px",
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
const closeRowMenu = () => {
|
|
2069
|
+
setOpenRowMenuKey(null);
|
|
2070
|
+
setOpenRowMenuRow(null);
|
|
2071
|
+
setRowMenuPosition(null);
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
const handleOpenRowMenu = (rowId, row, position) => {
|
|
2075
|
+
setOpenRowMenuKey(rowId);
|
|
2076
|
+
setOpenRowMenuRow(row);
|
|
2077
|
+
setRowMenuPosition(position);
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
const renderRowMenuCell = (row) => {
|
|
2081
|
+
const id = getRowId(row);
|
|
2082
|
+
if (!id) return null;
|
|
2083
|
+
|
|
2084
|
+
const isOpen = openRowMenuKey === id;
|
|
2085
|
+
|
|
2086
|
+
return (
|
|
2087
|
+
<div
|
|
2088
|
+
className={`mdt-row-menu-cell${isOpen ? " mdt-row-menu-cell-open" : ""}`}
|
|
2089
|
+
onClick={(e) => e.stopPropagation()}
|
|
2090
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2091
|
+
>
|
|
2092
|
+
<MdtRowActionsMenu
|
|
2093
|
+
variant="table"
|
|
2094
|
+
row={isOpen ? openRowMenuRow ?? row : row}
|
|
2095
|
+
rowId={id}
|
|
2096
|
+
isOpen={isOpen}
|
|
2097
|
+
menuPosition={isOpen ? rowMenuPosition : null}
|
|
2098
|
+
onOpen={handleOpenRowMenu}
|
|
2099
|
+
onClose={closeRowMenu}
|
|
2100
|
+
onRowEdit={onRowEdit}
|
|
2101
|
+
onRowDownload={onRowDownload}
|
|
2102
|
+
onRowDelete={onRowDelete}
|
|
2103
|
+
rowMenuActions={rowMenuActions}
|
|
2104
|
+
deleteConfirmConfig={deleteConfirmConfig}
|
|
2105
|
+
isDeletable={rowCanDelete(row)}
|
|
2106
|
+
/>
|
|
2107
|
+
</div>
|
|
2108
|
+
);
|
|
2109
|
+
};
|
|
2110
|
+
|
|
2111
|
+
const showNewButton =
|
|
2112
|
+
resolvedHeaderActions.showNew &&
|
|
2113
|
+
onPrimaryAction &&
|
|
2114
|
+
(!hideHeader || toolbarInBreadcrumb);
|
|
2115
|
+
|
|
2116
|
+
const renderNewButton = (slotClass = "") =>
|
|
2117
|
+
showNewButton ? (
|
|
2118
|
+
<button
|
|
2119
|
+
className={`mdt-primary${slotClass ? ` ${slotClass}` : ""}`}
|
|
2120
|
+
type="button"
|
|
2121
|
+
onClick={onPrimaryAction}
|
|
2122
|
+
>
|
|
2123
|
+
{primaryActionLabel}
|
|
2124
|
+
</button>
|
|
2125
|
+
) : null;
|
|
2126
|
+
|
|
2127
|
+
const [breadcrumbToolbarHost, setBreadcrumbToolbarHost] = useState(null);
|
|
2128
|
+
|
|
2129
|
+
useLayoutEffect(() => {
|
|
2130
|
+
if (!toolbarInBreadcrumb) {
|
|
2131
|
+
setBreadcrumbToolbarHost(null);
|
|
2132
|
+
return undefined;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
let cancelled = false;
|
|
2136
|
+
const resolveHost = () => {
|
|
2137
|
+
if (cancelled) return;
|
|
2138
|
+
const host = document.getElementById(breadcrumbToolbarHostId);
|
|
2139
|
+
if (host) {
|
|
2140
|
+
setBreadcrumbToolbarHost(host);
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
resolveHost();
|
|
2145
|
+
const rafId = requestAnimationFrame(() => {
|
|
2146
|
+
resolveHost();
|
|
2147
|
+
requestAnimationFrame(resolveHost);
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
return () => {
|
|
2151
|
+
cancelled = true;
|
|
2152
|
+
cancelAnimationFrame(rafId);
|
|
2153
|
+
};
|
|
2154
|
+
}, [toolbarInBreadcrumb, breadcrumbToolbarHostId, toolbarHostSeed]);
|
|
2155
|
+
|
|
2156
|
+
const renderToolbarActions = () => (
|
|
2157
|
+
<div className={`mdt-actions-right${toolbarInBreadcrumb ? " mdt-actions-right--in-breadcrumb" : ""}`}>
|
|
2158
|
+
{resolvedHeaderActions.showLayoutSwitch && !lockedViewMode ? (
|
|
2159
|
+
<div className="mdt-layout-switch" role="tablist" aria-label="Table mode">
|
|
2160
|
+
<button
|
|
2161
|
+
type="button"
|
|
2162
|
+
title="List layout"
|
|
2163
|
+
className={viewMode === "list" ? "mdt-layout-active" : ""}
|
|
2164
|
+
onClick={() => setViewMode("list")}
|
|
2165
|
+
>
|
|
2166
|
+
<Menu size={16} />
|
|
2167
|
+
</button>
|
|
2168
|
+
<button
|
|
2169
|
+
type="button"
|
|
2170
|
+
title="Spreadsheet layout"
|
|
2171
|
+
className={viewMode === "table" ? "mdt-layout-active" : ""}
|
|
2172
|
+
onClick={() => setViewMode("table")}
|
|
2173
|
+
>
|
|
2174
|
+
<Layout size={16} />
|
|
2175
|
+
</button>
|
|
2176
|
+
</div>
|
|
2177
|
+
) : null}
|
|
2178
|
+
|
|
2179
|
+
{resolvedHeaderActions.showFilter && (
|
|
2180
|
+
<div className="mdt-filter-button-wrap" ref={filterDropdownRef}>
|
|
2181
|
+
<button
|
|
2182
|
+
className={`mdt-ghost icon${activeFilters.length > 0 || isFilterBarOpen ? " mdt-filter-active" : ""}`}
|
|
2183
|
+
type="button"
|
|
2184
|
+
title={resolvedFilterConfig.toolbarButtonTitle}
|
|
2185
|
+
onClick={() => {
|
|
2186
|
+
if (isFilterBarOpen && activeFilters.length > 0) {
|
|
2187
|
+
setIsFilterBarOpen(false);
|
|
2188
|
+
setOpenPopup(null);
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
if (activeFilters.length > 0) {
|
|
2192
|
+
setIsFilterBarOpen(true);
|
|
2193
|
+
setOpenPopup(null);
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
setOpenPopup(null);
|
|
2197
|
+
setShowDisplayPopup(false);
|
|
2198
|
+
setShowFilterDropdown((v) => !v);
|
|
2199
|
+
setReplacingFilterId(null);
|
|
2200
|
+
setReplaceSearch("");
|
|
2201
|
+
}}
|
|
2202
|
+
>
|
|
2203
|
+
<ListFilter size={16} />
|
|
2204
|
+
</button>
|
|
2205
|
+
|
|
2206
|
+
{showFilterDropdown && (
|
|
2207
|
+
<div className="mdt-filter-dropdown" style={{ top: "calc(100% + 8px)" }}>
|
|
2208
|
+
<div className="mdt-fd-search">
|
|
2209
|
+
<div className="mdt-fd-search-wrap">
|
|
2210
|
+
<span className="mdt-fd-search-icon">
|
|
2211
|
+
<Search size={12} />
|
|
2212
|
+
</span>
|
|
2213
|
+
<input
|
|
2214
|
+
type="text"
|
|
2215
|
+
placeholder={resolvedFilterConfig.searchPlaceholder}
|
|
2216
|
+
value={filterSearch}
|
|
2217
|
+
onChange={(e) => setFilterSearch(e.target.value)}
|
|
2218
|
+
autoFocus
|
|
2219
|
+
/>
|
|
2220
|
+
</div>
|
|
2221
|
+
</div>
|
|
2222
|
+
<div className="mdt-fd-list">
|
|
2223
|
+
{availableForDropdown(filterSearch).map((col) => (
|
|
2224
|
+
<button
|
|
2225
|
+
key={col.accessorKey}
|
|
2226
|
+
type="button"
|
|
2227
|
+
className="mdt-fd-item"
|
|
2228
|
+
onClick={() => addFilter(col)}
|
|
2229
|
+
>
|
|
2230
|
+
<span className="mdt-fd-item-icon">
|
|
2231
|
+
{renderColumnIcon(col.headerIcon || col.rowIcon || "default", {
|
|
2232
|
+
className: "mdt-fd-svg",
|
|
2233
|
+
})}
|
|
2234
|
+
</span>
|
|
2235
|
+
<span>{col.header}</span>
|
|
2236
|
+
</button>
|
|
2237
|
+
))}
|
|
2238
|
+
{availableForDropdown(filterSearch).length === 0 && (
|
|
2239
|
+
<div className="mdt-fd-empty">
|
|
2240
|
+
{resolvedFilterConfig.emptyColumnsMessage}
|
|
2241
|
+
</div>
|
|
2242
|
+
)}
|
|
2243
|
+
</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
)}
|
|
2246
|
+
</div>
|
|
2247
|
+
)}
|
|
2248
|
+
|
|
2249
|
+
{resolvedHeaderActions.showDisplay && (
|
|
2250
|
+
<div className="mdt-filter-button-wrap" ref={displayPopupRef}>
|
|
2251
|
+
<button
|
|
2252
|
+
className={`mdt-ghost icon mdt-display-btn${showDisplayPopup ? " mdt-filter-active" : ""}`}
|
|
2253
|
+
type="button"
|
|
2254
|
+
title="Display"
|
|
2255
|
+
onClick={() => {
|
|
2256
|
+
setOpenPopup(null);
|
|
2257
|
+
setShowFilterDropdown(false);
|
|
2258
|
+
setShowDisplayPopup((v) => !v);
|
|
2259
|
+
}}
|
|
2260
|
+
>
|
|
2261
|
+
<SlidersHorizontal size={16} />
|
|
2262
|
+
</button>
|
|
2263
|
+
|
|
2264
|
+
{showDisplayPopup && (
|
|
2265
|
+
<MdtDisplayPopup
|
|
2266
|
+
{...displayPopupProps}
|
|
2267
|
+
viewMode={viewMode}
|
|
2268
|
+
/>
|
|
2269
|
+
)}
|
|
2270
|
+
</div>
|
|
2271
|
+
)}
|
|
2272
|
+
|
|
2273
|
+
{resolvedHeaderActions.showExport && onExport && (
|
|
2274
|
+
<div className="mdt-toolbar-cell">
|
|
2275
|
+
<button
|
|
2276
|
+
className="mdt-ghost icon mdt-export-btn"
|
|
2277
|
+
type="button"
|
|
2278
|
+
title="Export"
|
|
2279
|
+
onClick={() =>
|
|
2280
|
+
onExport({
|
|
2281
|
+
// Match show/hide column (Display) settings in the Excel export.
|
|
2282
|
+
columns: renderedColumns,
|
|
2283
|
+
hiddenColumns,
|
|
2284
|
+
// Flat rows currently shown (Group by is client-side only).
|
|
2285
|
+
rows: sortedRows,
|
|
2286
|
+
groupByKey,
|
|
2287
|
+
})
|
|
2288
|
+
}
|
|
2289
|
+
>
|
|
2290
|
+
<Download size={16} />
|
|
2291
|
+
</button>
|
|
2292
|
+
</div>
|
|
2293
|
+
)}
|
|
2294
|
+
|
|
2295
|
+
{renderNewButton(toolbarInBreadcrumb ? "" : "mdt-new-desktop")}
|
|
2296
|
+
</div>
|
|
2297
|
+
);
|
|
2298
|
+
|
|
2299
|
+
const toolbarActionsNode = renderToolbarActions();
|
|
2300
|
+
const portaledToolbar =
|
|
2301
|
+
toolbarInBreadcrumb && breadcrumbToolbarHost
|
|
2302
|
+
? createPortal(toolbarActionsNode, breadcrumbToolbarHost)
|
|
2303
|
+
: null;
|
|
2304
|
+
|
|
2305
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
2306
|
+
|
|
2307
|
+
const hasSidebarNav = Boolean(sidebarNavConfig?.items?.length);
|
|
2308
|
+
const sidebarWidth = sidebarNavConfig?.width ?? 240;
|
|
2309
|
+
const hasHeaderBreadcrumbMenu = Boolean(headerBreadcrumbMenu?.label);
|
|
2310
|
+
const isHeaderBreadcrumbMenuInteractive =
|
|
2311
|
+
hasHeaderBreadcrumbMenu && (isMobilePopupLayout() || menuToggle === false);
|
|
2312
|
+
const compactBreadcrumbsSource = Array.isArray(headerBreadcrumbs)
|
|
2313
|
+
? headerBreadcrumbs.filter(
|
|
2314
|
+
(crumb) => String(crumb?.label ?? "").trim().toLowerCase() !== "admin",
|
|
2315
|
+
)
|
|
2316
|
+
: [];
|
|
2317
|
+
const useCompactBreadcrumbs =
|
|
2318
|
+
enableCompactBreadcrumbEllipsis && isMobilePopupLayout() && compactBreadcrumbsSource.length > 0;
|
|
2319
|
+
|
|
2320
|
+
return (
|
|
2321
|
+
<div
|
|
2322
|
+
className={`mdt-page-shell${enableBulkSelection && hasSelection ? " mdt-page-shell--bulk-bar" : ""}`}
|
|
2323
|
+
>
|
|
2324
|
+
<div className="mdt-root">
|
|
2325
|
+
{portaledToolbar}
|
|
2326
|
+
|
|
2327
|
+
{/* ── Top action bar: hidden when toolbar is in global breadcrumb ── */}
|
|
2328
|
+
{!toolbarInBreadcrumb && (
|
|
2329
|
+
<div className="mdt-breadcrumb-row">
|
|
2330
|
+
<div className="mdt-breadcrumb-left">
|
|
2331
|
+
{(onSidebarToggle && (menuToggle === false || showSidebarToggleAlways)) && (
|
|
2332
|
+
<div
|
|
2333
|
+
className="mdt-sidebar-toggle"
|
|
2334
|
+
title={menuToggle === false ? "Show Sidebar" : "Toggle Sidebar"}
|
|
2335
|
+
onClick={onSidebarToggle}
|
|
2336
|
+
>
|
|
2337
|
+
<ChevronsLeft size={18} />
|
|
2338
|
+
</div>
|
|
2339
|
+
)}
|
|
2340
|
+
{!hideHeader && Array.isArray(headerBreadcrumbs) && headerBreadcrumbs.length > 0 ? (
|
|
2341
|
+
<span className="mdt-bc-crumbs" aria-label="Breadcrumb">
|
|
2342
|
+
{useCompactBreadcrumbs ? (
|
|
2343
|
+
<>
|
|
2344
|
+
<span className="mdt-bc-ellipsis" aria-hidden="true">
|
|
2345
|
+
<MoreHorizontal size={16} />
|
|
2346
|
+
</span>
|
|
2347
|
+
</>
|
|
2348
|
+
) : (
|
|
2349
|
+
headerBreadcrumbs.map((crumb, index) => (
|
|
2350
|
+
<React.Fragment key={`${crumb?.label || "crumb"}-${index}`}>
|
|
2351
|
+
{index > 0 && <span className="mdt-bc-separator">›</span>}
|
|
2352
|
+
<span className="mdt-bc-crumb">
|
|
2353
|
+
{crumb?.icon ? <span className="mdt-bc-crumb-icon">{crumb.icon}</span> : null}
|
|
2354
|
+
<span className="mdt-bc-crumb-label">{crumb?.label}</span>
|
|
2355
|
+
</span>
|
|
2356
|
+
</React.Fragment>
|
|
2357
|
+
))
|
|
2358
|
+
)}
|
|
2359
|
+
{hasHeaderBreadcrumbMenu ? (
|
|
2360
|
+
<>
|
|
2361
|
+
<span className="mdt-bc-separator">›</span>
|
|
2362
|
+
<span className="mdt-bc-menu-wrap" ref={headerBreadcrumbMenuRef}>
|
|
2363
|
+
{isHeaderBreadcrumbMenuInteractive ? (
|
|
2364
|
+
<>
|
|
2365
|
+
<button
|
|
2366
|
+
type="button"
|
|
2367
|
+
className={`mdt-bc-menu-btn${showHeaderBreadcrumbMenu ? " mdt-bc-menu-btn--open" : ""}`}
|
|
2368
|
+
onClick={() => setShowHeaderBreadcrumbMenu((prev) => !prev)}
|
|
2369
|
+
>
|
|
2370
|
+
<span className="mdt-bc-menu-label">{headerBreadcrumbMenu.label}</span>
|
|
2371
|
+
{headerBreadcrumbMenu.count != null && (
|
|
2372
|
+
<span className="mdt-bc-count-badge mdt-bc-count-badge--crumb">
|
|
2373
|
+
{headerBreadcrumbMenu.count}
|
|
2374
|
+
</span>
|
|
2375
|
+
)}
|
|
2376
|
+
<ChevronDown size={12} />
|
|
2377
|
+
</button>
|
|
2378
|
+
{showHeaderBreadcrumbMenu && Array.isArray(headerBreadcrumbMenu.items) && (
|
|
2379
|
+
<div className="mdt-filter-dropdown mdt-fd-left" style={{ top: "calc(100% + 8px)" }}>
|
|
2380
|
+
<div className="mdt-fd-list">
|
|
2381
|
+
{headerBreadcrumbMenu.items.map((item) => {
|
|
2382
|
+
const hasActiveKey = headerBreadcrumbMenu?.activeKey != null;
|
|
2383
|
+
const isActive = hasActiveKey
|
|
2384
|
+
? String(item?.key ?? "") === String(headerBreadcrumbMenu.activeKey)
|
|
2385
|
+
: String(item?.label ?? "") === String(headerBreadcrumbMenu?.label ?? "");
|
|
2386
|
+
|
|
2387
|
+
return (
|
|
2388
|
+
<button
|
|
2389
|
+
key={String(item?.key ?? item?.label)}
|
|
2390
|
+
type="button"
|
|
2391
|
+
className="mdt-fd-item"
|
|
2392
|
+
onClick={() => {
|
|
2393
|
+
setShowHeaderBreadcrumbMenu(false);
|
|
2394
|
+
if (headerBreadcrumbMenu?.onSelect) headerBreadcrumbMenu.onSelect(item);
|
|
2395
|
+
}}
|
|
2396
|
+
>
|
|
2397
|
+
<span style={{ flex: 1, minWidth: 0 }}>{item?.label}</span>
|
|
2398
|
+
{isActive && (
|
|
2399
|
+
<Check
|
|
2400
|
+
size={12}
|
|
2401
|
+
style={{ color: "oklch(0.2378 0.0029 230.83)", flexShrink: 0 }}
|
|
2402
|
+
/>
|
|
2403
|
+
)}
|
|
2404
|
+
</button>
|
|
2405
|
+
);
|
|
2406
|
+
})}
|
|
2407
|
+
</div>
|
|
2408
|
+
</div>
|
|
2409
|
+
)}
|
|
2410
|
+
</>
|
|
2411
|
+
) : (
|
|
2412
|
+
<span className="mdt-bc-menu-btn" aria-label={headerBreadcrumbMenu.label}>
|
|
2413
|
+
<span className="mdt-bc-menu-label">{headerBreadcrumbMenu.label}</span>
|
|
2414
|
+
{headerBreadcrumbMenu.count != null && (
|
|
2415
|
+
<span className="mdt-bc-count-badge mdt-bc-count-badge--crumb">
|
|
2416
|
+
{headerBreadcrumbMenu.count}
|
|
2417
|
+
</span>
|
|
2418
|
+
)}
|
|
2419
|
+
</span>
|
|
2420
|
+
)}
|
|
2421
|
+
</span>
|
|
2422
|
+
</>
|
|
2423
|
+
) : null}
|
|
2424
|
+
{(displayedTotalCount != null || countText) &&
|
|
2425
|
+
!(hasHeaderBreadcrumbMenu && headerBreadcrumbMenu.count != null) && (
|
|
2426
|
+
<span className="mdt-bc-count-badge">
|
|
2427
|
+
{displayedTotalCount != null ? displayedTotalCount : countText}
|
|
2428
|
+
</span>
|
|
2429
|
+
)}
|
|
2430
|
+
</span>
|
|
2431
|
+
) : !hideHeader && (
|
|
2432
|
+
<span className="mdt-bc-item">
|
|
2433
|
+
{titleIcon}
|
|
2434
|
+
<span className="mdt-bc-title">{title}</span>
|
|
2435
|
+
{(displayedTotalCount != null || countText) && (
|
|
2436
|
+
<span className="mdt-bc-count-badge">
|
|
2437
|
+
{displayedTotalCount != null ? displayedTotalCount : countText}
|
|
2438
|
+
</span>
|
|
2439
|
+
)}
|
|
2440
|
+
</span>
|
|
2441
|
+
)}
|
|
2442
|
+
{renderNewButton("mdt-new-mobile")}
|
|
2443
|
+
</div>
|
|
2444
|
+
|
|
2445
|
+
{toolbarActionsNode}
|
|
2446
|
+
</div>
|
|
2447
|
+
)}
|
|
2448
|
+
|
|
2449
|
+
<div className="mdt-content-layout">
|
|
2450
|
+
{hasSidebarNav && (
|
|
2451
|
+
<aside
|
|
2452
|
+
className="mdt-shell-side-panel"
|
|
2453
|
+
style={{ width: sidebarWidth, minWidth: sidebarWidth }}
|
|
2454
|
+
>
|
|
2455
|
+
<MdtSidebarNav
|
|
2456
|
+
items={sidebarNavConfig.items}
|
|
2457
|
+
activeKey={sidebarNavConfig.activeKey}
|
|
2458
|
+
onItemClick={sidebarNavConfig.onItemClick}
|
|
2459
|
+
/>
|
|
2460
|
+
</aside>
|
|
2461
|
+
)}
|
|
2462
|
+
<div className="mdt-content-main">
|
|
2463
|
+
|
|
2464
|
+
{/* ── Tabs: default (Active, Drafts, …) + saved views ── */}
|
|
2465
|
+
<MdtTabsSection
|
|
2466
|
+
tabs={mergedTabs}
|
|
2467
|
+
activeTabId={resolvedActiveTabId}
|
|
2468
|
+
onTabClick={handleTabClick}
|
|
2469
|
+
onEditSavedView={handleEditSavedView}
|
|
2470
|
+
tabsConfig={tabsConfig}
|
|
2471
|
+
rowClassName={
|
|
2472
|
+
viewMode === "list" &&
|
|
2473
|
+
!groupByKey &&
|
|
2474
|
+
enableBulkSelection &&
|
|
2475
|
+
hasSelection
|
|
2476
|
+
? "mdt-tabs-row--has-selection"
|
|
2477
|
+
: ""
|
|
2478
|
+
}
|
|
2479
|
+
leadingSlot={
|
|
2480
|
+
viewMode === "list" && !groupByKey && enableBulkSelection ? (
|
|
2481
|
+
<div
|
|
2482
|
+
className="mdt-tabs-list-select-all"
|
|
2483
|
+
onClick={(e) => e.stopPropagation()}
|
|
2484
|
+
>
|
|
2485
|
+
<input
|
|
2486
|
+
ref={selectAllRef}
|
|
2487
|
+
type="checkbox"
|
|
2488
|
+
className="mdt-row-select-checkbox mdt-row-select-checkbox--header"
|
|
2489
|
+
checked={allVisibleSelected}
|
|
2490
|
+
onChange={toggleSelectAllVisible}
|
|
2491
|
+
onClick={(e) => e.stopPropagation()}
|
|
2492
|
+
aria-label="Select all in list"
|
|
2493
|
+
disabled={selectableVisibleRowIds.length === 0}
|
|
2494
|
+
/>
|
|
2495
|
+
</div>
|
|
2496
|
+
) : null
|
|
2497
|
+
}
|
|
2498
|
+
/>
|
|
2499
|
+
|
|
2500
|
+
{/* ── Active filter chips bar ── */}
|
|
2501
|
+
{isFilterBarOpen &&
|
|
2502
|
+
activeFilters.length > 0 &&
|
|
2503
|
+
!showCreateViewModal && (
|
|
2504
|
+
<div className="mdt-filter-bar" ref={filterBarRef}>
|
|
2505
|
+
<MdtActiveFilterBar
|
|
2506
|
+
{...filterBarProps}
|
|
2507
|
+
showSaveAs={resolvedFilterConfig.showSaveAs}
|
|
2508
|
+
onSaveAs={handleOpenCreateViewModal}
|
|
2509
|
+
/>
|
|
2510
|
+
</div>
|
|
2511
|
+
)}
|
|
2512
|
+
|
|
2513
|
+
{/* ── Table / List view ── */}
|
|
2514
|
+
{viewMode === 'list' ? (
|
|
2515
|
+
<FlazDataViewList
|
|
2516
|
+
wrapRef={listWrapRef}
|
|
2517
|
+
rows={sortedRows}
|
|
2518
|
+
columns={renderedColumns}
|
|
2519
|
+
onRowClick={onRowClick}
|
|
2520
|
+
isLoading={isInitialLoading}
|
|
2521
|
+
isLoadingMore={isLoadingMore}
|
|
2522
|
+
skeletonRows={autoSkeletonRows}
|
|
2523
|
+
onListScroll={onTableScroll}
|
|
2524
|
+
groupByKey={groupByKey}
|
|
2525
|
+
title={title}
|
|
2526
|
+
listGroupHeadPrefix={listGroupHeadPrefix}
|
|
2527
|
+
listGroupHeadCount={listGroupHeadCount}
|
|
2528
|
+
suppressListRowCountFallback={Boolean(activeSavedViewId)}
|
|
2529
|
+
rowKey={rowKey}
|
|
2530
|
+
listMainColumnKey={listMainColumnKey ?? rowKey}
|
|
2531
|
+
listLeftColumnKeys={resolvedListLeftKeys}
|
|
2532
|
+
listLeftSecondaryKeys={listLeftSecondaryKeys}
|
|
2533
|
+
listLeftSecondarySeparator={listLeftSecondarySeparator}
|
|
2534
|
+
onRowEdit={onRowEdit}
|
|
2535
|
+
onRowDownload={onRowDownload}
|
|
2536
|
+
onRowDelete={onRowDelete}
|
|
2537
|
+
rowMenuActions={rowMenuActions}
|
|
2538
|
+
deleteConfirmConfig={deleteConfirmConfig}
|
|
2539
|
+
isRowDeletable={isRowDeletable}
|
|
2540
|
+
emptyStateTitle={emptyStateTitle}
|
|
2541
|
+
{...(enableBulkSelection
|
|
2542
|
+
? {
|
|
2543
|
+
selectedRowIds,
|
|
2544
|
+
onToggleRowSelection: toggleRowSelection,
|
|
2545
|
+
onToggleGroupSelection: toggleGroupSelection,
|
|
2546
|
+
hasSelection,
|
|
2547
|
+
}
|
|
2548
|
+
: {})}
|
|
2549
|
+
onCellActivate={onCellEdit ? handleCellClick : null}
|
|
2550
|
+
isColumnEditable={(col) => isColumnCellEditable(col, onCellEdit)}
|
|
2551
|
+
cellEdit={cellEdit}
|
|
2552
|
+
cellEditAnchorRef={cellEditAnchorRef}
|
|
2553
|
+
/>
|
|
2554
|
+
) : (
|
|
2555
|
+
<div
|
|
2556
|
+
className={`mdt-table-wrap${!isInitialLoading && sortedRows.length === 0 ? " mdt-table-wrap--empty" : ""}`}
|
|
2557
|
+
ref={tableWrapRef}
|
|
2558
|
+
onScroll={(e) => {
|
|
2559
|
+
const t = e.currentTarget;
|
|
2560
|
+
updateTableScrollState();
|
|
2561
|
+
if (openRowMenuKey) closeRowMenu();
|
|
2562
|
+
if (onTableScroll) {
|
|
2563
|
+
onTableScroll({
|
|
2564
|
+
scrollTop: t.scrollTop,
|
|
2565
|
+
scrollHeight: t.scrollHeight,
|
|
2566
|
+
clientHeight: t.clientHeight,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
}}
|
|
2570
|
+
>
|
|
2571
|
+
{!isInitialLoading && sortedRows.length === 0 ? (
|
|
2572
|
+
<MdtEmptyState title={emptyStateTitle} />
|
|
2573
|
+
) : (
|
|
2574
|
+
<table
|
|
2575
|
+
className={`mdt-table${enableBulkSelection && hasSelection ? " mdt-has-selection" : ""}`}
|
|
2576
|
+
style={{
|
|
2577
|
+
tableLayout: tableLayoutFixed ? "fixed" : "auto",
|
|
2578
|
+
width: "100%",
|
|
2579
|
+
minWidth: calcTableMinWidth(),
|
|
2580
|
+
}}
|
|
2581
|
+
>
|
|
2582
|
+
<colgroup>
|
|
2583
|
+
{renderedColumns.map((col, colIndex) => (
|
|
2584
|
+
<col
|
|
2585
|
+
key={`col-${col.accessorKey}`}
|
|
2586
|
+
className="mdt-col-data"
|
|
2587
|
+
style={getDataColWidth(col, colIndex)}
|
|
2588
|
+
/>
|
|
2589
|
+
))}
|
|
2590
|
+
{showRowMenuColumn && (
|
|
2591
|
+
<col
|
|
2592
|
+
key="col-row-menu"
|
|
2593
|
+
className="mdt-col-row-menu-col"
|
|
2594
|
+
/>
|
|
2595
|
+
)}
|
|
2596
|
+
</colgroup>
|
|
2597
|
+
<thead>
|
|
2598
|
+
<tr>
|
|
2599
|
+
{renderedColumns.map((column, colIndex) => (
|
|
2600
|
+
<th
|
|
2601
|
+
key={column.accessorKey}
|
|
2602
|
+
style={getColumnStyle(column, colIndex)}
|
|
2603
|
+
className={getTableCellClassName(column, colIndex, {
|
|
2604
|
+
header: true,
|
|
2605
|
+
})}
|
|
2606
|
+
>
|
|
2607
|
+
{enableBulkSelection && colIndex === 0 && (
|
|
2608
|
+
<div
|
|
2609
|
+
className="mdt-cell-select-slot"
|
|
2610
|
+
onClick={(e) => e.stopPropagation()}
|
|
2611
|
+
>
|
|
2612
|
+
<input
|
|
2613
|
+
ref={selectAllRef}
|
|
2614
|
+
type="checkbox"
|
|
2615
|
+
className="mdt-row-select-checkbox mdt-row-select-checkbox--header"
|
|
2616
|
+
checked={allVisibleSelected}
|
|
2617
|
+
onChange={toggleSelectAllVisible}
|
|
2618
|
+
onClick={(e) => e.stopPropagation()}
|
|
2619
|
+
aria-label="Select all rows"
|
|
2620
|
+
/>
|
|
2621
|
+
</div>
|
|
2622
|
+
)}
|
|
2623
|
+
<div className="mdt-th-wrapper">
|
|
2624
|
+
<span
|
|
2625
|
+
className="mdt-header-cell"
|
|
2626
|
+
onClick={() => handleHeaderClick(column.accessorKey)}
|
|
2627
|
+
>
|
|
2628
|
+
<span className="mdt-header-label">
|
|
2629
|
+
{renderColumnDefIcon(column, { className: "mdt-header-icon" })}
|
|
2630
|
+
<span className="mdt-header-title">{column.header}</span>
|
|
2631
|
+
</span>
|
|
2632
|
+
<span className="mdt-header-sort-slot" aria-hidden="false">
|
|
2633
|
+
{getSortIcon(column.accessorKey)}
|
|
2634
|
+
</span>
|
|
2635
|
+
</span>
|
|
2636
|
+
|
|
2637
|
+
{openPopup === column.accessorKey && (
|
|
2638
|
+
<div className="mdt-sort-popup" ref={popupRef}>
|
|
2639
|
+
{renderSortPopupContent(column)}
|
|
2640
|
+
</div>
|
|
2641
|
+
)}
|
|
2642
|
+
</div>
|
|
2643
|
+
</th>
|
|
2644
|
+
))}
|
|
2645
|
+
{showRowMenuColumn && (
|
|
2646
|
+
<th
|
|
2647
|
+
key="col-settings"
|
|
2648
|
+
style={OPTIONS_COL_STYLE}
|
|
2649
|
+
className={`mdt-th-row-menu ${rowMenuColClassName}`}
|
|
2650
|
+
>
|
|
2651
|
+
<div className="mdt-th-wrapper mdt-th-row-menu-inner">
|
|
2652
|
+
<MoreHorizontal
|
|
2653
|
+
size={14}
|
|
2654
|
+
className="mdt-row-menu-header-icon"
|
|
2655
|
+
aria-hidden="true"
|
|
2656
|
+
/>
|
|
2657
|
+
</div>
|
|
2658
|
+
</th>
|
|
2659
|
+
)}
|
|
2660
|
+
</tr>
|
|
2661
|
+
</thead>
|
|
2662
|
+
<tbody>
|
|
2663
|
+
{isInitialLoading
|
|
2664
|
+
? Array.from({ length: autoSkeletonRows }).map((_, i) => (
|
|
2665
|
+
<tr key={`skeleton-${i}`} className="mdt-table-skeleton-row">
|
|
2666
|
+
{renderedColumns.map((col, j) => (
|
|
2667
|
+
<td
|
|
2668
|
+
key={`sk-${i}-${col.accessorKey}-${j}`}
|
|
2669
|
+
style={getColumnStyle(col, j)}
|
|
2670
|
+
className={getTableCellClassName(col, j)}
|
|
2671
|
+
>
|
|
2672
|
+
{enableBulkSelection && j === 0 && (
|
|
2673
|
+
<div className="mdt-cell-select-slot" aria-hidden />
|
|
2674
|
+
)}
|
|
2675
|
+
<div className="mdt-cell-skeleton-wrap">
|
|
2676
|
+
<span className="mdt-skeleton-cell mdt-skeleton-table-cell" />
|
|
2677
|
+
</div>
|
|
2678
|
+
</td>
|
|
2679
|
+
))}
|
|
2680
|
+
{showRowMenuColumn && (
|
|
2681
|
+
<td
|
|
2682
|
+
key={`sk-${i}-menu`}
|
|
2683
|
+
style={OPTIONS_COL_STYLE}
|
|
2684
|
+
className={rowMenuColClassName}
|
|
2685
|
+
/>
|
|
2686
|
+
)}
|
|
2687
|
+
</tr>
|
|
2688
|
+
))
|
|
2689
|
+
: sortedRows.map((row) => {
|
|
2690
|
+
const key = getRowId(row);
|
|
2691
|
+
const selected = isRowSelected(key);
|
|
2692
|
+
return (
|
|
2693
|
+
<tr
|
|
2694
|
+
key={key}
|
|
2695
|
+
className={[
|
|
2696
|
+
onRowClick ? "mdt-row-clickable" : "",
|
|
2697
|
+
enableBulkSelection && selected ? "mdt-row-selected" : "",
|
|
2698
|
+
]
|
|
2699
|
+
.filter(Boolean)
|
|
2700
|
+
.join(" ")}
|
|
2701
|
+
onClick={onRowClick && key ? () => onRowClick(key) : undefined}
|
|
2702
|
+
>
|
|
2703
|
+
{renderedColumns.map((col, colIndex) => {
|
|
2704
|
+
const cellClickable =
|
|
2705
|
+
typeof col.onCellClick === "function"
|
|
2706
|
+
&& (typeof col.isCellClickable !== "function"
|
|
2707
|
+
|| col.isCellClickable(row));
|
|
2708
|
+
return (
|
|
2709
|
+
<td
|
|
2710
|
+
key={`${key}-${col.accessorKey}`}
|
|
2711
|
+
style={getColumnStyle(col, colIndex)}
|
|
2712
|
+
className={[
|
|
2713
|
+
getTableCellClassName(col, colIndex),
|
|
2714
|
+
cellClickable ? "mdt-td-clickable" : "",
|
|
2715
|
+
typeof col.onCellClick === "function" && !cellClickable
|
|
2716
|
+
? "mdt-td-clickable-disabled"
|
|
2717
|
+
: "",
|
|
2718
|
+
]
|
|
2719
|
+
.filter(Boolean)
|
|
2720
|
+
.join(" ")}
|
|
2721
|
+
onClick={
|
|
2722
|
+
cellClickable
|
|
2723
|
+
? (event) => {
|
|
2724
|
+
event.stopPropagation();
|
|
2725
|
+
col.onCellClick(row, event);
|
|
2726
|
+
}
|
|
2727
|
+
: undefined
|
|
2728
|
+
}
|
|
2729
|
+
>
|
|
2730
|
+
{enableBulkSelection && colIndex === 0 && (
|
|
2731
|
+
<div
|
|
2732
|
+
className="mdt-cell-select-slot"
|
|
2733
|
+
onClick={(e) => e.stopPropagation()}
|
|
2734
|
+
>
|
|
2735
|
+
<input
|
|
2736
|
+
type="checkbox"
|
|
2737
|
+
className="mdt-row-select-checkbox"
|
|
2738
|
+
checked={selected}
|
|
2739
|
+
onChange={(e) => toggleRowSelection(key, e)}
|
|
2740
|
+
onClick={(e) => e.stopPropagation()}
|
|
2741
|
+
aria-label={`Select row ${key}`}
|
|
2742
|
+
/>
|
|
2743
|
+
</div>
|
|
2744
|
+
)}
|
|
2745
|
+
<div
|
|
2746
|
+
className={[
|
|
2747
|
+
"mdt-td-inner",
|
|
2748
|
+
isColumnCellEditable(col, onCellEdit)
|
|
2749
|
+
? "mdt-cell-editable-wrap"
|
|
2750
|
+
: "mdt-td-readonly-inner",
|
|
2751
|
+
enableBulkSelection && colIndex === 0
|
|
2752
|
+
? "mdt-cell-first-content"
|
|
2753
|
+
: "",
|
|
2754
|
+
]
|
|
2755
|
+
.filter(Boolean)
|
|
2756
|
+
.join(" ")}
|
|
2757
|
+
ref={
|
|
2758
|
+
cellEdit?.rowId === key &&
|
|
2759
|
+
cellEdit?.column?.accessorKey === col.accessorKey
|
|
2760
|
+
? cellEditAnchorRef
|
|
2761
|
+
: null
|
|
2762
|
+
}
|
|
2763
|
+
>
|
|
2764
|
+
{renderCell(col, row, {
|
|
2765
|
+
editable: isColumnCellEditable(col, onCellEdit),
|
|
2766
|
+
onCellActivate: handleCellClick,
|
|
2767
|
+
})}
|
|
2768
|
+
</div>
|
|
2769
|
+
</td>
|
|
2770
|
+
);
|
|
2771
|
+
})}
|
|
2772
|
+
{showRowMenuColumn && (
|
|
2773
|
+
<td
|
|
2774
|
+
key={`${key}-row-menu`}
|
|
2775
|
+
style={OPTIONS_COL_STYLE}
|
|
2776
|
+
className={rowMenuColClassName}
|
|
2777
|
+
>
|
|
2778
|
+
{renderRowMenuCell(row)}
|
|
2779
|
+
</td>
|
|
2780
|
+
)}
|
|
2781
|
+
</tr>
|
|
2782
|
+
);
|
|
2783
|
+
})}
|
|
2784
|
+
{isLoadingMore && (
|
|
2785
|
+
<tr className="mdt-table-loading-more" aria-hidden>
|
|
2786
|
+
{renderedColumns.map((col, j) => (
|
|
2787
|
+
<td
|
|
2788
|
+
key={`load-more-${col.accessorKey}`}
|
|
2789
|
+
style={getColumnStyle(col, j)}
|
|
2790
|
+
className={getTableCellClassName(col, j)}
|
|
2791
|
+
>
|
|
2792
|
+
{enableBulkSelection && j === 0 && (
|
|
2793
|
+
<div className="mdt-cell-select-slot" aria-hidden />
|
|
2794
|
+
)}
|
|
2795
|
+
<div className="mdt-cell-skeleton-wrap">
|
|
2796
|
+
<span className="mdt-skeleton-cell mdt-skeleton-table-cell" />
|
|
2797
|
+
</div>
|
|
2798
|
+
</td>
|
|
2799
|
+
))}
|
|
2800
|
+
{showRowMenuColumn && (
|
|
2801
|
+
<td style={OPTIONS_COL_STYLE} className={rowMenuColClassName} />
|
|
2802
|
+
)}
|
|
2803
|
+
</tr>
|
|
2804
|
+
)}
|
|
2805
|
+
</tbody>
|
|
2806
|
+
</table>
|
|
2807
|
+
)}
|
|
2808
|
+
</div>
|
|
2809
|
+
)}
|
|
2810
|
+
</div>
|
|
2811
|
+
</div>
|
|
2812
|
+
</div>
|
|
2813
|
+
{cellEdit && cellEditPopupPos && (
|
|
2814
|
+
<div
|
|
2815
|
+
ref={cellEditPopupRef}
|
|
2816
|
+
className="mdt-cell-edit-popup-anchor"
|
|
2817
|
+
style={{
|
|
2818
|
+
position: "fixed",
|
|
2819
|
+
top: cellEditPopupPos.top,
|
|
2820
|
+
left: cellEditPopupPos.left,
|
|
2821
|
+
width: cellEditPopupPos.width,
|
|
2822
|
+
minWidth: cellEditPopupPos.width,
|
|
2823
|
+
maxWidth: isMobilePopupLayout()
|
|
2824
|
+
? "calc(100vw - 16px)"
|
|
2825
|
+
: "min(320px, calc(100vw - 16px))",
|
|
2826
|
+
zIndex: 10060,
|
|
2827
|
+
}}
|
|
2828
|
+
onClick={(e) => e.stopPropagation()}
|
|
2829
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2830
|
+
onWheel={(e) => e.stopPropagation()}
|
|
2831
|
+
>
|
|
2832
|
+
<MdtValueEditorPopup
|
|
2833
|
+
key={`${cellEdit.rowId ?? "row"}-${cellEdit.columnKey ?? cellEdit.column?.accessorKey ?? "col"}`}
|
|
2834
|
+
column={liveCellEditColumn ?? cellEdit.column}
|
|
2835
|
+
mode={EDIT_MODE.INLINE}
|
|
2836
|
+
value={cellEditDraftValue}
|
|
2837
|
+
onApply={handleCellEditApply}
|
|
2838
|
+
onCancel={closeCellEdit}
|
|
2839
|
+
row={cellEdit.row}
|
|
2840
|
+
/>
|
|
2841
|
+
</div>
|
|
2842
|
+
)}
|
|
2843
|
+
{bulkEdit && bulkEditPopupPos && (
|
|
2844
|
+
<div
|
|
2845
|
+
ref={bulkEditPopupRef}
|
|
2846
|
+
className="mdt-cell-edit-popup-anchor mdt-bulk-edit-popup-anchor"
|
|
2847
|
+
style={{
|
|
2848
|
+
position: "fixed",
|
|
2849
|
+
top: bulkEditPopupPos.top,
|
|
2850
|
+
left: bulkEditPopupPos.left,
|
|
2851
|
+
width: bulkEditPopupPos.width,
|
|
2852
|
+
minWidth: bulkEditPopupPos.width,
|
|
2853
|
+
maxWidth: isMobilePopupLayout()
|
|
2854
|
+
? "calc(100vw - 16px)"
|
|
2855
|
+
: "min(320px, calc(100vw - 16px))",
|
|
2856
|
+
zIndex: 10070,
|
|
2857
|
+
}}
|
|
2858
|
+
onClick={(e) => e.stopPropagation()}
|
|
2859
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2860
|
+
onWheel={(e) => e.stopPropagation()}
|
|
2861
|
+
>
|
|
2862
|
+
<MdtValueEditorPopup
|
|
2863
|
+
column={bulkEdit.column}
|
|
2864
|
+
mode={EDIT_MODE.BULK}
|
|
2865
|
+
value={bulkEditDraftValue}
|
|
2866
|
+
onApply={handleBulkEditApply}
|
|
2867
|
+
onCancel={closeBulkEdit}
|
|
2868
|
+
row={bulkEdit?.row}
|
|
2869
|
+
/>
|
|
2870
|
+
</div>
|
|
2871
|
+
)}
|
|
2872
|
+
<MdtCreateViewModal
|
|
2873
|
+
open={showCreateViewModal}
|
|
2874
|
+
mode={editingViewId ? "edit" : "create"}
|
|
2875
|
+
editingView={editingView}
|
|
2876
|
+
onClose={handleCloseCreateViewModal}
|
|
2877
|
+
onCreate={handleSaveView}
|
|
2878
|
+
onDelete={editingViewId ? handleDeleteSavedView : undefined}
|
|
2879
|
+
initialViewMode={editingView?.viewMode || viewMode}
|
|
2880
|
+
filterBar={
|
|
2881
|
+
<div className="mdt-filter-bar mdt-cv-filter-bar">
|
|
2882
|
+
<MdtActiveFilterBar {...filterBarProps} showSaveAs={false} />
|
|
2883
|
+
</div>
|
|
2884
|
+
}
|
|
2885
|
+
displayPopupProps={displayPopupProps}
|
|
2886
|
+
/>
|
|
2887
|
+
<DeleteConfirmModal
|
|
2888
|
+
open={showBulkDeleteConfirm}
|
|
2889
|
+
title={deleteConfirmConfig.bulkTitle || "Delete Selected Items"}
|
|
2890
|
+
message={
|
|
2891
|
+
deleteConfirmConfig.getBulkMessage
|
|
2892
|
+
? deleteConfirmConfig.getBulkMessage(
|
|
2893
|
+
selectedCount,
|
|
2894
|
+
getSelectedRows(),
|
|
2895
|
+
bulkDeleteCounts,
|
|
2896
|
+
)
|
|
2897
|
+
: (deleteConfirmConfig.bulkMessage || buildDefaultBulkDeleteMessage())
|
|
2898
|
+
}
|
|
2899
|
+
cancelText={deleteConfirmConfig.cancelText || "Cancel"}
|
|
2900
|
+
confirmText={deleteConfirmConfig.confirmText || "Delete"}
|
|
2901
|
+
onCancel={() => setShowBulkDeleteConfirm(false)}
|
|
2902
|
+
onConfirm={() => {
|
|
2903
|
+
const deletableRows = getDeletableSelectedRows();
|
|
2904
|
+
const idsToDelete = deletableRows
|
|
2905
|
+
.map((row) => getRowId(row))
|
|
2906
|
+
.filter((id) => id != null);
|
|
2907
|
+
const rowsToDelete = deletableRows;
|
|
2908
|
+
setShowBulkDeleteConfirm(false);
|
|
2909
|
+
// Optimistically remove only deletable rows from UI immediately
|
|
2910
|
+
setLocallyDeletedIds((prev) => {
|
|
2911
|
+
const next = new Set(prev);
|
|
2912
|
+
idsToDelete.forEach((id) => next.add(id));
|
|
2913
|
+
return next;
|
|
2914
|
+
});
|
|
2915
|
+
clearRowSelection();
|
|
2916
|
+
// Fire API call silently in the background
|
|
2917
|
+
if (onBulkDelete) {
|
|
2918
|
+
onBulkDelete(idsToDelete, rowsToDelete);
|
|
2919
|
+
}
|
|
2920
|
+
}}
|
|
2921
|
+
/>
|
|
2922
|
+
{enableBulkSelection && (
|
|
2923
|
+
<MdtBulkSelectionBar
|
|
2924
|
+
selectedCount={selectedCount}
|
|
2925
|
+
onClearSelection={clearRowSelection}
|
|
2926
|
+
bulkFields={resolvedBulkBarFields}
|
|
2927
|
+
onBulkFieldClick={handleBulkFieldClick}
|
|
2928
|
+
onBulkDelete={
|
|
2929
|
+
onBulkDelete ? () => setShowBulkDeleteConfirm(true) : undefined
|
|
2930
|
+
}
|
|
2931
|
+
bulkDeleteDisabled={Boolean(onBulkDelete) && deletableSelectedCount === 0}
|
|
2932
|
+
bulkDeleteDisabledReason={
|
|
2933
|
+
deleteConfirmConfig.deleteDisabledReason ||
|
|
2934
|
+
"None of the selected items can be deleted."
|
|
2935
|
+
}
|
|
2936
|
+
rightAction={bulkBarRightAction}
|
|
2937
|
+
/>
|
|
2938
|
+
)}
|
|
2939
|
+
</div>
|
|
2940
|
+
);
|
|
2941
|
+
};
|
|
2942
|
+
|
|
2943
|
+
export default FlazDataView;
|