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:
163
frontend/src/components/DashboardFilterBar.tsx
Normal file
163
frontend/src/components/DashboardFilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/__tests__/DashboardFilterBar.test.tsx
Normal file
128
frontend/src/components/__tests__/DashboardFilterBar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user