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

@@ -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 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"}
/>
) : (
<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 ?? "--")}
</span>
)}
</div>
))}
{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">
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
</span>
)}
</div>
);
})}
{isEditing && (
<div className="flex gap-2 mt-4">
@@ -172,8 +194,117 @@ 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, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}

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;
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"],