mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 11:33:22 +00:00
Add column sorting, reorder, and show/hide to SharedTable
- Click any column header to sort (toggle asc/desc) - Default sort by Last Updated descending - Sort indicator arrow on active column - Gear icon in pagination bar opens column config panel - Drag to reorder columns in the config panel - Checkbox to show/hide individual columns - Column resize still works (stopPropagation on handle)
This commit is contained in:
parent
4b44237b61
commit
1ae58e56c3
2 changed files with 291 additions and 12 deletions
|
|
@ -1,4 +1,80 @@
|
|||
import React, { useState, useCallback, useRef } from "react";
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
function ColumnConfigPanel({ columns, columnOrder, hiddenColumns, onReorder, onToggle, onClose }) {
|
||||
const btnRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (btnRef.current?.contains(e.target)) return;
|
||||
if (panelRef.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||
}
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const handleDragStart = (idx) => setDragIdx(idx);
|
||||
const handleDragOver = (e) => e.preventDefault();
|
||||
const handleDrop = (targetIdx) => {
|
||||
if (dragIdx === null || dragIdx === targetIdx) return;
|
||||
const newOrder = [...columnOrder];
|
||||
const [moved] = newOrder.splice(dragIdx, 1);
|
||||
newOrder.splice(targetIdx, 0, moved);
|
||||
onReorder(newOrder);
|
||||
setDragIdx(null);
|
||||
};
|
||||
|
||||
const ordered = columnOrder.map((key) => columns.find((c) => c.key === key)).filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={btnRef} className="paginationBtn colConfigBtn" onClick={handleOpen} title="Configure columns">
|
||||
⚙
|
||||
</button>
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={panelRef} className="colConfigPanel" style={{ top: menuPos.top, right: menuPos.right }}>
|
||||
<div className="colConfigTitle">Columns</div>
|
||||
<div className="colConfigList">
|
||||
{ordered.map((col, idx) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`colConfigItem ${dragIdx === idx ? "dragging" : ""}`}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
>
|
||||
<span className="colConfigDragHandle">⠿</span>
|
||||
<label className="colConfigLabel">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!hiddenColumns.includes(col.key)}
|
||||
onChange={() => onToggle(col.key)}
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SharedTable = ({
|
||||
rows = [],
|
||||
|
|
@ -15,8 +91,32 @@ const SharedTable = ({
|
|||
}) => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const [colWidths, setColWidths] = useState(null);
|
||||
const [columnOrder, setColumnOrder] = useState(() => columns.map((c) => c.key));
|
||||
const [hiddenColumns, setHiddenColumns] = useState([]);
|
||||
const [sortKey, setSortKey] = useState("lastUpdated");
|
||||
const [sortDir, setSortDir] = useState("desc");
|
||||
const gridRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder((prev) => {
|
||||
const newKeys = columns.map((c) => c.key);
|
||||
const existing = prev.filter((k) => newKeys.includes(k));
|
||||
const added = newKeys.filter((k) => !prev.includes(k));
|
||||
return [...existing, ...added];
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
const visibleColumns = columnOrder
|
||||
.map((key) => columns.find((c) => c.key === key))
|
||||
.filter((col) => col && !hiddenColumns.includes(col.key));
|
||||
|
||||
const handleToggleColumn = (key) => {
|
||||
setHiddenColumns((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||
);
|
||||
setColWidths(null);
|
||||
};
|
||||
|
||||
const snapshotWidths = useCallback(() => {
|
||||
if (!gridRef.current) return null;
|
||||
const headerCells = gridRef.current.querySelectorAll("[data-col]");
|
||||
|
|
@ -35,8 +135,8 @@ const SharedTable = ({
|
|||
if (!currentWidths) return;
|
||||
setColWidths(currentWidths);
|
||||
|
||||
const colIndex = columns.findIndex((c) => c.key === colKey);
|
||||
const nextColKey = colIndex < columns.length - 1 ? columns[colIndex + 1].key : null;
|
||||
const colIndex = visibleColumns.findIndex((c) => c.key === colKey);
|
||||
const nextColKey = colIndex < visibleColumns.length - 1 ? visibleColumns[colIndex + 1].key : null;
|
||||
const startWidth = currentWidths[colKey] || 100;
|
||||
const nextStartWidth = nextColKey ? (currentWidths[nextColKey] || 100) : 0;
|
||||
|
||||
|
|
@ -63,14 +163,34 @@ const SharedTable = ({
|
|||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [snapshotWidths, columns]);
|
||||
}, [snapshotWidths, visibleColumns]);
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedRows = [...rows].sort((a, b) => {
|
||||
if (!sortKey) return 0;
|
||||
let aVal = a[sortKey] ?? "";
|
||||
let bVal = b[sortKey] ?? "";
|
||||
if (typeof aVal === "string") aVal = aVal.toLowerCase();
|
||||
if (typeof bVal === "string") bVal = bVal.toLowerCase();
|
||||
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
if (error) return <div className="error">{error}</div>;
|
||||
|
||||
const gridTemplate = colWidths
|
||||
? ["40px", ...columns.map((col) => `${colWidths[col.key] || 100}px`), "80px"].join(" ")
|
||||
: ["40px", ...columns.map(() => "1fr"), "80px"].join(" ");
|
||||
? ["40px", ...visibleColumns.map((col) => `${colWidths[col.key] || 100}px`), "80px"].join(" ")
|
||||
: ["40px", ...visibleColumns.map(() => "1fr"), "80px"].join(" ");
|
||||
|
||||
const paginationBar = (
|
||||
<div className="paginationBar">
|
||||
|
|
@ -100,6 +220,13 @@ const SharedTable = ({
|
|||
<button className="paginationBtn" onClick={() => setPage(page + 1)} disabled={page >= totalPages} title="Next">›</button>
|
||||
<button className="paginationBtn" onClick={() => setPage(totalPages)} disabled={page >= totalPages} title="Last">»</button>
|
||||
</div>
|
||||
<ColumnConfigPanel
|
||||
columns={columns}
|
||||
columnOrder={columnOrder}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onReorder={(order) => { setColumnOrder(order); setColWidths(null); }}
|
||||
onToggle={handleToggleColumn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,23 +239,29 @@ const SharedTable = ({
|
|||
<div className="gridCell gridHeader">
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} data-col={col.key} className="gridCell gridHeader">
|
||||
{visibleColumns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
data-col={col.key}
|
||||
className={`gridCell gridHeader gridSortable ${sortKey === col.key ? "sorted" : ""}`}
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{sortKey === col.key && <span className="sortArrow">{sortDir === "asc" ? " ▲" : " ▼"}</span>}
|
||||
<span
|
||||
className="colResizeHandle"
|
||||
onMouseDown={(e) => handleMouseDown(e, col.key)}
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, col.key); }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="gridCell gridHeader">Actions</div>
|
||||
|
||||
{rows.map((row, idx) => (
|
||||
{sortedRows.map((row, idx) => (
|
||||
<React.Fragment key={row.id || idx}>
|
||||
<div className="gridCell gridBody">
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
{visibleColumns.map((col) => (
|
||||
<div key={col.key} className="gridCell gridBody">
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</div>
|
||||
|
|
@ -151,7 +284,6 @@ const SharedTable = ({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
147
src/site.css
147
src/site.css
|
|
@ -194,6 +194,153 @@ body {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.colConfigBtn {
|
||||
font-size: 15px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.colConfigPanel {
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
z-index: 99999;
|
||||
width: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colConfigTitle {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.colConfigList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.colConfigItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.colConfigItem:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.colConfigItem.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.colConfigDragHandle {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.colConfigLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.colConfigLabel input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.gridSortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gridSortable:hover {
|
||||
background: #eef1f4;
|
||||
}
|
||||
|
||||
.gridHeader.sorted {
|
||||
background: #e8edf2;
|
||||
}
|
||||
|
||||
.sortArrow {
|
||||
font-size: 10px;
|
||||
color: #007bff;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.colConfigBtn {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.colConfigPanel {
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
z-index: 99999;
|
||||
width: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colConfigTitle {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.colConfigList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.colConfigItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.colConfigItem:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.colConfigItem.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.colConfigDragHandle {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.colConfigLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.colResizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue