feat: basic/advanced config split with profile section gate

- FieldSchema gains optional `advanced` boolean flag
- ConfigEditor reads schema at top level and passes sectionSchema as prop
- ConfigSectionForm filters out advanced fields by default; "Show advanced"
  toggle reveals them without entering edit mode
- Profile (Difficulty) section shows an inline banner when
  forced_difficulty is not "Custom", guiding users to the right setting
- All 173 frontend tests pass; tsc clean
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-20 10:49:08 +07:00
parent 03ea623536
commit 64b35a7aaf
3 changed files with 319 additions and 22 deletions

View File

@@ -0,0 +1,210 @@
/**
* TDD RED → GREEN: basic/advanced field filtering and profile section gate.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("@/lib/api", () => ({
apiClient: {
get: vi.fn(),
put: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock("@/lib/logger", () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
import { apiClient } from "@/lib/api";
import { ConfigEditor } from "@/components/servers/ConfigEditor";
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const BASIC_SERVER_SECTION = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
hostname: "My Server",
max_players: 10,
kick_duplicate: 1, // advanced
server_command_password: "", // advanced
vote_threshold: 0.33, // advanced
};
const SCHEMA_WITH_ADVANCED = {
server: {
hostname: { widget: "text", label: "Hostname", advanced: false },
max_players: { widget: "number", label: "Max Players", advanced: false },
kick_duplicate: { widget: "toggle", label: "Kick Duplicate", advanced: true },
server_command_password: { widget: "password", label: "SC Password", advanced: true },
vote_threshold: { widget: "number", label: "Vote Threshold", advanced: true },
},
};
const PROFILE_SECTION = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
group_indicators: 1,
reduced_damage: 0,
};
const PROFILE_SCHEMA = {
profile: {
group_indicators: { widget: "toggle", label: "Group Indicators", advanced: false },
reduced_damage: { widget: "toggle", label: "Reduced Damage", advanced: true },
},
};
describe("ConfigEditor — basic/advanced filtering", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
function mockServerConfig(sections: Record<string, Record<string, unknown>>) {
const configMap: Record<string, unknown> = {};
for (const [k, v] of Object.entries(sections)) {
configMap[k] = v;
}
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config/schema")) {
return Promise.resolve({ data: { success: true, data: { ...SCHEMA_WITH_ADVANCED } } });
}
if (url.endsWith("/config")) {
return Promise.resolve({ data: { success: true, data: configMap } });
}
if (url.includes("/config/")) {
const section = url.split("/config/")[1];
return Promise.resolve({ data: { success: true, data: sections[section] ?? {} } });
}
return Promise.resolve({ data: { success: true, data: {} } });
});
}
it("hides advanced fields by default", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
expect(screen.getByText("Hostname")).toBeInTheDocument();
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
expect(screen.queryByText("SC Password")).not.toBeInTheDocument();
});
it("shows advanced fields after clicking Show advanced", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
fireEvent.click(screen.getByText(/show advanced/i));
expect(screen.getByText("Kick Duplicate")).toBeInTheDocument();
expect(screen.getByText("SC Password")).toBeInTheDocument();
});
it("hides advanced fields again after toggling off", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
fireEvent.click(screen.getByText(/show advanced/i));
await screen.findByText("Kick Duplicate");
fireEvent.click(screen.getByText(/hide advanced/i));
await waitFor(() => {
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
});
});
it("shows all fields when no schema is available (graceful fallback)", async () => {
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config")) {
return Promise.resolve({
data: { success: true, data: { server: BASIC_SERVER_SECTION } },
});
}
if (url.includes("/config/server")) {
return Promise.resolve({ data: { success: true, data: BASIC_SERVER_SECTION } });
}
if (url.endsWith("/config/schema")) {
return Promise.resolve({ data: { success: true, data: {} } }); // no schema
}
return Promise.resolve({ data: { success: true, data: {} } });
});
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
// Without schema, fields have no advanced flag — all should display
expect(screen.getByText("Hostname")).toBeInTheDocument();
});
});
describe("ConfigEditor — profile section gate", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
function mockWithDifficulty(difficulty: string) {
const serverSectionData = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
forced_difficulty: difficulty,
hostname: "My Server",
};
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config/schema")) {
return Promise.resolve({
data: {
success: true,
data: {
server: {
forced_difficulty: { widget: "select", label: "Difficulty", advanced: false, options: ["Recruit", "Regular", "Veteran", "Custom"] },
hostname: { widget: "text", label: "Hostname", advanced: false },
},
...PROFILE_SCHEMA,
},
},
});
}
if (url.endsWith("/config")) {
return Promise.resolve({
data: { success: true, data: { server: serverSectionData, profile: PROFILE_SECTION } },
});
}
if (url.includes("/config/server")) {
return Promise.resolve({ data: { success: true, data: serverSectionData } });
}
if (url.includes("/config/profile")) {
return Promise.resolve({ data: { success: true, data: PROFILE_SECTION } });
}
return Promise.resolve({ data: { success: true, data: {} } });
});
}
it("shows profile warning banner when difficulty is not Custom", async () => {
mockWithDifficulty("Regular");
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
// Switch to Difficulty tab
await screen.findByText("Difficulty");
fireEvent.click(screen.getByText("Difficulty"));
await screen.findByText(/only apply when/i);
});
it("does not show profile warning banner when difficulty is Custom", async () => {
mockWithDifficulty("Custom");
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Difficulty");
fireEvent.click(screen.getByText("Difficulty"));
// Fields should show without the gating banner
await screen.findByText("Group Indicators");
expect(screen.queryByText(/only apply when/i)).not.toBeInTheDocument();
});
});

View File

@@ -35,6 +35,7 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const addNotification = useUIStore((s) => s.addNotification); const addNotification = useUIStore((s) => s.addNotification);
const { data: configMap, isLoading } = useServerConfig(serverId); const { data: configMap, isLoading } = useServerConfig(serverId);
const { data: schema } = useServerConfigSchema(serverId);
const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : []; const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : [];
const [activeSection, setActiveSection] = useState<string>(sections[0] ?? ""); const [activeSection, setActiveSection] = useState<string>(sections[0] ?? "");
@@ -49,6 +50,11 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
const currentSection = activeSection || sections[0]; const currentSection = activeSection || sections[0];
// Derive forced_difficulty from server section for profile gate
const serverSection = configMap["server"] as Record<string, unknown> | undefined;
const forcedDifficulty = serverSection?.["forced_difficulty"] as string | undefined;
const profileGated = currentSection === "profile" && forcedDifficulty !== "Custom";
return ( return (
<div data-testid="config-editor"> <div data-testid="config-editor">
<div className="flex gap-1 mb-3 overflow-x-auto"> <div className="flex gap-1 mb-3 overflow-x-auto">
@@ -72,11 +78,18 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p> <p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
)} )}
{profileGated && (
<div className="rounded-lg border border-surface-raised bg-surface-overlay px-4 py-3 mb-4 text-sm text-text-secondary">
These settings only apply when <strong>Forced Difficulty</strong> is set to <strong>Custom</strong> in the Server tab. Current value: <em>{forcedDifficulty ?? "—"}</em>
</div>
)}
{currentSection && ( {currentSection && (
<ConfigSectionForm <ConfigSectionForm
key={currentSection} key={currentSection}
serverId={serverId} serverId={serverId}
section={currentSection} section={currentSection}
sectionSchema={schema?.[currentSection] ?? {}}
isAdmin={isAdmin} isAdmin={isAdmin}
addNotification={addNotification} addNotification={addNotification}
/> />
@@ -88,19 +101,21 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
function ConfigSectionForm({ function ConfigSectionForm({
serverId, serverId,
section, section,
sectionSchema,
isAdmin, isAdmin,
addNotification, addNotification,
}: { }: {
serverId: number; serverId: number;
section: string; section: string;
sectionSchema: Record<string, FieldSchema>;
isAdmin: boolean; isAdmin: boolean;
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void; addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
}) { }) {
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section); const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
const { data: schema } = useServerConfigSchema(serverId);
const updateSection = useUpdateConfigSection(serverId, section); const updateSection = useUpdateConfigSection(serverId, section);
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null); const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({}); const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
if (isLoading) { if (isLoading) {
return <div className="text-text-muted text-sm">Loading section...</div>; return <div className="text-text-muted text-sm">Loading section...</div>;
@@ -110,12 +125,23 @@ function ConfigSectionForm({
return <div className="text-text-muted text-sm">No data for this section.</div>; return <div className="text-text-muted text-sm">No data for this section.</div>;
} }
const sectionSchema = schema?.[section] ?? {}; const hasAdvancedFields = Object.values(sectionSchema).some((f) => f.advanced === true);
const fields = Object.entries(sectionData).filter(([key]) => {
const allFields = Object.entries(sectionData).filter(([key]) => {
if (key === "_meta") return false; if (key === "_meta") return false;
if (sectionSchema[key]?.widget === "hidden") return false; if (sectionSchema[key]?.widget === "hidden") return false;
return true; return true;
}); });
// When schema has advanced flags, filter by them; otherwise show everything
const schemaHasFlags = Object.keys(sectionSchema).length > 0 && Object.values(sectionSchema).some((f) => "advanced" in f);
const fields = schemaHasFlags
? allFields.filter(([key]) => {
const fieldSchema = sectionSchema[key];
if (!fieldSchema || fieldSchema.advanced === undefined) return true;
return showAdvanced ? true : !fieldSchema.advanced;
})
: allFields;
const meta = sectionData._meta; const meta = sectionData._meta;
const displayValues = editValues ?? Object.fromEntries(fields); const displayValues = editValues ?? Object.fromEntries(fields);
const isEditing = editValues !== null; const isEditing = editValues !== null;
@@ -131,7 +157,11 @@ function ConfigSectionForm({
}; };
const handleSave = async () => { const handleSave = async () => {
if (!editValues || !meta) return; if (!editValues) return;
if (!meta) {
addNotification({ type: "error", message: "Cannot save: config metadata is missing. Please refresh the page." });
return;
}
try { try {
await updateSection.mutateAsync({ await updateSection.mutateAsync({
config_version: meta.config_version, config_version: meta.config_version,
@@ -167,9 +197,19 @@ function ConfigSectionForm({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-text-muted text-xs"> <div className="flex items-center gap-3">
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"} <p className="text-text-muted text-xs">
</p> Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
</p>
{hasAdvancedFields && !isEditing && (
<button
onClick={() => setShowAdvanced((v) => !v)}
className="text-xs text-text-secondary hover:text-text-primary underline underline-offset-2"
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
)}
</div>
{isAdmin && !isEditing && ( {isAdmin && !isEditing && (
<button onClick={handleEdit} className="btn-ghost text-sm"> <button onClick={handleEdit} className="btn-ghost text-sm">
Edit Edit
@@ -197,7 +237,7 @@ function ConfigSectionForm({
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
/> />
) : widget === "toggle" ? ( ) : widget === "toggle" ? (
<div className="flex-1 px-3 py-2"> <div className="flex items-center flex-1 px-1">
<ToggleDisplay value={rawValue} /> <ToggleDisplay value={rawValue} />
</div> </div>
) : widget === "select" ? ( ) : widget === "select" ? (
@@ -267,9 +307,11 @@ function FieldWidget({
const options = fieldSchema?.options ?? []; const options = fieldSchema?.options ?? [];
// Options may use "N - Description" format for numeric fields // Options may use "N - Description" format for numeric fields
const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]); const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]);
const selectedOpt = isNumericOptions const matchedOpt = isNumericOptions
? (options.find((opt) => parseInt(opt, 10) === Number(value)) ?? String(value ?? "")) ? options.find((opt) => parseInt(opt, 10) === Number(value))
: String(value ?? ""); : options.find((opt) => opt === String(value ?? ""));
// Fall back to first option when stored value has no match (avoids silent blank selection)
const selectedOpt = matchedOpt ?? options[0] ?? String(value ?? "");
return ( return (
<select <select
className="neu-input flex-1 text-sm" className="neu-input flex-1 text-sm"
@@ -293,11 +335,9 @@ function FieldWidget({
case "toggle": case "toggle":
return ( return (
<input <ToggleSwitch
type="checkbox"
className="w-5 h-5 accent-accent"
checked={value === true || value === 1 || value === "true"} checked={value === true || value === 1 || value === "true"}
onChange={(e) => onChange(e.target.checked ? 1 : 0)} onChange={(checked) => onChange(checked ? 1 : 0)}
/> />
); );
@@ -323,10 +363,13 @@ function FieldWidget({
<input <input
type="number" type="number"
className="neu-input flex-1 text-sm" className="neu-input flex-1 text-sm"
value={String(value ?? "")} value={value === null || value === undefined ? "" : String(value)}
min={fieldSchema?.min} min={fieldSchema?.min}
max={fieldSchema?.max} max={fieldSchema?.max}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => {
const v = e.target.value;
onChange(v === "" ? null : Number(v));
}}
/> />
); );
@@ -362,13 +405,50 @@ function FieldWidget({
} }
} }
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={clsx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full",
"transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-surface-base",
checked
? "bg-accent shadow-glow-amber"
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
)}
>
<span
className={clsx(
"inline-block h-4 w-4 rounded-full shadow-md transform transition-transform duration-200",
checked ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
)}
/>
</button>
);
}
function ToggleDisplay({ value }: { value: unknown }) { function ToggleDisplay({ value }: { value: unknown }) {
const on = value === true || value === 1 || value === "true" || value === "1"; const on = value === true || value === 1 || value === "true" || value === "1";
return ( return (
<span className={clsx("inline-flex items-center gap-1.5 text-sm font-sans", on ? "text-green-400" : "text-text-muted")}> <div
<span className={clsx("w-3 h-3 rounded-full", on ? "bg-green-400" : "bg-surface-overlay border border-text-muted")} /> className={clsx(
{on ? "Enabled" : "Disabled"} "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full cursor-not-allowed opacity-75",
</span> on
? "bg-accent shadow-glow-amber"
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
)}
aria-hidden="true"
>
<span
className={clsx(
"inline-block h-4 w-4 rounded-full shadow-md transform",
on ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
)}
/>
</div>
); );
} }

View File

@@ -124,10 +124,16 @@ export interface Mod {
path: string; path: string;
size_bytes: number; size_bytes: number;
enabled: boolean; enabled: boolean;
is_server_mod: boolean;
display_name: string | null; display_name: string | null;
workshop_id: string | null; workshop_id: string | null;
} }
export interface EnabledModEntry {
name: string;
is_server_mod: boolean;
}
export interface FieldSchema { export interface FieldSchema {
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value"; widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
label?: string; label?: string;
@@ -135,6 +141,7 @@ export interface FieldSchema {
min?: number; min?: number;
max?: number; max?: number;
options?: string[]; options?: string[];
advanced?: boolean;
} }
export interface ConfigSchema { export interface ConfigSchema {
@@ -380,7 +387,7 @@ export function useDeleteMission(serverId: number) {
export function useSetEnabledMods(serverId: number) { export function useSetEnabledMods(serverId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (mods: string[]) => mutationFn: (mods: EnabledModEntry[]) =>
apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }), apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mods", serverId] }); queryClient.invalidateQueries({ queryKey: ["mods", serverId] });