Compare commits
3 Commits
15d53a8e96
...
v0.9.19
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f75db75f | |||
| 554c75247f | |||
| 6e2abe9d97 |
@@ -1 +1 @@
|
|||||||
v0.9.18
|
v0.9.19
|
||||||
|
|||||||
@@ -71,61 +71,3 @@ When a country is selected the companion table **must** return the complete set
|
|||||||
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
|
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TASK-002 — WorldMap: sticky table header and sticky pagination bar
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
**Priority:** Low
|
|
||||||
**Domain:** Frontend only
|
|
||||||
**References:** `Docs/Features.md §4`, `Docs/Web-Design.md`, `Docs/Web-Development.md`
|
|
||||||
|
|
||||||
#### Background
|
|
||||||
|
|
||||||
The companion ban table in `MapPage.tsx` is wrapped in `tableWrapper` (CSS `overflow: auto; maxHeight: 420px`). Both the Fluent UI `TableHeader` row and the `.pagination` div inside `tableWrapper` scroll with the content. Once the user scrolls more than a few rows, the column header labels disappear and the pagination controls become unreachable without scrolling back to the top or bottom.
|
|
||||||
|
|
||||||
#### Desired behaviour
|
|
||||||
|
|
||||||
- The column header row (`TableHeader →TableRow → TableHeaderCell × 6`) must remain fixed at the **top** of the scrollable container at all times.
|
|
||||||
- The pagination / page-size bar (`.pagination` div at the bottom of `tableWrapper`) must remain fixed at the **bottom** of the scrollable container at all times.
|
|
||||||
- Rows in `TableBody` scroll normally between the two fixed ends.
|
|
||||||
- No changes to the container height, overall layout, or other pages.
|
|
||||||
|
|
||||||
#### Implementation steps
|
|
||||||
|
|
||||||
All changes are in `frontend/src/pages/MapPage.tsx`.
|
|
||||||
|
|
||||||
1. **Sticky table header cells**
|
|
||||||
- In `useStyles` (`makeStyles`), add a new class:
|
|
||||||
```ts
|
|
||||||
stickyHeaderCell: {
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
|
||||||
boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`,
|
|
||||||
},
|
|
||||||
```
|
|
||||||
- Apply `className={styles.stickyHeaderCell}` to **each** `TableHeaderCell` in the header row.
|
|
||||||
- Note: `position: sticky` on `<tr>` elements is unreliable across browsers for table layouts; apply it to each `<th>` (`TableHeaderCell`) instead.
|
|
||||||
|
|
||||||
2. **Sticky pagination bar**
|
|
||||||
- In the existing `pagination` entry in `useStyles`, add:
|
|
||||||
```ts
|
|
||||||
position: "sticky",
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
```
|
|
||||||
- The existing `backgroundColor: tokens.colorNeutralBackground2` already prevents table rows from bleeding through.
|
|
||||||
|
|
||||||
3. **No other changes** — do not alter `tableWrapper`, its height, or anything outside `MapPage.tsx`.
|
|
||||||
|
|
||||||
#### Testing guidance
|
|
||||||
|
|
||||||
- Load the Map page with a time range that produces > 25 bans (enough to overflow the `420px` container).
|
|
||||||
- Scroll down through the table and confirm the column headers remain visible at the top.
|
|
||||||
- Scroll down and confirm the pagination bar remains visible at the bottom.
|
|
||||||
- Verify no visual artefacts (table body rows must not overlap or bleed through the sticky elements).
|
|
||||||
- Run `tsc --noEmit` — zero type errors expected.
|
|
||||||
- Run existing frontend tests: `vitest run` — no regressions.
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.18",
|
"version": "0.9.19",
|
||||||
"description": "BanGUI frontend — fail2ban web management interface",
|
"description": "BanGUI frontend — fail2ban web management interface",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -208,10 +208,16 @@ export function WorldMap({
|
|||||||
[onSelectCountry, selectedCountry],
|
[onSelectCountry, selectedCountry],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** SVG-level click handler — paths never receive click when pointer capture
|
||||||
|
* is active on the SVG, so we resolve the target via the data-cc attribute. */
|
||||||
|
const handleSvgClick = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
const target = (event.target as Element).closest('[data-cc]');
|
||||||
|
const cc = target?.getAttribute('data-cc') ?? null;
|
||||||
|
if (cc) handleCountrySelect(cc);
|
||||||
|
}, [handleCountrySelect]);
|
||||||
|
|
||||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
active: true,
|
active: true,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
@@ -231,6 +237,7 @@ export function WorldMap({
|
|||||||
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||||
drag.moved = true;
|
drag.moved = true;
|
||||||
clickSuppressedRef.current = true;
|
clickSuppressedRef.current = true;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
||||||
@@ -332,6 +339,7 @@ export function WorldMap({
|
|||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={handlePointerUp}
|
onPointerLeave={handlePointerUp}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
|
onClick={handleSvgClick}
|
||||||
>
|
>
|
||||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||||
{countryFeatures.map((featureItem) => {
|
{countryFeatures.map((featureItem) => {
|
||||||
@@ -350,6 +358,7 @@ export function WorldMap({
|
|||||||
<g key={String(rawId)}>
|
<g key={String(rawId)}>
|
||||||
<path
|
<path
|
||||||
d={pathString}
|
d={pathString}
|
||||||
|
data-cc={cc ?? undefined}
|
||||||
role={cc ? "button" : undefined}
|
role={cc ? "button" : undefined}
|
||||||
tabIndex={cc ? 0 : undefined}
|
tabIndex={cc ? 0 : undefined}
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -373,9 +382,7 @@ export function WorldMap({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
if (cc) {
|
if (cc) handleCountrySelect(cc);
|
||||||
handleCountrySelect(cc);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event): void => {
|
onKeyDown={(event): void => {
|
||||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export function MapPage(): React.JSX.Element {
|
|||||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||||
useMapData(range, originFilter, source, selectedCountry ?? undefined);
|
useMapData(range, originFilter, source, selectedCountry ?? undefined);
|
||||||
|
|
||||||
|
// True after the first successful data load — keeps the map mounted
|
||||||
|
// during subsequent re-fetches so country selection gives instant feedback.
|
||||||
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !error) setHasLoadedOnce(true);
|
||||||
|
}, [loading, error]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
thresholds: mapThresholds,
|
thresholds: mapThresholds,
|
||||||
error: mapThresholdError,
|
error: mapThresholdError,
|
||||||
@@ -195,7 +202,8 @@ export function MapPage(): React.JSX.Element {
|
|||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && !error && (
|
{/* Initial load spinner — only shown before the first data arrives. */}
|
||||||
|
{loading && !error && !hasLoadedOnce && (
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
||||||
<Spinner label="Loading map data…" />
|
<Spinner label="Loading map data…" />
|
||||||
</div>
|
</div>
|
||||||
@@ -203,8 +211,10 @@ export function MapPage(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{/* World map */}
|
{/* World map */}
|
||||||
|
{/* Keep the map mounted after first load so clicking a country gives */}
|
||||||
|
{/* immediate visual feedback before the filtered data arrives. */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{!loading && !error && (
|
{!error && hasLoadedOnce && (
|
||||||
<WorldMap
|
<WorldMap
|
||||||
countries={countries}
|
countries={countries}
|
||||||
countryNames={countryNames}
|
countryNames={countryNames}
|
||||||
@@ -242,19 +252,22 @@ export function MapPage(): React.JSX.Element {
|
|||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{/* Summary line */}
|
{/* Summary line */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{!loading && !error && (
|
{!error && hasLoadedOnce && (
|
||||||
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
||||||
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
{" · "}
|
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
||||||
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
{" · "}
|
||||||
</Text>
|
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
||||||
|
</Text>
|
||||||
|
{loading && <Spinner size="tiny" />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{/* Companion bans table */}
|
{/* Companion bans table */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{!loading && !error && (
|
{!error && hasLoadedOnce && (
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper} style={{ opacity: loading ? 0.5 : 1, transition: "opacity 150ms" }}>
|
||||||
<Table size="small" aria-label="Bans list">
|
<Table size="small" aria-label="Bans list">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user