feat: optimize table component with deferred search and dynamic container height

This commit is contained in:
2025-08-14 12:01:38 +02:00
parent 9d64e7c274
commit 40d5f35afb

View File

@@ -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 Cookies from "js-cookie";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getTableData, readCachedTableData } from "../utils/userHandler"; import { getTableData, readCachedTableData } from "../utils/userHandler";
@@ -13,6 +19,7 @@ interface DataPackage {
adresse: string | null; adresse: string | null;
plz: string | null; plz: string | null;
email: string | null; email: string | null;
_search?: string;
[key: string]: any; [key: string]: any;
} }
@@ -20,14 +27,29 @@ const Table: React.FC = () => {
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const firstRowRef = useRef<HTMLTableRowElement | null>(null); const firstRowRef = useRef<HTMLTableRowElement | null>(null);
const [rowHeight, setRowHeight] = useState<number>(0); const [rowHeight, setRowHeight] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>();
const [range, setRange] = useState<{ start: number; end: number }>({ const [range, setRange] = useState<{ start: number; end: number }>({
start: 0, start: 0,
end: 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) // Einheitliche Input-Styles (nur Tailwind)
const inputClasses = const inputClasses =
@@ -41,7 +63,9 @@ const Table: React.FC = () => {
return; return;
} }
// Server könnte entweder ein Objekt oder ein Array liefern // 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); setRows(normalized);
}; };
@@ -79,22 +103,10 @@ const Table: React.FC = () => {
// Filter rows by search query (case-insensitive) // Filter rows by search query (case-insensitive)
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase(); const q = deferredSearch.trim().toLowerCase();
if (!q) return rows; if (!q) return rows;
return rows.filter((r) => { return rows.filter((r) => (r._search ?? buildSearchText(r)).includes(q));
const values = [ }, [rows, deferredSearch]);
r.losnummer,
r.vorname ?? "",
r.nachname ?? "",
r.adresse ?? "",
r.plz ?? "",
r.email ?? "",
]
.join(" ")
.toLowerCase();
return values.includes(q);
});
}, [rows, search]);
// Measure row height once the first visible row mounts // Measure row height once the first visible row mounts
useEffect(() => { useEffect(() => {
@@ -137,6 +149,24 @@ const Table: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowHeight, filteredRows.length]); }, [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) // Recompute range whenever data set changes significantly (search, data load)
useEffect(() => { useEffect(() => {
// Reset scroll position when filter changes to show top of list // 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 // Next frame compute range for new data
requestAnimationFrame(recomputeRange); requestAnimationFrame(recomputeRange);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, rows.length]); }, [deferredSearch, rows.length]);
// Handles input changes for table rows // Handles input changes for table rows
const handleInputChange = ( const handleInputChange = (
@@ -155,7 +185,16 @@ const Table: React.FC = () => {
) => { ) => {
setRows((prevRows) => setRows((prevRows) =>
prevRows.map((row) => 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 Refresh
</button> </button>
</div> </div>
*/} */}
<div <div
ref={containerRef} ref={containerRef}
className="overflow-auto rounded-lg shadow ring-1 ring-black/5" 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"> <table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 sticky top-0"> <thead className="bg-gray-50 sticky top-0">