From 1ae58e56c3a2064c5155aa8b0ba53c8043682438 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:40:41 -0400 Subject: [PATCH] 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) --- src/components/SharedTable.js | 156 +++++++++++++++++++++++++++++++--- src/site.css | 147 ++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 12 deletions(-) diff --git a/src/components/SharedTable.js b/src/components/SharedTable.js index a060d632..bfa4b278 100644 --- a/src/components/SharedTable.js +++ b/src/components/SharedTable.js @@ -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 ( + <> + + {open && ReactDOM.createPortal( +
+
Columns
+
+ {ordered.map((col, idx) => ( +
handleDragStart(idx)} + onDragOver={handleDragOver} + onDrop={() => handleDrop(idx)} + > + ⠿ + +
+ ))} +
+
, + 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
Loading...
; if (error) return
{error}
; 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 = (
@@ -100,6 +220,13 @@ const SharedTable = ({
+ { setColumnOrder(order); setColWidths(null); }} + onToggle={handleToggleColumn} + /> ); @@ -112,23 +239,29 @@ const SharedTable = ({
- {columns.map((col) => ( -
+ {visibleColumns.map((col) => ( +
handleSort(col.key)} + > {col.label} + {sortKey === col.key && {sortDir === "asc" ? " ▲" : " ▼"}} handleMouseDown(e, col.key)} + onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, col.key); }} />
))}
Actions
- {rows.map((row, idx) => ( + {sortedRows.map((row, idx) => (
- {columns.map((col) => ( + {visibleColumns.map((col) => (
{col.render ? col.render(row[col.key], row) : row[col.key]}
@@ -151,7 +284,6 @@ const SharedTable = ({ ))}
- ); }; diff --git a/src/site.css b/src/site.css index 59c25f28..17058f35 100644 --- a/src/site.css +++ b/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;