From 2fc46485158f79762d9913937bad68257ec39bdc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 11 Jun 2026 18:21:15 +0200 Subject: [PATCH] DataGrid - initial commit --- ...atagrid-individual-resize-fix-20260611.zip | Bin 0 -> 10945 bytes src/components/DismissibleAlert.tsx | 41 ++ src/components/table/DataGrid.tsx | 672 ++++++++++++++++++ src/features/addressbook/AddressBookPage.tsx | 37 +- src/features/admin/AdminPage.tsx | 25 +- .../components/AdminPlaceholderTable.tsx | 44 +- .../campaigns/AttachmentsDataPage.tsx | 109 +-- src/features/campaigns/CampaignAuditPage.tsx | 3 +- src/features/campaigns/CampaignFieldsPage.tsx | 74 +- src/features/campaigns/CampaignJsonView.tsx | 3 +- src/features/campaigns/CampaignListPage.tsx | 108 ++- .../campaigns/CampaignOverviewPage.tsx | 83 +-- src/features/campaigns/CampaignReportPage.tsx | 3 +- src/features/campaigns/CampaignWorkspace.tsx | 5 +- src/features/campaigns/GlobalSettingsPage.tsx | 5 +- src/features/campaigns/MailSettingsPage.tsx | 56 +- src/features/campaigns/RecipientDataPage.tsx | 95 +-- .../campaigns/RecipientDetailsPage.tsx | 137 ++-- src/features/campaigns/SendDataPage.tsx | 190 ++--- src/features/campaigns/TemplateDataPage.tsx | 9 +- .../components/AttachmentRulesOverlay.tsx | 183 +++-- .../components/ReviewWorkflowCards.tsx | 55 +- src/features/campaigns/utils/campaignView.ts | 11 +- .../campaigns/wizard/CreateWizard.tsx | 5 +- src/features/files/FilesPage.tsx | 94 ++- src/features/templates/TemplatesPage.tsx | 82 +-- src/styles/components.css | 332 +++++++++ 27 files changed, 1813 insertions(+), 648 deletions(-) create mode 100644 multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip create mode 100644 src/components/DismissibleAlert.tsx create mode 100644 src/components/table/DataGrid.tsx diff --git a/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip b/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip new file mode 100644 index 0000000000000000000000000000000000000000..2ecebfaf8a0315af20b85be3060a1221b37674e8 GIT binary patch literal 10945 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 { + setVisible(true); + }, [resetKey, children]); + + if (!visible) return null; + + return ( +
+
{children}
+ {dismissible && ( + + )} +
+ ); +} diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx new file mode 100644 index 0000000..f05f8e9 --- /dev/null +++ b/src/components/table/DataGrid.tsx @@ -0,0 +1,672 @@ +import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, X } from "lucide-react"; + +export type DataGridSortDirection = "asc" | "desc"; +export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date"; + +type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after"; + +export type DataGridColumn = { + id: string; + header: ReactNode; + width?: number | string; + minWidth?: number; + maxWidth?: number; + resizable?: boolean; + sortable?: boolean; + filterable?: boolean; + filterType?: DataGridFilterType; + sticky?: "start" | "end"; + align?: "left" | "center" | "right"; + className?: string; + headerClassName?: string; + render?: (row: T, index: number) => ReactNode; + value?: (row: T, index: number) => unknown; + sortValue?: (row: T, index: number) => unknown; + filterValue?: (row: T, index: number) => unknown; +}; + +type DataGridState = { + sort?: { columnId: string; direction: DataGridSortDirection }; + filters?: Record; + widths?: Record; +}; + +type DataGridProps = { + id: string; + rows: T[]; + columns: DataGridColumn[]; + getRowKey: (row: T, index: number) => string; + emptyText?: ReactNode; + fit?: "content" | "container"; + className?: string; + rowClassName?: (row: T, index: number) => string | undefined; + storageKey?: string; +}; + +type FilterPosition = { + top: number; + left: number; + width: number; +}; + +const STORAGE_PREFIX = "multimailer.datagrid."; +const FILTER_POPOVER_WIDTH = 320; +const FILTER_POPOVER_MARGIN = 12; + +export default function DataGrid({ + id, + rows, + columns, + getRowKey, + emptyText = "No rows found.", + fit = "content", + className = "", + rowClassName, + storageKey +}: DataGridProps) { + const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`; + const [state, setState] = useState(() => loadState(localStorageKey)); + const [resizeState, setResizeState] = useState<{ columnId: string; startX: number; startWidth: number } | null>(null); + const [openFilterColumnId, setOpenFilterColumnId] = useState(null); + const [filterPosition, setFilterPosition] = useState(null); + const gridRef = useRef(null); + const headerCellRefs = useRef>({}); + const filterButtonRefs = useRef>({}); + const filterPopoverRef = useRef(null); + const [measuredWidths, setMeasuredWidths] = useState>({}); + + useEffect(() => { + try { + window.localStorage.setItem(localStorageKey, JSON.stringify(state)); + } catch { + // Local storage is an enhancement only. + } + }, [localStorageKey, state]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const filtersFromUrl: Record = {}; + for (const column of columns) { + const exact = params.get(`grid.${id}.${column.id}`) ?? params.get(`filter.${id}.${column.id}`); + if (exact) filtersFromUrl[column.id] = exact; + } + const table = params.get("table"); + const filter = params.get("filter"); + if (table === id && filter?.includes(":")) { + const [columnId, ...parts] = filter.split(":"); + const value = parts.join(":"); + if (columnId && value) filtersFromUrl[columnId] = value; + } + if (Object.keys(filtersFromUrl).length > 0) { + setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), ...filtersFromUrl } })); + } + }, [id, columns]); + + useLayoutEffect(() => { + function measure() { + const next: Record = {}; + for (const column of columns) { + const element = headerCellRefs.current[column.id]; + if (!element) continue; + const width = Math.round(element.getBoundingClientRect().width); + if (width > 0) next[column.id] = width; + } + setMeasuredWidths((current) => shallowEqualNumberRecords(current, next) ? current : next); + } + + measure(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + } + + const observer = new ResizeObserver(measure); + for (const column of columns) { + const element = headerCellRefs.current[column.id]; + if (element) observer.observe(element); + } + return () => observer.disconnect(); + }, [columns, state.widths]); + + useEffect(() => { + if (!resizeState) return; + const activeResize = resizeState; + function onMove(event: MouseEvent) { + const column = columns.find((item) => item.id === activeResize.columnId); + const minWidth = column?.minWidth ?? 80; + const maxWidth = column?.maxWidth ?? 2000; + const nextWidth = Math.min(maxWidth, Math.max(minWidth, activeResize.startWidth + event.clientX - activeResize.startX)); + setState((current) => ({ ...current, widths: { ...(current.widths ?? {}), [activeResize.columnId]: nextWidth } })); + } + function onUp() { + setResizeState(null); + } + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [resizeState, columns]); + + useEffect(() => { + if (!openFilterColumnId) return undefined; + const activeFilterColumnId = openFilterColumnId; + function update() { + updateFilterPosition(activeFilterColumnId); + } + update(); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + }; + }, [openFilterColumnId]); + + useEffect(() => { + if (!openFilterColumnId) return undefined; + const activeFilterColumnId = openFilterColumnId; + function onPointerDown(event: MouseEvent) { + const target = event.target as Node | null; + const popover = filterPopoverRef.current; + const trigger = filterButtonRefs.current[activeFilterColumnId]; + if (target && (popover?.contains(target) || trigger?.contains(target))) return; + setOpenFilterColumnId(null); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpenFilterColumnId(null); + } + window.addEventListener("mousedown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("mousedown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [openFilterColumnId]); + + const filterTypes = useMemo(() => { + const result: Record = {}; + for (const column of columns) result[column.id] = column.filterType ?? inferFilterType(column, rows); + return result; + }, [columns, rows]); + + const visibleRows = useMemo(() => { + const filters = state.filters ?? {}; + const filtered = rows.filter((row, rowIndex) => columns.every((column) => { + const filterValue = filters[column.id] ?? ""; + if (!filterValue.trim()) return true; + const rawValue = valueForFilter(column, row, rowIndex); + return matchesFilter(rawValue, filterValue, filterTypes[column.id]); + })); + if (!state.sort) return filtered; + const sortColumn = columns.find((column) => column.id === state.sort?.columnId); + if (!sortColumn) return filtered; + return [...filtered].sort((a, b) => { + const aIndex = rows.indexOf(a); + const bIndex = rows.indexOf(b); + const aValue = sortColumn.sortValue?.(a, aIndex) ?? sortColumn.value?.(a, aIndex) ?? ""; + const bValue = sortColumn.sortValue?.(b, bIndex) ?? sortColumn.value?.(b, bIndex) ?? ""; + const result = compareValues(aValue, bValue); + return state.sort?.direction === "desc" ? -result : result; + }); + }, [rows, columns, state.filters, state.sort, filterTypes]); + + const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths), [columns, state.widths]); + const templateColumns = columns.map((column) => widthForColumn(column, state.widths?.[column.id], stretchedColumnIds.has(column.id))).join(" "); + const hasFlexibleColumns = columns.some((column) => stretchedColumnIds.has(column.id) || isFlexibleColumn(column, state.widths?.[column.id])); + const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]); + const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`; + const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined; + + function toggleSort(column: DataGridColumn) { + if (!column.sortable) return; + setState((current) => { + if (current.sort?.columnId !== column.id) return { ...current, sort: { columnId: column.id, direction: "asc" } }; + if (current.sort.direction === "asc") return { ...current, sort: { columnId: column.id, direction: "desc" } }; + return { ...current, sort: undefined }; + }); + } + + function patchFilter(columnId: string, value: string) { + setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } })); + } + + function clearFilter(columnId: string) { + setState((current) => { + const nextFilters = { ...(current.filters ?? {}) }; + delete nextFilters[columnId]; + return { ...current, filters: nextFilters }; + }); + } + + function updateFilterPosition(columnId: string) { + const element = filterButtonRefs.current[columnId]; + if (!element) return; + const rect = element.getBoundingClientRect(); + const width = Math.min(FILTER_POPOVER_WIDTH, Math.max(240, window.innerWidth - FILTER_POPOVER_MARGIN * 2)); + const left = Math.min(Math.max(FILTER_POPOVER_MARGIN, rect.left), window.innerWidth - width - FILTER_POPOVER_MARGIN); + const top = Math.min(rect.bottom + 8, window.innerHeight - 120); + setFilterPosition({ top, left, width }); + } + + function toggleFilterPopover(columnId: string) { + setOpenFilterColumnId((current) => { + if (current === columnId) return null; + window.requestAnimationFrame(() => updateFilterPosition(columnId)); + return columnId; + }); + } + + return ( +
+
+ {columns.map((column, columnIndex) => { + const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined; + const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim()); + return ( +
{ headerCellRefs.current[column.id] = element; }} + className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()} + style={stickyStyle(column, stickyOffsets[columnIndex])} + > + + {column.filterable && ( + + )} + {column.resizable && ( + + )} +
+ ); + })} + {visibleRows.length === 0 ? ( +
+
{emptyText}
+
+ ) : visibleRows.map((row, visibleIndex) => { + const originalIndex = rows.indexOf(row); + const rowClass = rowClassName?.(row, originalIndex); + const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd"; + return columns.map((column, columnIndex) => ( +
+ {column.render ? column.render(row, originalIndex) : stringifyCell(column.value?.(row, originalIndex))} +
+ )); + })} +
+ {activeFilterColumn && filterPosition && createPortal( + patchFilter(activeFilterColumn.id, value)} + onClear={() => clearFilter(activeFilterColumn.id)} + onClose={() => setOpenFilterColumnId(null)} + />, + document.body + )} +
+ ); +} + +type FilterPopoverProps = { + column: Pick, "id" | "header">; + filterType: DataGridFilterType; + value: string; + position: FilterPosition; + onChange: (value: string) => void; + onClear: () => void; + onClose: () => void; +}; + +const FilterPopover = forwardRef(function FilterPopover( + { column, filterType, value, position, onChange, onClear, onClose }, + ref +) { + const parsed = parseTypedFilter(value, filterType); + const operatorOptions = filterType === "date" ? DATE_OPERATORS : NUMBER_OPERATORS; + return ( +
+
+ Filter {column.header} + +
+ {filterType === "boolean" ? ( + + ) : filterType === "number" || filterType === "integer" || filterType === "date" ? ( +
+ + +
+ ) : ( + + )} +
+ ); +}); + +function SortIcon({ direction }: { direction?: DataGridSortDirection }) { + if (direction === "asc") return ; + if (direction === "desc") return ; + return