refactor(frontend): decompose ConfigPage into dedicated config components
- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab, GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab - Add form components: JailFileForm, ActionForm, FilterForm - Add AutoSaveIndicator, RegexList, configStyles, and barrel index - ConfigPage now composes these components; greatly reduces file size - Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
212
frontend/src/components/config/MapTab.tsx
Normal file
212
frontend/src/components/config/MapTab.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* MapTab — world map color threshold configuration editor.
|
||||
*
|
||||
* Allows the user to set the low / medium / high ban-count thresholds
|
||||
* that drive country fill colors on the World Map page.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
} from "../../api/config";
|
||||
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner form — only mounted after data is loaded.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MapFormProps {
|
||||
initial: MapColorThresholdsResponse;
|
||||
}
|
||||
|
||||
function MapForm({ initial }: MapFormProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
|
||||
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
|
||||
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
|
||||
|
||||
const high = Number(thresholdHigh);
|
||||
const medium = Number(thresholdMedium);
|
||||
const low = Number(thresholdLow);
|
||||
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (isNaN(high) || isNaN(medium) || isNaN(low))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (high <= 0 || medium <= 0 || low <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(high > medium && medium > low))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [high, medium, low]);
|
||||
|
||||
// Only pass a new payload to useAutoSave when all values are valid.
|
||||
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: initial.threshold_high,
|
||||
threshold_medium: initial.threshold_medium,
|
||||
threshold_low: initial.threshold_low,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (validationError !== null) return;
|
||||
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
|
||||
}, [high, medium, low, validationError]);
|
||||
|
||||
const saveThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(validPayload, saveThresholds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={validationError ? "idle" : saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{validationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {thresholdLow}: Light green → Full green
|
||||
<br />• {thresholdLow} to {thresholdMedium}: Green → Yellow
|
||||
<br />• {thresholdMedium} to {thresholdHigh}: Yellow → Red
|
||||
<br />• {thresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outer loader component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component for editing world-map ban-count color thresholds.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function MapTab(): React.JSX.Element {
|
||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setThresholds(data);
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!thresholds && !loadError) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading map settings…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{loadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
if (!thresholds) return <></>;
|
||||
|
||||
return <MapForm initial={thresholds} />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user