feat: closed-set admin knobs are pickers, not free text

log_level, search_source, download_layout and the transcode hw/preset
were typed by hand, and every consumer carried its own guess at what a
typo meant. The value set is now a Literal in config.py: pydantic
rejects a bad env value at boot (any letter case accepted), sysconfig
refuses a bad override and derives the admin picker's options from the
same Literal, and the consumers read the value as-is.

The Configuration page renders a choice key as a segmented bar, or a
dropdown past six options, with translated option names. Its tabs now
follow the registry's order instead of a hand-kept copy that had
already dropped the explore group.

A single-choice FilterSelect now opens on the option in use: in the
browser it could land on the first row instead.
This commit is contained in:
2026-09-17 00:03:56 +02:00
parent 6c63df6704
commit 07766c43e8
15 changed files with 496 additions and 93 deletions
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
// as the active option rather than no option at all.
//
// i18n is a key-passthrough; the admin config API is stubbed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
key,
type: "choice",
group: "g",
secret: false,
min: null,
max: null,
is_set,
allow_empty: false,
choices,
value,
default: choices[0],
});
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({
groups: {
g: [
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
choice("plex_transcode_preset", PRESETS, "veryfast"),
],
},
secrets_manageable: true,
}),
setConfig: (key: string, value: unknown) => setConfig(key, value),
resetConfig: (key: string) => resetConfig(key),
},
};
});
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mount = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
describe("ConfigPanel choice keys", () => {
it("renders a short list as a segmented bar and saves the pick", async () => {
mount();
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
const pressed = () =>
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
expect(pressed()).toEqual(["config.choices.plex_transcode_hw.on"]);
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
fireEvent.click(screen.getByRole("button", { name: "config.choices.plex_transcode_hw.off" }));
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
});
it("shows the default as active once the override is reset, and Save resets it", async () => {
mount();
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
expect(
screen
.getByRole("button", { name: "config.choices.plex_transcode_hw.auto" })
.getAttribute("aria-pressed"),
).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
});
it("renders a long list as a dropdown showing only the value", async () => {
mount();
const trigger = await screen.findByRole("button", {
name: "config.fields.plex_transcode_preset.label: config.choices.plex_transcode_preset.veryfast",
});
expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.veryfast");
fireEvent.click(trigger);
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
fireEvent.click(
rows.find((r) => r.textContent === "config.choices.plex_transcode_preset.slow")!,
);
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
});
});
+56 -24
View File
@@ -12,30 +12,17 @@ import Tabs, { usePersistedTab } from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar; nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = [
"access",
"email",
"youtube",
"google",
"quota",
"backfill",
"shorts",
"batch",
"maintenance",
"downloads",
"audit",
"plex",
"tmdb",
];
// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
// needing a second, hand-kept order list here.
export default function ConfigPanel() {
const { t } = useTranslation();
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
);
const groupKeys = Object.keys(data.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
@@ -330,6 +315,7 @@ function Field({
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
const label = t(`config.fields.${item.key}.label`, item.key);
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@@ -356,7 +342,7 @@ function Field({
return (
<div className="flex items-start justify-between gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
@@ -366,10 +352,12 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? (
<Switch
label={t(`config.fields.${item.key}.label`, item.key)}
label={label}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
/>
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : (
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
@@ -379,7 +367,7 @@ function Field({
min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)}
aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
)}
@@ -400,3 +388,47 @@ function Field({
</div>
);
}
// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
// default on Save) shows the default as the active option, as a text field shows it as placeholder.
const MAX_SEGMENTS = 6;
function ChoiceField({
item,
label,
value,
onChange,
}: {
item: ConfigItem;
label: string;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
const options = item.choices.map((c) => ({
value: c,
label: t(`config.choices.${item.key}.${c}`, c),
}));
const current = value || String(item.default ?? "");
if (options.length <= MAX_SEGMENTS) {
return (
<SegmentedToggle
options={options}
value={current}
onChange={onChange}
ariaLabel={label}
size="md"
/>
);
}
return (
<FilterSelect
label={label}
hideLabel
options={options}
selected={[current]}
onChange={([next]) => next && onChange(next)}
/>
);
}
+13 -1
View File
@@ -21,6 +21,9 @@ export interface FilterSelectOption {
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
/** Show only the value on the trigger — for a form row whose own caption already names the field.
* The label still names the control and its menu for assistive tech. */
hideLabel?: boolean;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
@@ -39,6 +42,7 @@ export default function FilterSelect({
onChange,
multiple = false,
requireOne = true,
hideLabel = false,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
@@ -47,6 +51,14 @@ export default function FilterSelect({
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
// Single choice: opening lands on the option in use, not the first row. A multi-select toggle
// list has no one "current" row, so it keeps the default.
selectedIndex: multiple
? null
: Math.max(
0,
options.findIndex((o) => o.value === selected[0]),
),
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
@@ -88,7 +100,7 @@ export default function FilterSelect({
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
{!hideLabel && <span className="text-muted">{label}:</span>}
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>