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:
210
frontend/src/__tests__/ConfigEditorAdvanced.test.tsx
Normal file
210
frontend/src/__tests__/ConfigEditorAdvanced.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-text-muted text-xs">
|
<p className="text-text-muted text-xs">
|
||||||
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
|
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
|
||||||
</p>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
Reference in New Issue
Block a user