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:
@@ -12,7 +12,7 @@
|
||||
|
||||
| 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` | |
|
||||
| 3 — Mod Display Names + Split Pane | `[ ] not started` | |
|
||||
| 4 — Player Kick/Ban | `[ ] not started` | |
|
||||
|
||||
@@ -382,6 +382,37 @@ class Arma3ConfigGenerator:
|
||||
args.extend(mod_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(
|
||||
self,
|
||||
server_id: int,
|
||||
|
||||
@@ -134,6 +134,15 @@ def get_config(
|
||||
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")
|
||||
def get_config_preview(
|
||||
server_id: int,
|
||||
|
||||
@@ -396,6 +396,14 @@ class ServerService:
|
||||
data[field] = "***"
|
||||
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:
|
||||
server = self.get_server(server_id)
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
|
||||
103
frontend/src/__tests__/ConfigSchemaPhase1.test.tsx
Normal file
103
frontend/src/__tests__/ConfigSchemaPhase1.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
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 { useUIStore } from "@/store/ui.store";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -74,8 +76,10 @@ function ConfigSectionForm({
|
||||
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<Record<string, unknown> | null>(null);
|
||||
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-text-muted text-sm">Loading section...</div>;
|
||||
@@ -89,13 +93,16 @@ function ConfigSectionForm({
|
||||
const meta = sectionData._meta;
|
||||
const displayValues = editValues ?? Object.fromEntries(fields);
|
||||
const isEditing = editValues !== null;
|
||||
const sectionSchema = schema?.[section] ?? {};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditValues(Object.fromEntries(fields));
|
||||
setShowPassword({});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValues(null);
|
||||
setShowPassword({});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -107,6 +114,7 @@ function ConfigSectionForm({
|
||||
});
|
||||
addNotification({ type: "success", message: `${section} config updated` });
|
||||
setEditValues(null);
|
||||
setShowPassword({});
|
||||
} catch (err) {
|
||||
logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err);
|
||||
if (err instanceof Error && "response" in err) {
|
||||
@@ -127,6 +135,10 @@ function ConfigSectionForm({
|
||||
setEditValues({ ...editValues, [key]: value });
|
||||
};
|
||||
|
||||
const toggleShowPassword = (key: string) => {
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -140,23 +152,33 @@ function ConfigSectionForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fields.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<label className="text-text-secondary text-sm w-40 shrink-0">{formatLabel(key)}</label>
|
||||
{isEditing && !SENSITIVE_KEYS.has(key) ? (
|
||||
<input
|
||||
className="neu-input flex-1 text-sm"
|
||||
value={String(editValues?.[key] ?? "")}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
type={typeof value === "number" ? "number" : "text"}
|
||||
{fields.map(([key]) => {
|
||||
const fieldSchema: FieldSchema | undefined = sectionSchema[key];
|
||||
const widget = fieldSchema?.widget ?? (SENSITIVE_KEYS.has(key) ? "password" : "text");
|
||||
const label = fieldSchema?.label ?? formatLabel(key);
|
||||
const rawValue = displayValues[key];
|
||||
|
||||
return (
|
||||
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}>
|
||||
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
|
||||
{isEditing ? (
|
||||
<FieldWidget
|
||||
fieldKey={key}
|
||||
widget={widget}
|
||||
fieldSchema={fieldSchema}
|
||||
value={rawValue}
|
||||
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">
|
||||
{SENSITIVE_KEYS.has(key) ? "••••••••" : String(value ?? "--")}
|
||||
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{isEditing && (
|
||||
<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 {
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
|
||||
40
frontend/src/components/ui/TagListEditor.tsx
Normal file
40
frontend/src/components/ui/TagListEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,21 @@ export interface Mod {
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
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 {
|
||||
@@ -125,6 +140,19 @@ export interface ModsResponse {
|
||||
|
||||
// ── 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) {
|
||||
return useQuery({
|
||||
queryKey: ["servers", serverId, "config"],
|
||||
|
||||
Reference in New Issue
Block a user