Add DashboardFilterBar and move global filters to top of dashboard

- Create DashboardFilterBar component with time-range and origin-filter
  toggle-button groups in a single card row (Stage 7, Tasks 7.1–7.3)
- Integrate filter bar below ServerStatusBar in DashboardPage; remove
  filter toolbars from the Ban List section header (Task 7.2)
- Add 6 tests covering rendering, active-state reflection, and callbacks
- tsc --noEmit, eslint, npm run build, npm test all pass (27/27 tests)
This commit is contained in:
2026-03-11 19:05:52 +01:00
parent 0a73c49d01
commit 2f602e45f7
4 changed files with 458 additions and 53 deletions

View File

@@ -0,0 +1,163 @@
/**
* DashboardFilterBar — global filter toolbar for the dashboard.
*
* Renders the time-range presets and origin filter as two labelled groups of
* toggle buttons. The component is fully controlled: it owns no state and
* communicates changes through the provided callbacks.
*/
import {
Divider,
Text,
ToggleButton,
Toolbar,
makeStyles,
tokens,
} from "@fluentui/react-components";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import {
BAN_ORIGIN_FILTER_LABELS,
TIME_RANGE_LABELS,
} from "../types/ban";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for {@link DashboardFilterBar}. */
export interface DashboardFilterBarProps {
/** Currently selected time-range preset. */
timeRange: TimeRange;
/** Called when the user selects a different time-range preset. */
onTimeRangeChange: (value: TimeRange) => void;
/** Currently selected origin filter. */
originFilter: BanOriginFilter;
/** Called when the user selects a different origin filter. */
onOriginFilterChange: (value: BanOriginFilter) => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ordered time-range presets rendered as toggle buttons. */
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
/** Ordered origin filter options rendered as toggle buttons. */
const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"];
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
container: {
display: "flex",
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
paddingTop: tokens.spacingVerticalS,
paddingBottom: tokens.spacingVerticalS,
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalM,
},
group: {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: tokens.spacingHorizontalM,
},
divider: {
height: "24px",
paddingLeft: tokens.spacingHorizontalXL,
paddingRight: tokens.spacingHorizontalXL,
},
});
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Renders a global filter bar with time-range and origin-filter toggle buttons.
*
* The bar is fully controlled — it does not maintain its own state.
*
* @param props - See {@link DashboardFilterBarProps}.
*/
export function DashboardFilterBar({
timeRange,
onTimeRangeChange,
originFilter,
onOriginFilterChange,
}: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.container}>
{/* Time-range group */}
<div className={styles.group}>
<Text weight="semibold" size={300}>
Time Range
</Text>
<Toolbar aria-label="Time range" size="small">
{TIME_RANGES.map((r) => (
<ToggleButton
key={r}
size="small"
checked={timeRange === r}
aria-pressed={timeRange === r}
onClick={() => {
onTimeRangeChange(r);
}}
>
{TIME_RANGE_LABELS[r]}
</ToggleButton>
))}
</Toolbar>
</div>
{/* Visual separator */}
<div className={styles.divider}>
<Divider vertical />
</div>
{/* Origin-filter group */}
<div className={styles.group}>
<Text weight="semibold" size={300}>
Filter
</Text>
<Toolbar aria-label="Origin filter" size="small">
{ORIGIN_FILTERS.map((f) => (
<ToggleButton
key={f}
size="small"
checked={originFilter === f}
aria-pressed={originFilter === f}
onClick={() => {
onOriginFilterChange(f);
}}
>
{BAN_ORIGIN_FILTER_LABELS[f]}
</ToggleButton>
))}
</Toolbar>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
/**
* Tests for the DashboardFilterBar component.
*
* Covers rendering, active-state reflection, and callback invocation.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { DashboardFilterBar } from "../DashboardFilterBar";
import type { BanOriginFilter, TimeRange } from "../../types/ban";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface RenderProps {
timeRange?: TimeRange;
onTimeRangeChange?: (value: TimeRange) => void;
originFilter?: BanOriginFilter;
onOriginFilterChange?: (value: BanOriginFilter) => void;
}
function renderBar({
timeRange = "24h",
onTimeRangeChange = vi.fn<(value: TimeRange) => void>(),
originFilter = "all",
onOriginFilterChange = vi.fn<(value: BanOriginFilter) => void>(),
}: RenderProps = {}): {
onTimeRangeChange: (value: TimeRange) => void;
onOriginFilterChange: (value: BanOriginFilter) => void;
} {
render(
<FluentProvider theme={webLightTheme}>
<DashboardFilterBar
timeRange={timeRange}
onTimeRangeChange={onTimeRangeChange}
originFilter={originFilter}
onOriginFilterChange={onOriginFilterChange}
/>
</FluentProvider>,
);
return { onTimeRangeChange, onOriginFilterChange };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("DashboardFilterBar", () => {
// -------------------------------------------------------------------------
// 1. Renders all time-range buttons
// -------------------------------------------------------------------------
it("renders all four time-range buttons", () => {
renderBar();
expect(screen.getByRole("button", { name: /last 24 h/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /last 7 days/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /last 30 days/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /last 365 days/i })).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// 2. Renders all origin-filter buttons
// -------------------------------------------------------------------------
it("renders all three origin-filter buttons", () => {
renderBar();
expect(screen.getByRole("button", { name: /^all$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /blocklist/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /selfblock/i })).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// 3. Active state matches props
// -------------------------------------------------------------------------
it("marks the correct time-range button as active", () => {
renderBar({ timeRange: "7d" });
expect(
screen.getByRole("button", { name: /last 7 days/i }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByRole("button", { name: /last 24 h/i }),
).toHaveAttribute("aria-pressed", "false");
});
it("marks the correct origin-filter button as active", () => {
renderBar({ originFilter: "blocklist" });
expect(
screen.getByRole("button", { name: /blocklist/i }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByRole("button", { name: /^all$/i }),
).toHaveAttribute("aria-pressed", "false");
});
// -------------------------------------------------------------------------
// 4. Time-range click fires callback
// -------------------------------------------------------------------------
it("calls onTimeRangeChange with the correct value when a time-range button is clicked", async () => {
const user = userEvent.setup();
const { onTimeRangeChange } = renderBar({ timeRange: "24h" });
await user.click(screen.getByRole("button", { name: /last 30 days/i }));
expect(onTimeRangeChange).toHaveBeenCalledOnce();
expect(onTimeRangeChange).toHaveBeenCalledWith("30d");
});
// -------------------------------------------------------------------------
// 5. Origin-filter click fires callback
// -------------------------------------------------------------------------
it("calls onOriginFilterChange with the correct value when an origin button is clicked", async () => {
const user = userEvent.setup();
const { onOriginFilterChange } = renderBar({ originFilter: "all" });
await user.click(screen.getByRole("button", { name: /selfblock/i }));
expect(onOriginFilterChange).toHaveBeenCalledOnce();
expect(onOriginFilterChange).toHaveBeenCalledWith("selfblock");
});
// -------------------------------------------------------------------------
// 6. Already-active button click still fires callback
// -------------------------------------------------------------------------
it("calls onTimeRangeChange even when the active button is clicked again", async () => {
const user = userEvent.setup();
const { onTimeRangeChange } = renderBar({ timeRange: "24h" });
await user.click(screen.getByRole("button", { name: /last 24 h/i }));
expect(onTimeRangeChange).toHaveBeenCalledOnce();
expect(onTimeRangeChange).toHaveBeenCalledWith("24h");
});
});

View File

@@ -7,23 +7,17 @@
*/
import { useState } from "react";
import {
Text,
ToggleButton,
Toolbar,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { JailDistributionChart } from "../components/JailDistributionChart";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
// ---------------------------------------------------------------------------
@@ -82,16 +76,6 @@ const useStyles = makeStyles({
},
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ordered time-range presets for the toolbar. */
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
/** Ordered origin filter options for the toolbar. */
const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -117,6 +101,16 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
<ServerStatusBar />
{/* ------------------------------------------------------------------ */}
{/* Global filter bar */}
{/* ------------------------------------------------------------------ */}
<DashboardFilterBar
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
originFilter={originFilter}
onOriginFilterChange={setOriginFilter}
/>
{/* ------------------------------------------------------------------ */}
{/* Ban Trend section */}
{/* ------------------------------------------------------------------ */}
@@ -188,40 +182,6 @@ export function DashboardPage(): React.JSX.Element {
<Text as="h2" size={500} weight="semibold">
Ban List
</Text>
{/* Time-range selector */}
<Toolbar aria-label="Time range" size="small">
{TIME_RANGES.map((r) => (
<ToggleButton
key={r}
size="small"
checked={timeRange === r}
onClick={() => {
setTimeRange(r);
}}
aria-pressed={timeRange === r}
>
{TIME_RANGE_LABELS[r]}
</ToggleButton>
))}
</Toolbar>
{/* Origin filter */}
<Toolbar aria-label="Origin filter" size="small">
{ORIGIN_FILTERS.map((f) => (
<ToggleButton
key={f}
size="small"
checked={originFilter === f}
onClick={() => {
setOriginFilter(f);
}}
aria-pressed={originFilter === f}
>
{BAN_ORIGIN_FILTER_LABELS[f]}
</ToggleButton>
))}
</Toolbar>
</div>
{/* Ban table */}