feat: Phase 1 — Config UI Schema system with per-field widget routing

- Backend: add Arma3ConfigGenerator.get_ui_schema() with widget hints per field
- Backend: add ServerService.get_config_schema() and GET /config/schema endpoint
- Frontend: add FieldSchema/ConfigSchema types + useServerConfigSchema hook
- Frontend: new TagListEditor component for dynamic string-list editing
- Frontend: ConfigEditor now routes each field to correct widget (text/number/password/textarea/select/toggle/tag-list)
- Frontend: password fields have show/hide toggle; toggles render as checkbox; tag-list uses TagListEditor
- Tests: 8 new tests covering hook and TagListEditor; all 136 tests green
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-17 20:27:06 +07:00
parent e71dd9a600
commit dedf082491
8 changed files with 370 additions and 20 deletions

View File

@@ -12,7 +12,7 @@
| Phase | Status | Last session note | | Phase | Status | Last session note |
|-------|--------|------------------| |-------|--------|------------------|
| 1 — Config UI Schema | `[ ] not started` | | | 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint |
| 2 — Mission Rotation | `[ ] not started` | | | 2 — Mission Rotation | `[ ] not started` | |
| 3 — Mod Display Names + Split Pane | `[ ] not started` | | | 3 — Mod Display Names + Split Pane | `[ ] not started` | |
| 4 — Player Kick/Ban | `[ ] not started` | | | 4 — Player Kick/Ban | `[ ] not started` | |

View File

@@ -382,6 +382,37 @@ class Arma3ConfigGenerator:
args.extend(mod_args) args.extend(mod_args)
return args return args
def get_ui_schema(self) -> dict:
return {
"server": {
"hostname": {"widget": "text", "label": "Server Hostname"},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
"password": {"widget": "password", "label": "Player Password"},
"password_admin": {"widget": "password", "label": "Admin Password"},
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"},
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"},
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)",
"min": 0, "max": 2},
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000"},
},
"basic": {
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"},
},
"launch": {
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-limitFPS=100"},
},
"rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password"},
"max_ping": {"widget": "number", "label": "RCon Port"},
},
}
def preview_config( def preview_config(
self, self,
server_id: int, server_id: int,

View File

@@ -134,6 +134,15 @@ def get_config(
return _ok(ServerService(db).get_config(server_id)) return _ok(ServerService(db).get_config(server_id))
@router.get("/{server_id}/config/schema")
def get_config_schema(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).get_config_schema(server_id))
@router.get("/{server_id}/config/preview") @router.get("/{server_id}/config/preview")
def get_config_preview( def get_config_preview(
server_id: int, server_id: int,

View File

@@ -396,6 +396,14 @@ class ServerService:
data[field] = "***" data[field] = "***"
return sections return sections
def get_config_schema(self, server_id: int) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if hasattr(config_gen, "get_ui_schema"):
return config_gen.get_ui_schema()
return {}
def get_config_section(self, server_id: int, section: str) -> dict: def get_config_section(self, server_id: int, section: str) -> dict:
server = self.get_server(server_id) server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"]) adapter = GameAdapterRegistry.get(server["game_type"])

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useServerConfigSchema } from "@/hooks/useServerDetail";
import { TagListEditor } from "@/components/ui/TagListEditor";
vi.mock("@/lib/api", () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
import { apiClient } from "@/lib/api";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
const MOCK_SCHEMA = {
server: {
hostname: { widget: "text", label: "Server Hostname" },
max_players: { widget: "number", label: "Max Players", min: 1, max: 1000 },
password: { widget: "password", label: "Player Password" },
forced_difficulty: {
widget: "select",
label: "Difficulty Preset",
options: ["Recruit", "Regular", "Veteran", "Custom"],
},
battleye: { widget: "toggle", label: "BattleEye Anti-Cheat" },
motd_lines: { widget: "textarea", label: "Message of the Day (one line per row)" },
admin_uids: { widget: "tag-list", label: "Admin Steam UIDs", placeholder: "76561198000000000" },
},
};
describe("useServerConfigSchema", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
it("fetches schema from /api/servers/:id/config/schema", async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { success: true, data: MOCK_SCHEMA } });
const { result } = renderHook(() => useServerConfigSchema(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(MOCK_SCHEMA);
expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/config/schema");
});
it("is disabled when serverId is 0", () => {
const { result } = renderHook(() => useServerConfigSchema(0), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe("idle");
});
});
describe("TagListEditor", () => {
it("renders existing items", () => {
render(<TagListEditor value={["uid1", "uid2"]} onChange={() => {}} />);
expect(screen.getByDisplayValue("uid1")).toBeInTheDocument();
expect(screen.getByDisplayValue("uid2")).toBeInTheDocument();
});
it("calls onChange with new item when Add is clicked", () => {
const onChange = vi.fn();
render(<TagListEditor value={["uid1"]} onChange={onChange} />);
fireEvent.click(screen.getByText("+ Add"));
expect(onChange).toHaveBeenCalledWith(["uid1", ""]);
});
it("calls onChange without item when remove is clicked", () => {
const onChange = vi.fn();
render(<TagListEditor value={["uid1", "uid2"]} onChange={onChange} />);
const removeButtons = screen.getAllByText("✕");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["uid2"]);
});
it("calls onChange with updated value on input change", () => {
const onChange = vi.fn();
render(<TagListEditor value={["uid1"]} onChange={onChange} />);
fireEvent.change(screen.getByDisplayValue("uid1"), { target: { value: "uid_new" } });
expect(onChange).toHaveBeenCalledWith(["uid_new"]);
});
it("renders placeholder text", () => {
render(<TagListEditor value={[""]} onChange={() => {}} placeholder="Enter UID" />);
expect(screen.getByPlaceholderText("Enter UID")).toBeInTheDocument();
});
it("disables inputs when disabled prop is true", () => {
render(<TagListEditor value={["uid1"]} onChange={() => {}} disabled />);
expect(screen.getByDisplayValue("uid1")).toBeDisabled();
});
});

View File

@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useServerConfig, useServerConfigSection, useUpdateConfigSection } from "@/hooks/useServerDetail"; import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
import type { FieldSchema } from "@/hooks/useServerDetail";
import { TagListEditor } from "@/components/ui/TagListEditor";
import { useAuthStore } from "@/store/auth.store"; import { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store"; import { useUIStore } from "@/store/ui.store";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -74,8 +76,10 @@ function ConfigSectionForm({
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>>({});
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>;
@@ -89,13 +93,16 @@ function ConfigSectionForm({
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;
const sectionSchema = schema?.[section] ?? {};
const handleEdit = () => { const handleEdit = () => {
setEditValues(Object.fromEntries(fields)); setEditValues(Object.fromEntries(fields));
setShowPassword({});
}; };
const handleCancel = () => { const handleCancel = () => {
setEditValues(null); setEditValues(null);
setShowPassword({});
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -107,6 +114,7 @@ function ConfigSectionForm({
}); });
addNotification({ type: "success", message: `${section} config updated` }); addNotification({ type: "success", message: `${section} config updated` });
setEditValues(null); setEditValues(null);
setShowPassword({});
} catch (err) { } catch (err) {
logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err); logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err);
if (err instanceof Error && "response" in err) { if (err instanceof Error && "response" in err) {
@@ -127,6 +135,10 @@ function ConfigSectionForm({
setEditValues({ ...editValues, [key]: value }); setEditValues({ ...editValues, [key]: value });
}; };
const toggleShowPassword = (key: string) => {
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
};
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">
@@ -140,23 +152,33 @@ function ConfigSectionForm({
)} )}
</div> </div>
{fields.map(([key, value]) => ( {fields.map(([key]) => {
<div key={key} className="flex items-center gap-3"> const fieldSchema: FieldSchema | undefined = sectionSchema[key];
<label className="text-text-secondary text-sm w-40 shrink-0">{formatLabel(key)}</label> const widget = fieldSchema?.widget ?? (SENSITIVE_KEYS.has(key) ? "password" : "text");
{isEditing && !SENSITIVE_KEYS.has(key) ? ( const label = fieldSchema?.label ?? formatLabel(key);
<input const rawValue = displayValues[key];
className="neu-input flex-1 text-sm"
value={String(editValues?.[key] ?? "")} return (
onChange={(e) => handleChange(key, e.target.value)} <div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}>
type={typeof value === "number" ? "number" : "text"} <label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
/> {isEditing ? (
) : ( <FieldWidget
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2"> fieldKey={key}
{SENSITIVE_KEYS.has(key) ? "••••••••" : String(value ?? "--")} widget={widget}
</span> fieldSchema={fieldSchema}
)} value={rawValue}
</div> showPassword={showPassword[key] ?? false}
))} onTogglePassword={() => toggleShowPassword(key)}
onChange={(v) => handleChange(key, v)}
/>
) : (
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
</span>
)}
</div>
);
})}
{isEditing && ( {isEditing && (
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
@@ -172,6 +194,115 @@ function ConfigSectionForm({
); );
} }
function FieldWidget({
fieldKey,
widget,
fieldSchema,
value,
showPassword,
onTogglePassword,
onChange,
}: {
fieldKey: string;
widget: string;
fieldSchema: FieldSchema | undefined;
value: unknown;
showPassword: boolean;
onTogglePassword: () => void;
onChange: (v: unknown) => void;
}) {
switch (widget) {
case "textarea":
return (
<textarea
className="neu-input flex-1 text-sm"
rows={4}
value={Array.isArray(value) ? (value as string[]).join("\n") : String(value ?? "")}
onChange={(e) => onChange(e.target.value.split("\n"))}
/>
);
case "select":
return (
<select
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
>
{(fieldSchema?.options ?? []).map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
);
case "toggle":
return (
<input
type="checkbox"
className="w-5 h-5 accent-accent"
checked={value === true || value === 1 || value === "true"}
onChange={(e) => onChange(e.target.checked ? "true" : "false")}
/>
);
case "tag-list":
return (
<TagListEditor
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
placeholder={fieldSchema?.placeholder}
/>
);
case "number":
return (
<input
type="number"
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
min={fieldSchema?.min}
max={fieldSchema?.max}
onChange={(e) => onChange(Number(e.target.value))}
/>
);
case "password":
return (
<div className="flex gap-2 flex-1">
<input
type={showPassword ? "text" : "password"}
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
onClick={onTogglePassword}
className="btn-ghost text-sm px-2"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
);
default:
return (
<input
type="text"
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
function formatDisplayValue(value: unknown): string {
if (Array.isArray(value)) return value.join(", ") || "--";
return String(value ?? "--");
}
function formatLabel(key: string): string { function formatLabel(key: string): string {
return key return key
.replace(/_/g, " ") .replace(/_/g, " ")

View File

@@ -0,0 +1,40 @@
interface TagListEditorProps {
value: string[];
onChange: (v: string[]) => void;
placeholder?: string;
disabled?: boolean;
}
export function TagListEditor({ value, onChange, placeholder, disabled }: TagListEditorProps) {
const update = (idx: number, val: string) =>
onChange(value.map((v, i) => (i === idx ? val : v)));
const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
const add = () => onChange([...value, ""]);
return (
<div className="space-y-1">
{value.map((item, idx) => (
<div key={idx} className="flex gap-2">
<input
className="flex-1 neu-input"
value={item}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => update(idx, e.target.value)}
/>
<button
type="button"
onClick={() => remove(idx)}
disabled={disabled}
className="btn-ghost text-status-crashed px-2"
>
</button>
</div>
))}
<button type="button" onClick={add} disabled={disabled} className="btn-ghost text-sm">
+ Add
</button>
</div>
);
}

View File

@@ -115,6 +115,21 @@ export interface Mod {
path: string; path: string;
size_bytes: number; size_bytes: number;
enabled: boolean; enabled: boolean;
display_name: string | null;
workshop_id: string | null;
}
export interface FieldSchema {
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
label?: string;
placeholder?: string;
min?: number;
max?: number;
options?: string[];
}
export interface ConfigSchema {
[section: string]: { [field: string]: FieldSchema };
} }
export interface ModsResponse { export interface ModsResponse {
@@ -125,6 +140,19 @@ export interface ModsResponse {
// ── Query Hooks ──────────────────────────────────────────────────────── // ── Query Hooks ────────────────────────────────────────────────────────
export function useServerConfigSchema(serverId: number) {
return useQuery({
queryKey: ["servers", serverId, "config", "schema"],
queryFn: async () => {
const res = await apiClient.get<{ success: boolean; data: ConfigSchema }>(
`/api/servers/${serverId}/config/schema`,
);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerConfig(serverId: number) { export function useServerConfig(serverId: number) {
return useQuery({ return useQuery({
queryKey: ["servers", serverId, "config"], queryKey: ["servers", serverId, "config"],