feature/ignore-self-toggle #1
154
Docs/Tasks.md
154
Docs/Tasks.md
@@ -527,3 +527,157 @@ Run the complete quality-assurance pipeline:
|
|||||||
- Build artifacts generated successfully.
|
- 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 `<Toolbar>` groups separated by a visual divider (use Fluent UI `<Divider vertical>` or a horizontal gap of `spacingHorizontalXL`).
|
||||||
|
- **Left group** — "Time Range" label + four `<ToggleButton>` 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 `<ToggleButton>` options:
|
||||||
|
- `All` (value `"all"`)
|
||||||
|
- `Blocklist` (value `"blocklist"`)
|
||||||
|
- `Selfblock` (value `"selfblock"`)
|
||||||
|
- Each group label is a `<Text weight="semibold" size={300}>` 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** `<DashboardFilterBar>` immediately **below** `<ServerStatusBar />` and **above** the "Ban Trend" section. Pass the existing `timeRange`, `setTimeRange`, `originFilter`, and `setOriginFilter` as props.
|
||||||
|
2. **Remove** the two `<Toolbar>` blocks (time-range selector and origin filter) that are currently inside the "Ban List" section header. The section header should keep only the `<Text as="h2">Ban List</Text>` 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 `<FluentProvider theme={webLightTheme}>` (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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
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 { useState } from "react";
|
||||||
import {
|
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||||
Text,
|
|
||||||
ToggleButton,
|
|
||||||
Toolbar,
|
|
||||||
makeStyles,
|
|
||||||
tokens,
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import { ChartStateWrapper } from "../components/ChartStateWrapper";
|
|
||||||
import { BanTable } from "../components/BanTable";
|
import { BanTable } from "../components/BanTable";
|
||||||
import { BanTrendChart } from "../components/BanTrendChart";
|
import { BanTrendChart } from "../components/BanTrendChart";
|
||||||
|
import { ChartStateWrapper } from "../components/ChartStateWrapper";
|
||||||
|
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||||
import { JailDistributionChart } from "../components/JailDistributionChart";
|
import { JailDistributionChart } from "../components/JailDistributionChart";
|
||||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||||
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
||||||
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
||||||
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
|
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
|
||||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -117,6 +101,16 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<ServerStatusBar />
|
<ServerStatusBar />
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
{/* Global filter bar */}
|
||||||
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
<DashboardFilterBar
|
||||||
|
timeRange={timeRange}
|
||||||
|
onTimeRangeChange={setTimeRange}
|
||||||
|
originFilter={originFilter}
|
||||||
|
onOriginFilterChange={setOriginFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* Ban Trend section */}
|
{/* Ban Trend section */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
@@ -188,40 +182,6 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
<Text as="h2" size={500} weight="semibold">
|
<Text as="h2" size={500} weight="semibold">
|
||||||
Ban List
|
Ban List
|
||||||
</Text>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Ban table */}
|
{/* Ban table */}
|
||||||
|
|||||||
Reference in New Issue
Block a user