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:
Adam Moussa 2026-04-17 12:40:41 -04:00
parent 4b44237b61
commit 1ae58e56c3
2 changed files with 291 additions and 12 deletions

View file

@ -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>
);
};

View file

@ -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;