From d7ae1e66c1015678c12c31c76e465b2ded7745d4 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Mon, 5 Oct 2026 15:43:57 +0800 Subject: [PATCH] fix(panel): unify fixed headers and collapsible auth sources --- panel/e2e/a11y.spec.ts | 1 + panel/e2e/auth-sources.smoke.spec.ts | 5 +- panel/e2e/fixtures.ts | 12 ++++ panel/e2e/mobile.spec.ts | 11 +++- panel/e2e/smoke.spec.ts | 55 ++++++++++++++++- panel/src/components/AppShell.test.tsx | 19 +++++- panel/src/components/AppShell.tsx | 61 ++++++++++--------- panel/src/components/PageHeader.tsx | 13 ++-- panel/src/components/ui/switch.tsx | 17 ++++++ .../src/i18n/resources/en-US/authSources.json | 2 + .../src/i18n/resources/zh-CN/authSources.json | 2 + panel/src/pages/ServerConsole.tsx | 6 +- .../src/pages/admin/AuthSourcesPage.test.tsx | 38 +++++++++++- panel/src/pages/admin/AuthSourcesPage.tsx | 34 +++++++---- panel/src/pages/admin/LobbyPage.tsx | 10 +-- 15 files changed, 223 insertions(+), 63 deletions(-) create mode 100644 panel/src/components/ui/switch.tsx diff --git a/panel/e2e/a11y.spec.ts b/panel/e2e/a11y.spec.ts index e8c104a..1c2e614 100644 --- a/panel/e2e/a11y.spec.ts +++ b/panel/e2e/a11y.spec.ts @@ -31,6 +31,7 @@ for (const [account, path] of [ ["owner", "/"], ["owner", "/servers"], ["owner", "/admin/lobby?space=login"], + ["owner", "/admin/auth-sources"], ["owner", "/admin/images"], ["owner", "/admin/builds"], ["owner", "/admin/submissions"], diff --git a/panel/e2e/auth-sources.smoke.spec.ts b/panel/e2e/auth-sources.smoke.spec.ts index 5d05097..2847fb8 100644 --- a/panel/e2e/auth-sources.smoke.spec.ts +++ b/panel/e2e/auth-sources.smoke.spec.ts @@ -7,6 +7,7 @@ test("Owner manages durable authentication sources from the sidebar", async ({ p await expect(page).toHaveURL(/\/admin\/auth-sources$/); await expect(page.getByLabel(t("authSources:tag"))).toHaveValue("littleskin"); await expect(page.getByLabel(t("authSources:tag"))).toHaveAttribute("readonly", ""); + await page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) }).click(); await page.getByRole("button", { name: t("authSources:add"), exact: true }).click(); await page.getByLabel(t("authSources:tag")).nth(1).fill("custom"); await page.getByLabel(t("authSources:prefix")).nth(1).fill("CS"); @@ -14,12 +15,12 @@ test("Owner manages durable authentication sources from the sidebar", async ({ p await page.getByRole("button", { name: t("authSources:test"), exact: true }).nth(1).click(); await expect(page.getByText(t("authSources:test_ok", { ms: 20 }), { exact: true })).toBeVisible(); await page.getByRole("button", { name: t("authSources:move_up"), exact: true }).nth(1).click(); - await page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck(); + await page.getByRole("switch", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck(); await page.getByRole("button", { name: t("authSources:save"), exact: true }).click(); await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible(); await page.reload(); await expect(page.getByLabel(t("authSources:tag")).nth(0)).toHaveValue("custom"); - await expect(page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked(); + await expect(page.getByRole("switch", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked(); await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toHaveCount(0); const available = await page.request.get("/api/v1/account/link/sources"); expect((await available.json()).sources.map((source: { tag: string }) => source.tag)).toEqual(["mojang", "custom"]); diff --git a/panel/e2e/fixtures.ts b/panel/e2e/fixtures.ts index b44f8b3..4f6f679 100644 --- a/panel/e2e/fixtures.ts +++ b/panel/e2e/fixtures.ts @@ -36,6 +36,18 @@ export const test = base.extend<{ signIn: (account: Account) => Promise }> export { expect }; +export async function expectPinnedHeading(page: Page, title: string, top: number) { + const heading = page.getByRole("heading", { name: title, exact: true }); + const before = (await heading.boundingBox())!; + const pane = page.getByRole("main"); + await pane.evaluate((el) => { el.scrollTop = el.scrollHeight; }); + await expect.poll(() => pane.evaluate((el) => el.scrollTop)).toBeGreaterThan(100); + const after = (await heading.boundingBox())!; + expect(after.y).toBeCloseTo(before.y, 0); + expect(after.y).toBeGreaterThanOrEqual(top); + await expect(heading).toBeInViewport({ ratio: 1 }); +} + /** expectFitsScreen fails when anything scrolls sideways or a visible element * pokes past the viewport edge (an overflow-hidden parent would just cut it * off). The shell scrolls in an overflow-y-auto pane, whose computed diff --git a/panel/e2e/mobile.spec.ts b/panel/e2e/mobile.spec.ts index 9b3d318..8835b77 100644 --- a/panel/e2e/mobile.spec.ts +++ b/panel/e2e/mobile.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, t, expectFitsScreen } from "./fixtures"; +import { test, expect, t, expectFitsScreen, expectPinnedHeading } from "./fixtures"; test("the menu drawer reaches the other sections", async ({ page, signIn }) => { await signIn("linked"); @@ -71,3 +71,12 @@ test("authentication source drafts with long IDs fit a phone", async ({ page, si await expectFitsScreen(page); await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toBeVisible(); }); + +test("the mobile navigation and page heading stay visible above a scrolling form", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/lobby"); + await expect(page.getByLabel(t("lobby:bookTitle"))).toBeEnabled(); + await expectPinnedHeading(page, t("lobby:title"), 56); + await expect(page.getByRole("button", { name: t("common:open_menu") })).toBeInViewport({ ratio: 1 }); + await expectFitsScreen(page); +}); diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index 7e03c83..f692979 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -1,4 +1,57 @@ -import { test, expect, t, expectFitsScreen } from "./fixtures"; +import { test, expect, t, expectFitsScreen, expectPinnedHeading } from "./fixtures"; + +test("admin headings share their size and gutters and stay pinned while content scrolls", async ({ page, signIn }) => { + await signIn("owner"); + await page.setViewportSize({ width: 1440, height: 600 }); + const metrics = []; + for (const [path, title] of [ + ["/admin/submissions", "admin:submissions_title"], + ["/admin/lobby", "lobby:title"], + ["/admin/auth-sources", "authSources:title"], + ]) { + await page.goto(path); + const heading = page.getByRole("heading", { name: t(title), exact: true }); + await expect(heading).toBeVisible(); + metrics.push(await heading.evaluate((el) => ({ x: el.getBoundingClientRect().x, y: el.getBoundingClientRect().y, size: getComputedStyle(el).fontSize }))); + expect(await page.getByRole("main").getByRole("heading", { level: 1 }).count()).toBe(0); + if (path === "/admin/auth-sources") { + await page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) }).click(); + await expectPinnedHeading(page, t(title), 0); + } else if (path === "/admin/lobby") { + await expect(page.getByLabel(t("lobby:bookTitle"))).toBeEnabled(); + await expectPinnedHeading(page, t(title), 0); + } + } + expect(metrics[1]).toEqual(metrics[0]); + expect(metrics[2]).toEqual(metrics[0]); +}); + +test("saved authentication sources start closed and switches leave them closed", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/auth-sources"); + const expand = page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) }); + await expect(expand).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByLabel(t("authSources:prefix"))).toBeHidden(); + const enabled = page.getByRole("switch", { name: t("authSources:enabled"), exact: true }); + await enabled.focus(); + await page.keyboard.press("Space"); + await expect(enabled).toHaveAttribute("aria-checked", "false"); + await expect(expand).toHaveAttribute("aria-expanded", "false"); + await page.getByRole("button", { name: t("authSources:save"), exact: true }).click(); + await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible(); + await expand.click(); + await expect(page.getByLabel(t("authSources:prefix"))).toBeVisible(); +}); + +test("the console keeps a bounded log pane below the fixed heading", async ({ page, signIn }) => { + await signIn("owner"); + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto("/servers/lobby"); + await expect(page.getByRole("textbox", { name: t("servers:command_label") })).toBeInViewport({ ratio: 1 }); + await expect(page.getByRole("button", { name: t("servers:log_clear_btn") })).toBeInViewport({ ratio: 1 }); + await expect(page.getByRole("heading", { name: "Hub Lobby", exact: true })).toBeInViewport({ ratio: 1 }); + expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBe(0); +}); test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => { await signIn("owner"); diff --git a/panel/src/components/AppShell.test.tsx b/panel/src/components/AppShell.test.tsx index 2821172..aa51ea8 100644 --- a/panel/src/components/AppShell.test.tsx +++ b/panel/src/components/AppShell.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen } from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import { Link, MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { AppShell } from "./AppShell"; +import { PageHeader } from "./PageHeader"; import type { Identity } from "@/lib/types"; const tier = vi.hoisted(() => ({ @@ -42,6 +44,21 @@ function roleLine(email: string) { } describe("AppShell user card", () => { + it("keeps page headings and actions outside the scrolling content and replaces them on navigation", async () => { + const action = vi.fn(); + render(}> + Header action} />Next page} /> +

Second content

} /> +
); + expect(screen.getByRole("main").contains(screen.getByRole("heading", { name: "First page" }))).toBe(false); + await userEvent.click(screen.getByRole("button", { name: "Header action" })); + expect(action).toHaveBeenCalledOnce(); + await userEvent.click(screen.getByRole("link", { name: "Next page" })); + expect(screen.queryByRole("heading", { name: "First page" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Header action" })).toBeNull(); + expect(screen.getByRole("main").contains(screen.getByRole("heading", { name: "Second page" }))).toBe(false); + expect(screen.getByRole("main").textContent).toContain("Second content"); + }); it.each([ ["en-US", "owner", "Owner"], ["en-US", "user", "User"], diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index e92c517..306063f 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -16,6 +16,7 @@ import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Loading } from "@/components/States"; import { ROLE_LABEL_KEY } from "@/components/RoleBadge"; import { FelisLogo } from "@/components/FelisLogo"; +import { PageHeaderHostContext } from "@/components/PageHeader"; function SectionGroup({ section, @@ -242,6 +243,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) { } export function AppShell() { + const [headerHost, setHeaderHost] = useState(null); const { isAdmin, isOwner } = useTier(); const { pathname } = useLocation(); const { t, i18n } = useTranslation("navigation"); @@ -291,34 +293,37 @@ export function AppShell() { -
- - -
-
- - - {t("common:brand_name")} -
-
- - -
-
-
-
- {/* A crash on one page leaves the navigation usable; moving to - another route clears it. Pages load as their own chunks, so the - first visit to one shows a spinner here with the shell in place, - and a chunk gone after a deploy lands in the boundary. */} - - }> - - - -
-
-
+ +
+ + +
+
+ + + {t("common:brand_name")} +
+
+ + +
+
+
+
+
+ {/* A crash on one page leaves the navigation usable; moving to + another route clears it. Pages load as their own chunks, so the + first visit to one shows a spinner here with the shell in place, + and a chunk gone after a deploy lands in the boundary. */} + + }> + + + +
+
+
+
); } diff --git a/panel/src/components/PageHeader.tsx b/panel/src/components/PageHeader.tsx index e281a78..cfdd5ee 100644 --- a/panel/src/components/PageHeader.tsx +++ b/panel/src/components/PageHeader.tsx @@ -1,7 +1,10 @@ import React from "react"; +import { createPortal } from "react-dom"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +export const PageHeaderHostContext = React.createContext(null); + export interface PageHeaderProps { icon?: LucideIcon | React.ReactNode; title: React.ReactNode; @@ -11,9 +14,10 @@ export interface PageHeaderProps { } export function PageHeader({ icon: Icon, title, subtitle, actions, className }: PageHeaderProps) { - return ( -
-
+ const host = React.useContext(PageHeaderHostContext); + const header = ( +
+
{Icon && (
{React.isValidElement(Icon) @@ -23,7 +27,7 @@ export function PageHeader({ icon: Icon, title, subtitle, actions, className }: })}
)} -
+

{title}

{subtitle &&
{subtitle}
}
@@ -31,4 +35,5 @@ export function PageHeader({ icon: Icon, title, subtitle, actions, className }: {actions &&
{actions}
}
); + return host ? createPortal(header, host) : header; } diff --git a/panel/src/components/ui/switch.tsx b/panel/src/components/ui/switch.tsx new file mode 100644 index 0000000..7344a8a --- /dev/null +++ b/panel/src/components/ui/switch.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface SwitchProps extends Omit, "onChange" | "onClick"> { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export const Switch = React.forwardRef( + ({ checked, onCheckedChange, className, ...props }, ref) => ( + + ), +); +Switch.displayName = "Switch"; diff --git a/panel/src/i18n/resources/en-US/authSources.json b/panel/src/i18n/resources/en-US/authSources.json index cec19da..e9eacaf 100644 --- a/panel/src/i18n/resources/en-US/authSources.json +++ b/panel/src/i18n/resources/en-US/authSources.json @@ -10,6 +10,8 @@ "new_source": "New source", "loading": "Loading authentication sources…", "enabled": "Enabled", + "expand": "Expand {{name}}", + "collapse": "Collapse {{name}}", "move_up": "Move source up", "move_down": "Move source down", "remove": "Remove unsaved source", diff --git a/panel/src/i18n/resources/zh-CN/authSources.json b/panel/src/i18n/resources/zh-CN/authSources.json index d205cb0..d6e35d8 100644 --- a/panel/src/i18n/resources/zh-CN/authSources.json +++ b/panel/src/i18n/resources/zh-CN/authSources.json @@ -10,6 +10,8 @@ "new_source": "新认证源", "loading": "正在加载认证源…", "enabled": "启用", + "expand": "展开 {{name}}", + "collapse": "收起 {{name}}", "move_up": "上移认证源", "move_down": "下移认证源", "remove": "移除未保存的认证源", diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 9ff7e2a..9e129df 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -263,7 +263,7 @@ export function ServerConsole() { const failure = data ? startFailure(data) : null; return ( -
+
@@ -301,7 +301,7 @@ export function ServerConsole() { )} -
+
{/* Left/Main column: Console */}
@@ -336,7 +336,7 @@ export function ServerConsole() {
{/* Right/Sidebar column: Navigation */} -
+
{isAdmin && ( ({ sources, revi function page() { render(); } const button = (name: string) => screen.getByRole("button", { name }); const field = (label: string, index = 0) => screen.getAllByLabelText(label)[index] as HTMLInputElement; -async function ready() { await waitFor(() => expect(field("Permanent source ID").disabled).toBe(false)); } +async function ready() { await userEvent.click(await screen.findByRole("button", { name: "Expand littleskin" })); } beforeEach(() => { vi.clearAllMocks(); @@ -23,6 +23,32 @@ beforeEach(() => { }); describe("AuthSourcesPage", () => { + it("collapses saved sources and retains unsaved edits through keyboard folding", async () => { + page(); + const toggle = await screen.findByRole("button", { name: "Expand littleskin" }); + expect(screen.queryByRole("textbox")).toBeNull(); + toggle.focus(); + await userEvent.keyboard("{Enter}"); + fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } }); + await userEvent.keyboard("{Enter}"); + expect(screen.queryByRole("textbox")).toBeNull(); + await userEvent.keyboard(" "); + expect(screen.getByRole("textbox", { name: "Name collision prefix" })).toHaveProperty("value", "NEW"); + expect(calls.setAuthSources).not.toHaveBeenCalled(); + }); + it("toggles a closed source with the keyboard without expanding its fields", async () => { + page(); + await screen.findByRole("button", { name: "Expand littleskin" }); + expect((button("Reload") as HTMLButtonElement).disabled).toBe(false); + const enabled = screen.getByRole("switch", { name: "Enabled" }); + enabled.focus(); + await userEvent.keyboard(" "); + expect(enabled.getAttribute("aria-checked")).toBe("false"); + expect(screen.queryByRole("textbox")).toBeNull(); + await userEvent.click(button("Save & apply")); + await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source(), enabled: false }], "original")); + expect(screen.getByRole("button", { name: "Expand littleskin" })).toBeTruthy(); + }); it("shows disabled fields while loading, then protects saved IDs", async () => { let resolve!: (settings: AuthSourcesSettings) => void; calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; })); @@ -37,8 +63,11 @@ describe("AuthSourcesPage", () => { expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull(); }); it("adds a source, fixes a duplicate ID, saves and locks it without sending UI state", async () => { - page(); await ready(); + page(); await screen.findByRole("button", { name: "Expand littleskin" }); await userEvent.click(button("Add source")); + expect(screen.getByRole("button", { name: "Expand littleskin" }).getAttribute("aria-expanded")).toBe("false"); + expect(screen.getByRole("button", { name: "Collapse New source" }).getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("textbox", { name: "Permanent source ID" })).toBe(field("Permanent source ID", 1)); fireEvent.change(field("Permanent source ID", 1), { target: { value: "littleskin" } }); expect(field("Permanent source ID", 1).readOnly).toBe(false); expect(button("Remove unsaved source")).toBeTruthy(); @@ -57,11 +86,14 @@ describe("AuthSourcesPage", () => { calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")])); page(); await ready(); await userEvent.click(screen.getAllByRole("button", { name: "Move source up" })[1]); + expect(screen.getByRole("button", { name: "Collapse littleskin" }).getAttribute("aria-controls")).toBe("source-fields-1"); + expect(screen.getByRole("button", { name: "Expand custom" }).getAttribute("aria-expanded")).toBe("false"); expect(field("Permanent source ID").value).toBe("custom"); - await userEvent.click(screen.getAllByRole("checkbox", { name: "Enabled" })[0]); + await userEvent.click(screen.getAllByRole("switch", { name: "Enabled" })[0]); await userEvent.click(button("Save & apply")); await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original")); await waitFor(() => expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true)); + await userEvent.click(button("Expand custom")); fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } }); await userEvent.click(button("Discard changes")); expect(field("Name collision prefix").value).toBe("CS"); diff --git a/panel/src/pages/admin/AuthSourcesPage.tsx b/panel/src/pages/admin/AuthSourcesPage.tsx index 6edc669..136788a 100644 --- a/panel/src/pages/admin/AuthSourcesPage.tsx +++ b/panel/src/pages/admin/AuthSourcesPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ArrowDown, ArrowUp, Check, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, ChevronRight, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@/components/PageHeader"; import { InlineError, MessageLine } from "@/components/MessageLine"; @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { api, humanizeError } from "@/lib/api"; import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types"; @@ -26,10 +27,10 @@ function validSource(source: AuthSourceConfig): boolean { /^[a-z0-9]{1,4}$/i.test(source.prefix) && endpoint(source.url) && (!source.api_url || endpoint(source.api_url)); } -type SourceDraft = AuthSourceConfig & { locked?: boolean }; +type SourceDraft = AuthSourceConfig & { locked?: boolean; expanded?: boolean }; type Draft = Omit & { sources: SourceDraft[]; original: string }; -const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, ...source }) => source); -const loadedDraft = (settings: AuthSourcesSettings): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true })), original: JSON.stringify(settings.sources) }); +const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, expanded: _expanded, ...source }) => source); +const loadedDraft = (settings: AuthSourcesSettings, previous: SourceDraft[] = []): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true, expanded: previous.find((s) => s.tag === source.tag)?.expanded ?? false })), original: JSON.stringify(settings.sources) }); export function AuthSourcesPage() { const { t } = useTranslation("authSources"); @@ -41,7 +42,7 @@ export function AuthSourcesPage() { const [checks, setChecks] = useState>({}); const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); useEffect(() => { - if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!)); + if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!, current?.sources)); }, [query.data]); const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original; useUnsavedGuard(dirty); @@ -58,7 +59,7 @@ export function AuthSourcesPage() { function discard() { if (!draft) return; - setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] })); + setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] }, draft.sources)); setChecks({}); setMessage(null); } @@ -69,7 +70,7 @@ export function AuthSourcesPage() { setMessage(null); try { const saved = await reauth.guard(() => api.setAuthSources(wireSources(draft.sources), draft.revision)); - setDraft(loadedDraft(saved)); + setDraft((current) => loadedDraft(saved, current?.sources)); setMessage({ kind: "success", text: t("saved") }); } catch (error) { if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) }); @@ -87,7 +88,7 @@ export function AuthSourcesPage() { } finally { setTesting(null); } } - return
+ return
@@ -97,7 +98,7 @@ export function AuthSourcesPage() {

{t("custom_title")}

{t("priority_hint")}

- +
{query.loading &&

{t("loading")}

} @@ -105,6 +106,7 @@ export function AuthSourcesPage() {
{(draft?.sources ?? [emptySource()]).map((source, index) => { const locked = "locked" in source && source.locked === true; + const expanded = "expanded" in source ? source.expanded === true : true; const set = (patch: Partial) => draft && edit(draft.sources.map((s, i) => i === index ? { ...s, ...patch } : s)); const move = (offset: number) => { if (!draft) return; @@ -113,16 +115,22 @@ export function AuthSourcesPage() { edit(sources); }; return - -
{index + 2}{source.tag || t(draft ? "new_source" : "loading")}
+ + + +
- + {!locked && }
- +