diff --git a/frontend/src/__tests__/ConfigEditorAdvanced.test.tsx b/frontend/src/__tests__/ConfigEditorAdvanced.test.tsx new file mode 100644 index 0000000..8a9ec24 --- /dev/null +++ b/frontend/src/__tests__/ConfigEditorAdvanced.test.tsx @@ -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 {children}; + }; +} + +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>) { + const configMap: Record = {}; + 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(); + }); +}); diff --git a/frontend/src/components/servers/ConfigEditor.tsx b/frontend/src/components/servers/ConfigEditor.tsx index 69a9b1c..6259f29 100644 --- a/frontend/src/components/servers/ConfigEditor.tsx +++ b/frontend/src/components/servers/ConfigEditor.tsx @@ -35,6 +35,7 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const addNotification = useUIStore((s) => s.addNotification); const { data: configMap, isLoading } = useServerConfig(serverId); + const { data: schema } = useServerConfigSchema(serverId); const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : []; const [activeSection, setActiveSection] = useState(sections[0] ?? ""); @@ -49,6 +50,11 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) { const currentSection = activeSection || sections[0]; + // Derive forced_difficulty from server section for profile gate + const serverSection = configMap["server"] as Record | undefined; + const forcedDifficulty = serverSection?.["forced_difficulty"] as string | undefined; + const profileGated = currentSection === "profile" && forcedDifficulty !== "Custom"; + return (
@@ -72,11 +78,18 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {

{SECTION_DESCRIPTIONS[currentSection]}

)} + {profileGated && ( +
+ These settings only apply when Forced Difficulty is set to Custom in the Server tab. Current value: {forcedDifficulty ?? "—"} +
+ )} + {currentSection && ( @@ -88,19 +101,21 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) { function ConfigSectionForm({ serverId, section, + sectionSchema, isAdmin, addNotification, }: { serverId: number; section: string; + sectionSchema: Record; isAdmin: boolean; addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void; }) { const { data: sectionData, isLoading } = useServerConfigSection(serverId, section); - const { data: schema } = useServerConfigSchema(serverId); const updateSection = useUpdateConfigSection(serverId, section); const [editValues, setEditValues] = useState | null>(null); const [showPassword, setShowPassword] = useState>({}); + const [showAdvanced, setShowAdvanced] = useState(false); if (isLoading) { return
Loading section...
; @@ -110,12 +125,23 @@ function ConfigSectionForm({ return
No data for this section.
; } - const sectionSchema = schema?.[section] ?? {}; - const fields = Object.entries(sectionData).filter(([key]) => { + const hasAdvancedFields = Object.values(sectionSchema).some((f) => f.advanced === true); + + const allFields = Object.entries(sectionData).filter(([key]) => { if (key === "_meta") return false; if (sectionSchema[key]?.widget === "hidden") return false; 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 displayValues = editValues ?? Object.fromEntries(fields); const isEditing = editValues !== null; @@ -131,7 +157,11 @@ function ConfigSectionForm({ }; 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 { await updateSection.mutateAsync({ config_version: meta.config_version, @@ -167,9 +197,19 @@ function ConfigSectionForm({ return (
-

- Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"} -

+
+

+ Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"} +

+ {hasAdvancedFields && !isEditing && ( + + )} +
{isAdmin && !isEditing && ( + ); +} + function ToggleDisplay({ value }: { value: unknown }) { const on = value === true || value === 1 || value === "true" || value === "1"; return ( - - - {on ? "Enabled" : "Disabled"} - + ); } diff --git a/frontend/src/hooks/useServerDetail.ts b/frontend/src/hooks/useServerDetail.ts index fe14558..7ee375e 100644 --- a/frontend/src/hooks/useServerDetail.ts +++ b/frontend/src/hooks/useServerDetail.ts @@ -124,10 +124,16 @@ export interface Mod { path: string; size_bytes: number; enabled: boolean; + is_server_mod: boolean; display_name: string | null; workshop_id: string | null; } +export interface EnabledModEntry { + name: string; + is_server_mod: boolean; +} + export interface FieldSchema { widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value"; label?: string; @@ -135,6 +141,7 @@ export interface FieldSchema { min?: number; max?: number; options?: string[]; + advanced?: boolean; } export interface ConfigSchema { @@ -380,7 +387,7 @@ export function useDeleteMission(serverId: number) { export function useSetEnabledMods(serverId: number) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (mods: string[]) => + mutationFn: (mods: EnabledModEntry[]) => apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["mods", serverId] });