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 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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -200,6 +239,7 @@ const Table: React.FC = () => {
|
|||||||
<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">
|
||||||
|
Reference in New Issue
Block a user