Polish dashboard charts and add frontend tests (Stage 6)
Task 6.1 - Consistent loading/error/empty states across all charts: - Add ChartStateWrapper shared component with Spinner, error MessageBar + Retry button, and friendly empty message - Expose reload() in useBanTrend, useJailDistribution, useDashboardCountryData hooks - Update BanTrendChart and JailDistributionChart to use ChartStateWrapper - Add empty state to TopCountriesBarChart and TopCountriesPieChart - Replace manual loading/error logic in DashboardPage with ChartStateWrapper Task 6.2 - Frontend tests (5 files, 20 tests): - Install Vitest v4, jsdom, @testing-library/react, @testing-library/jest-dom - Add vitest.config.ts (separate from vite.config.ts to avoid Vite v5/v7 clash) - Add src/setupTests.ts with jest-dom matchers and ResizeObserver/matchMedia stubs - Tests: ChartStateWrapper (7), BanTrendChart (4), JailDistributionChart (4), TopCountriesPieChart (2), TopCountriesBarChart (3) Task 6.3 - Full QA: - ruff: clean - mypy --strict: 52 files, no issues - pytest: 497 passed - tsc --noEmit: clean - eslint: clean (added test-file override for explicit-function-return-type) - vite build: success
This commit is contained in:
@@ -15,10 +15,6 @@ import {
|
||||
} from "recharts";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import {
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
tokens,
|
||||
makeStyles,
|
||||
} from "@fluentui/react-components";
|
||||
@@ -28,6 +24,7 @@ import {
|
||||
CHART_PALETTE,
|
||||
resolveFluentToken,
|
||||
} from "../utils/chartTheme";
|
||||
import { ChartStateWrapper } from "./ChartStateWrapper";
|
||||
import { useBanTrend } from "../hooks/useBanTrend";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
|
||||
@@ -73,19 +70,9 @@ interface TrendEntry {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
width: "100%",
|
||||
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
chartWrapper: {
|
||||
width: "100%",
|
||||
},
|
||||
emptyText: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -190,6 +177,12 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||
* Fetches data via `useBanTrend` and handles loading, error, and empty states
|
||||
* inline so the parent only needs to pass filter props.
|
||||
*
|
||||
/**
|
||||
* Area chart showing ban counts over time.
|
||||
*
|
||||
* Fetches data via `useBanTrend` and delegates loading, error, and empty states
|
||||
* to `ChartStateWrapper`.
|
||||
*
|
||||
* @param props - `timeRange` and `origin` filter props.
|
||||
*/
|
||||
export function BanTrendChart({
|
||||
@@ -197,33 +190,9 @@ export function BanTrendChart({
|
||||
origin,
|
||||
}: BanTrendChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { buckets, isLoading, error } = useBanTrend(timeRange, origin);
|
||||
|
||||
if (error != null) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Spinner label="Loading trend data…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
|
||||
|
||||
const isEmpty = buckets.every((b) => b.count === 0);
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Text className={styles.emptyText}>No bans in this time range.</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = buildEntries(buckets, timeRange);
|
||||
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
|
||||
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
|
||||
@@ -231,40 +200,50 @@ export function BanTrendChart({
|
||||
const tickInterval = TICK_INTERVAL[timeRange];
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={TrendTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={primaryColour}
|
||||
strokeWidth={2}
|
||||
fill="url(#trendAreaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: primaryColour }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartStateWrapper
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={reload}
|
||||
isEmpty={isEmpty}
|
||||
emptyMessage="No bans in this time range."
|
||||
minHeight={MIN_CHART_HEIGHT}
|
||||
>
|
||||
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={TrendTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={primaryColour}
|
||||
strokeWidth={2}
|
||||
fill="url(#trendAreaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: primaryColour }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartStateWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user