fix: admin config pickers fit a phone
A segmented bar cannot shrink or wrap, and the five log levels (~300 px) are wider than a phone leaves beside the navigation rail. A choice is a bar only when its labels are short; the rows stack below sm.
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
// 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.
|
||||
// i18n is a key-passthrough, except that an option's label is its value (the bar-or-dropdown choice
|
||||
// weighs the real label length, which a full key would distort); 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";
|
||||
@@ -14,7 +15,10 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => (key.startsWith("config.choices.") ? key.split(".").pop() : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,6 +52,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
g: [
|
||||
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
|
||||
choice("plex_transcode_preset", PRESETS, "veryfast"),
|
||||
choice("log_level", ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"], "INFO"),
|
||||
],
|
||||
},
|
||||
secrets_manageable: true,
|
||||
@@ -79,10 +84,10 @@ describe("ConfigPanel choice keys", () => {
|
||||
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(pressed()).toEqual(["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(screen.getByRole("button", { name: "off" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
|
||||
});
|
||||
@@ -91,11 +96,7 @@ describe("ConfigPanel choice keys", () => {
|
||||
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");
|
||||
expect(screen.getByRole("button", { name: "auto" }).getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
|
||||
});
|
||||
@@ -103,16 +104,20 @@ describe("ConfigPanel choice keys", () => {
|
||||
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",
|
||||
name: "config.fields.plex_transcode_preset.label: veryfast",
|
||||
});
|
||||
expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.veryfast");
|
||||
expect(trigger.textContent).toBe("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(rows.find((r) => r.textContent === "slow")!);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
|
||||
});
|
||||
|
||||
it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "config.fields.log_level.label: INFO" });
|
||||
expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +340,9 @@ function Field({
|
||||
const showReset = item.is_set && !isBool;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-3">
|
||||
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
|
||||
// label and hint a sliver of a phone's width.
|
||||
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
@@ -349,7 +351,7 @@ function Field({
|
||||
<p className="text-2xs text-muted mt-1">{status}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
|
||||
{isBool ? (
|
||||
<Switch
|
||||
label={label}
|
||||
@@ -392,7 +394,12 @@ function Field({
|
||||
// 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.
|
||||
//
|
||||
// "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level
|
||||
// names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px).
|
||||
// Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too.
|
||||
const MAX_SEGMENTS = 6;
|
||||
const MAX_SEGMENT_CHARS = 22;
|
||||
|
||||
function ChoiceField({
|
||||
item,
|
||||
@@ -411,7 +418,8 @@ function ChoiceField({
|
||||
label: t(`config.choices.${item.key}.${c}`, c),
|
||||
}));
|
||||
const current = value || String(item.default ?? "");
|
||||
if (options.length <= MAX_SEGMENTS) {
|
||||
const labelChars = options.reduce((sum, o) => sum + o.label.length, 0);
|
||||
if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) {
|
||||
return (
|
||||
<SegmentedToggle
|
||||
options={options}
|
||||
|
||||
Reference in New Issue
Block a user