milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex

This commit is contained in:
2026-08-12 22:40:50 +02:00
parent 3b33d7afee
commit 15c895e180
2 changed files with 159 additions and 49 deletions
@@ -130,7 +130,11 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
test("enum and boolean fields diff as their own types", async () => { test("enum and boolean fields diff as their own types", async () => {
await renderPage(); await renderPage();
const logging = screen.getByRole("group", { name: "Logging" }); const logging = screen.getByRole("group", { name: "Logging" });
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } }); // A RAC Select names its trigger with the current value and then the label, and
// carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.click(within(logging).getByLabelText("hide_domains")); fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1)); await waitFor(() => expect(putBodies).toHaveLength(1));
+154 -48
View File
@@ -1,12 +1,15 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner"; import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean { export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -139,8 +142,121 @@ const SECTIONS: readonly AnySectionDef[] = [
}), }),
]; ];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const DARK = "@media (prefers-color-scheme: dark)";
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
function FieldRow({ function FieldRow({
section, section,
@@ -156,15 +272,15 @@ function FieldRow({
const id = `${section}.${def.key}`; const id = `${section}.${def.key}`;
if (def.kind === "boolean") { if (def.kind === "boolean") {
return ( return (
<div className="flex items-center gap-2"> <div {...stylex.props(styles.checkboxRow)}>
<input <input
id={id} id={id}
type="checkbox" type="checkbox"
checked={value as boolean} checked={value as boolean}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
</div> </div>
@@ -172,30 +288,20 @@ function FieldRow({
} }
if (Array.isArray(def.kind)) { if (Array.isArray(def.kind)) {
return ( return (
<div className="flex flex-col gap-1"> <Select
<label htmlFor={id} className={LABEL_CLASS}> variant="inline"
{def.key} label={def.key}
</label> value={value as string}
<select onChange={onChange}
id={id} options={def.kind.map((option) => ({ value: option, label: option }))}
value={value as string} />
onChange={(e) => onChange(e.target.value)}
className={fieldInputClass}
>
{def.kind.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
); );
} }
if (def.kind === "number") { if (def.kind === "number") {
const numeric = value as number; const numeric = value as number;
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -203,14 +309,14 @@ function FieldRow({
type="number" type="number"
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -218,7 +324,7 @@ function FieldRow({
type="text" type="text"
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
@@ -270,16 +376,16 @@ export default function SettingsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Settings</h1> <h1 {...stylex.props(styles.heading)}>Settings</h1>
<p className="mt-1 text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Changes are validated as a whole; every setting requires a restart to take effect. Changes are validated as a whole; every setting requires a restart to take effect.
</p> </p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6"> <fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => ( {SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800"> <fieldset key={section} {...stylex.props(styles.section)}>
<legend className="px-1 text-sm font-semibold">{title}</legend> <legend {...stylex.props(styles.legend)}>{title}</legend>
<div className="grid gap-3 sm:grid-cols-2"> <div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => ( {(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow <FieldRow
key={def.key} key={def.key}
@@ -291,12 +397,12 @@ export default function SettingsPage() {
))} ))}
{section === "web" && ( {section === "web" && (
<> <>
<p className={LABEL_CLASS}> <p {...stylex.props(styles.label)}>
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "} auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
<span className="text-zinc-500">(derived, read-only)</span> <span {...stylex.props(styles.derived)}>(derived, read-only)</span>
</p> </p>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password" className={LABEL_CLASS}> <label htmlFor="web.password" {...stylex.props(styles.label)}>
password password
</label> </label>
<input <input
@@ -305,11 +411,11 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" className={LABEL_CLASS}> <label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password confirm password
</label> </label>
<input <input
@@ -318,17 +424,17 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
{password !== "" && ( {password !== "" && (
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400"> <p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log Changing the password signs out every session; you will be asked to log
in again. in again.
</p> </p>
)} )}
{passwordsMismatch && ( {passwordsMismatch && (
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400"> <p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match. Passwords do not match.
</p> </p>
)} )}
@@ -337,12 +443,12 @@ export default function SettingsPage() {
</div> </div>
</fieldset> </fieldset>
))} ))}
<div className="flex items-center gap-3"> <div {...stylex.props(styles.submitRow)}>
<button <button
type="submit" type="submit"
disabled={saveDisabled} disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`} {...stylex.props(styles.save, shared.focusRing)}
> >
{mutation.isPending ? "Saving…" : "Save"} {mutation.isPending ? "Saving…" : "Save"}
</button> </button>