diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 3f84d94..ddc03f8 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -527,3 +527,157 @@ Run the complete quality-assurance pipeline: - Build artifacts generated successfully. --- + +## Stage 7 — Global Dashboard Filter Bar + +The time-range and origin-filter controls currently live inside the "Ban List" section header, but they control **every** section on the dashboard (Ban Trend, Top Countries, Jail Distribution, **and** Ban List). This creates a misleading UX: the buttons appear scoped to the ban list when they are actually global. This stage extracts those controls into a dedicated, always-visible filter bar at the top of the dashboard, directly below the `ServerStatusBar`. + +### Task 7.1 — Create the `DashboardFilterBar` component + +**Status:** `done` + +Create `frontend/src/components/DashboardFilterBar.tsx`. This is a self-contained toolbar component that renders the time-range presets and origin filter as two groups of toggle buttons. + +**Props interface:** + +```ts +interface DashboardFilterBarProps { + timeRange: TimeRange; + onTimeRangeChange: (value: TimeRange) => void; + originFilter: BanOriginFilter; + onOriginFilterChange: (value: BanOriginFilter) => void; +} +``` + +**Visual requirements:** + +- Render inside a card-like container using the existing `section` style pattern (neutral background, border, border-radius, padding) — but **without** a section title. The toolbar **is** the content. +- Layout: a single row with two `` groups separated by a visual divider (use Fluent UI `` or a horizontal gap of `spacingHorizontalXL`). + - **Left group** — "Time Range" label + four `` presets: + - `Last 24 h` (value `"24h"`) + - `Last 7 days` (value `"7d"`) + - `Last 30 days` (value `"30d"`) + - `Last 365 days` (value `"365d"`) + - **Right group** — "Filter" label + three `` options: + - `All` (value `"all"`) + - `Blocklist` (value `"blocklist"`) + - `Selfblock` (value `"selfblock"`) +- Each group label is a `` rendered inline before the buttons. +- Use `size="small"` on all toggle buttons. The active button uses `checked={true}` and `aria-pressed={true}`. +- On narrow viewports (< 640 px), the two groups should **wrap** onto separate lines (use `flexWrap: "wrap"` on the outer container). +- Reuse `TIME_RANGE_LABELS` and `BAN_ORIGIN_FILTER_LABELS` from `types/ban.ts` — no hard-coded label strings. +- Use `makeStyles` for all styling. Follow [Web-Design.md](Web-Design.md) spacing conventions: `spacingHorizontalM` between buttons within a group, `spacingHorizontalXL` between groups, `spacingVerticalS` for vertical padding. + +**Behaviour:** + +- Clicking a time-range button calls `onTimeRangeChange(value)`. +- Clicking an origin-filter button calls `onOriginFilterChange(value)`. +- Exactly one button per group is active at any time (mutually exclusive — not multi-select). +- Component is fully controlled: it does not own state, it receives and reports values only. + +**File structure rules:** + +- One component per file. No barrel exports needed — import directly. +- Keep under 100 lines. + +**Acceptance criteria:** + +- The component renders two labelled button groups in a single row. +- Calls the correct callback with the correct value when a button is clicked. +- Buttons reflect the current selection via `checked` / `aria-pressed`. +- Wraps gracefully on narrow viewports. +- `tsc --noEmit` passes. No `any`. ESLint clean. + +--- + +### Task 7.2 — Integrate `DashboardFilterBar` into `DashboardPage` + +**Status:** `done` + +Move the global filter controls out of the "Ban List" section and replace them with the new `DashboardFilterBar`, placed at the top of the page. + +**Changes to `DashboardPage.tsx`:** + +1. **Add** `` immediately **below** `` and **above** the "Ban Trend" section. Pass the existing `timeRange`, `setTimeRange`, `originFilter`, and `setOriginFilter` as props. +2. **Remove** the two `` blocks (time-range selector and origin filter) that are currently inside the "Ban List" section header. The section header should keep only the `Ban List` title — no filter buttons. +3. **Remove** the `TIME_RANGES` and `ORIGIN_FILTERS` local constant arrays from `DashboardPage.tsx` since the `DashboardFilterBar` component now owns the iteration. (If `DashboardFilterBar` re-uses these arrays, it defines them locally or imports them from `types/ban.ts`.) +4. **Keep** the `timeRange` and `originFilter` state (`useState`) in `DashboardPage` — the page still owns the state; it just no longer renders the buttons directly. +5. **Verify** that all sections (Ban Trend, Top Countries, Jail Distribution, Ban List) still receive the filter values as props and re-render when they change — this should already work since the state location is unchanged. + +**Layout after change:** + +``` +┌──────────────────────────────────────┐ +│ ServerStatusBar │ +├──────────────────────────────────────┤ +│ DashboardFilterBar │ ← NEW location +│ [24h] [7d] [30d] [365d] │ [All] [Blocklist] [Selfblock] │ +├──────────────────────────────────────┤ +│ Ban Trend (chart) │ +├──────────────────────────────────────┤ +│ Top Countries (pie + bar) │ +├──────────────────────────────────────┤ +│ Jail Distribution (bar) │ +├──────────────────────────────────────┤ +│ Ban List (table) │ ← filters REMOVED from here +└──────────────────────────────────────┘ +``` + +**Acceptance criteria:** + +- The filter bar is visible at the top of the dashboard, below the status bar. +- Changing a filter updates all four sections simultaneously. +- The "Ban List" section header no longer contains filter buttons. +- No functional regression — the dashboard behaves identically, filters are just relocated. +- `tsc --noEmit` and ESLint pass. + +--- + +### Task 7.3 — Write tests for `DashboardFilterBar` + +**Status:** `done` + +Create `frontend/src/components/__tests__/DashboardFilterBar.test.tsx`. + +**Test cases:** + +1. **Renders all time-range buttons** — confirm four buttons with correct labels appear. +2. **Renders all origin-filter buttons** — confirm three buttons with correct labels appear. +3. **Active state matches props** — given `timeRange="7d"` and `originFilter="blocklist"`, the corresponding buttons have `aria-pressed="true"` and the others `"false"`. +4. **Time-range click fires callback** — click the "Last 30 days" button, assert `onTimeRangeChange` was called with `"30d"`. +5. **Origin-filter click fires callback** — click the "Selfblock" button, assert `onOriginFilterChange` was called with `"selfblock"`. +6. **Already-active button click still fires callback** — clicking the currently active button should still call the callback (no no-op guard). + +**Test setup:** + +- Wrap the component in `` (required for Fluent UI token resolution). +- Use `vi.fn()` for the callback props. +- Follow the existing test patterns in `frontend/src/components/__tests__/`. + +**Acceptance criteria:** + +- All 6 test cases pass. +- Tests are fully typed — no `any`. +- ESLint clean. + +--- + +### Task 7.4 — Final lint, type-check, and build verification + +**Status:** `done` + +Run the full quality-assurance pipeline after the filter-bar changes: + +1. `tsc --noEmit` — zero errors. +2. `npm run lint` — zero warnings, zero errors. +3. `npm run build` — succeeds. +4. `npm test` — all frontend tests pass (including the new `DashboardFilterBar` tests). +5. Backend: `ruff check`, `mypy --strict`, `pytest` — still green (no backend changes expected, but verify no accidental modifications). + +**Acceptance criteria:** + +- Zero lint warnings/errors. +- All tests pass on both frontend and backend. +- Production build succeeds. + +--- diff --git a/frontend/src/components/DashboardFilterBar.tsx b/frontend/src/components/DashboardFilterBar.tsx new file mode 100644 index 0000000..8ab9398 --- /dev/null +++ b/frontend/src/components/DashboardFilterBar.tsx @@ -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 ( +
+ {/* Time-range group */} +
+ + Time Range + + + {TIME_RANGES.map((r) => ( + { + onTimeRangeChange(r); + }} + > + {TIME_RANGE_LABELS[r]} + + ))} + +
+ + {/* Visual separator */} +
+ +
+ + {/* Origin-filter group */} +
+ + Filter + + + {ORIGIN_FILTERS.map((f) => ( + { + onOriginFilterChange(f); + }} + > + {BAN_ORIGIN_FILTER_LABELS[f]} + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/__tests__/DashboardFilterBar.test.tsx b/frontend/src/components/__tests__/DashboardFilterBar.test.tsx new file mode 100644 index 0000000..512c342 --- /dev/null +++ b/frontend/src/components/__tests__/DashboardFilterBar.test.tsx @@ -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( + + + , + ); + 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"); + }); +}); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 488d880..b3e55d6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 { {/* ------------------------------------------------------------------ */} + {/* ------------------------------------------------------------------ */} + {/* Global filter bar */} + {/* ------------------------------------------------------------------ */} + + {/* ------------------------------------------------------------------ */} {/* Ban Trend section */} {/* ------------------------------------------------------------------ */} @@ -188,40 +182,6 @@ export function DashboardPage(): React.JSX.Element { Ban List - - {/* Time-range selector */} - - {TIME_RANGES.map((r) => ( - { - setTimeRange(r); - }} - aria-pressed={timeRange === r} - > - {TIME_RANGE_LABELS[r]} - - ))} - - - {/* Origin filter */} - - {ORIGIN_FILTERS.map((f) => ( - { - setOriginFilter(f); - }} - aria-pressed={originFilter === f} - > - {BAN_ORIGIN_FILTER_LABELS[f]} - - ))} - {/* Ban table */}