feat: optimize table component with deferred search and dynamic container height
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTableData, readCachedTableData } from "../utils/userHandler";
|
||||
@@ -13,6 +19,7 @@ interface DataPackage {
|
||||
adresse: string | null;
|
||||
plz: string | null;
|
||||
email: string | null;
|
||||
_search?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -20,14 +27,29 @@ const Table: React.FC = () => {
|
||||
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstRowRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const [rowHeight, setRowHeight] = useState<number>(0);
|
||||
const [containerHeight, setContainerHeight] = useState<number>();
|
||||
const [range, setRange] = useState<{ start: number; end: number }>({
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
const OVERSCAN = 100; // Render 100 rows above and below the viewport
|
||||
const OVERSCAN = 30; // Render 30 rows above and below the viewport
|
||||
|
||||
// Build a canonical lowercase search text for a row
|
||||
const buildSearchText = (r: DataPackage) =>
|
||||
[
|
||||
r.losnummer,
|
||||
r.vorname ?? "",
|
||||
r.nachname ?? "",
|
||||
r.adresse ?? "",
|
||||
r.plz ?? "",
|
||||
r.email ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
// Einheitliche Input-Styles (nur Tailwind)
|
||||
const inputClasses =
|
||||
@@ -41,7 +63,9 @@ const Table: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
// Server könnte entweder ein Objekt oder ein Array liefern
|
||||
const normalized: DataPackage[] = Array.isArray(cached) ? cached : [cached];
|
||||
const normalized: DataPackage[] = (
|
||||
Array.isArray(cached) ? cached : [cached]
|
||||
).map((r: DataPackage) => ({ ...r, _search: buildSearchText(r) }));
|
||||
setRows(normalized);
|
||||
};
|
||||
|
||||
@@ -79,22 +103,10 @@ const Table: React.FC = () => {
|
||||
|
||||
// Filter rows by search query (case-insensitive)
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const q = deferredSearch.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => {
|
||||
const values = [
|
||||
r.losnummer,
|
||||
r.vorname ?? "",
|
||||
r.nachname ?? "",
|
||||
r.adresse ?? "",
|
||||
r.plz ?? "",
|
||||
r.email ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return values.includes(q);
|
||||
});
|
||||
}, [rows, search]);
|
||||
return rows.filter((r) => (r._search ?? buildSearchText(r)).includes(q));
|
||||
}, [rows, deferredSearch]);
|
||||
|
||||
// Measure row height once the first visible row mounts
|
||||
useEffect(() => {
|
||||
@@ -137,6 +149,24 @@ const Table: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowHeight, filteredRows.length]);
|
||||
|
||||
// Make the scroll container fill to the bottom of the viewport
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const available = Math.max(0, window.innerHeight - rect.top - 16); // 16px bottom breathing room
|
||||
if (!containerHeight || Math.abs(available - containerHeight) > 1) {
|
||||
setContainerHeight(available);
|
||||
requestAnimationFrame(recomputeRange);
|
||||
}
|
||||
};
|
||||
compute();
|
||||
window.addEventListener("resize", compute);
|
||||
return () => window.removeEventListener("resize", compute);
|
||||
// Re-evaluate when content above changes size noticeably
|
||||
}, [deferredSearch, rows.length]);
|
||||
|
||||
// Recompute range whenever data set changes significantly (search, data load)
|
||||
useEffect(() => {
|
||||
// Reset scroll position when filter changes to show top of list
|
||||
@@ -145,7 +175,7 @@ const Table: React.FC = () => {
|
||||
// Next frame compute range for new data
|
||||
requestAnimationFrame(recomputeRange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, rows.length]);
|
||||
}, [deferredSearch, rows.length]);
|
||||
|
||||
// Handles input changes for table rows
|
||||
const handleInputChange = (
|
||||
@@ -155,7 +185,16 @@ const Table: React.FC = () => {
|
||||
) => {
|
||||
setRows((prevRows) =>
|
||||
prevRows.map((row) =>
|
||||
row.losnummer === losnummer ? { ...row, [field]: value } : row
|
||||
row.losnummer === losnummer
|
||||
? {
|
||||
...row,
|
||||
[field]: value,
|
||||
_search: buildSearchText({
|
||||
...(row as DataPackage),
|
||||
[field]: value,
|
||||
} as DataPackage),
|
||||
}
|
||||
: row
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -196,10 +235,11 @@ const Table: React.FC = () => {
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
*/}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-auto rounded-lg shadow ring-1 ring-black/5"
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
|
Reference in New Issue
Block a user