Fix the Stage 1 Dashboard UX defects found in assembled review
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with the sidebar collapsed, and on an iPhone. This fixes what that review found, and records the decisions in ADR 0064: - Discover is its own page at /settings/discover, with Installed and Updates beneath it as children. There is no Browse item any more. /settings/discover/browse is still read, as an alias, and is respelled in place. Parents and children now read as a hierarchy: a guide line, text-only children, a disclosure chevron, and parent and current states that look different. - The sidebar is navigation only. Display pairing moves from the sidebar and drawer to Settings -> Displays: the paired displays, a deliberate Add display dialog, and revocation. Nothing takes focus on arrival; the code field is focused only once Add display is chosen. - Collapsed icons are centred. Nav-link layout moves from inline styles to the shell stylesheet, and every collapsed item is one square centred on the rail. - /settings is an overview of the installation: displays paired, modules installed and running, and a line for each other area. It reads only existing endpoints. - Phones get a sticky, safe-area-aware header holding the menu trigger and the page name. New pages start at the top, and focus no longer scrolls content under the header. - @ekklesia/ui draws checkbox and radio indicators from tokens rather than accent-color, so the selected state is the strong accent in Light and Dark alike. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
186668915b
commit
405e509bcb
@@ -12,6 +12,7 @@ import {
|
||||
returnTargetFromSearch,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
DISCOVER_CHILD_VIEWS,
|
||||
DISCOVER_VIEWS,
|
||||
SETTINGS_SECTIONS,
|
||||
} from "../src/dashboard-routes.js";
|
||||
@@ -134,26 +135,41 @@ describe("Settings routes (ADR 0061)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
|
||||
for (const view of DISCOVER_VIEWS) {
|
||||
it("reads Discover's own page at /settings/discover, and Installed and Updates beneath it", () => {
|
||||
expect(DISCOVER_CHILD_VIEWS).toEqual(["installed", "updates"]);
|
||||
// Discover itself is where modules are found and installed (ADR 0064): one canonical address.
|
||||
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
|
||||
expect(parseDashboardPath("/settings/discover/")).toEqual({ kind: "discover", view: "browse" });
|
||||
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover");
|
||||
for (const view of DISCOVER_CHILD_VIEWS) {
|
||||
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
|
||||
kind: "discover",
|
||||
view,
|
||||
});
|
||||
expect(dashboardPath({ kind: "discover", view }), view).toBe(`/settings/discover/${view}`);
|
||||
}
|
||||
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
|
||||
// place, so there is one meaningful Browse address rather than two.
|
||||
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
|
||||
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
|
||||
for (const pathname of [
|
||||
"/settings/discover/catalog",
|
||||
"/settings/discover/browse/signage",
|
||||
"/settings/discover/installed/signage",
|
||||
"/settings/discover//",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||
}
|
||||
});
|
||||
|
||||
it("still reads the earlier /settings/discover/browse, only as an alias the shell respells", () => {
|
||||
// A bookmark from before ADR 0064 keeps working: it reads as Discover's own page, whose one
|
||||
// canonical spelling is `/settings/discover`, so the shell replaces it in place.
|
||||
expect(parseDashboardPath("/settings/discover/browse")).toEqual({
|
||||
kind: "discover",
|
||||
view: "browse",
|
||||
});
|
||||
expect(dashboardPath(parseDashboardPath("/settings/discover/browse") as never)).toBe(
|
||||
"/settings/discover",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads every other name as a module's settings — never under /settings/modules/", () => {
|
||||
expect(parseDashboardPath("/settings/signage")).toEqual({
|
||||
kind: "module-settings",
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DevicePanel } from "../src/browser/DevicePanel.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
function server() {
|
||||
let revoked = false;
|
||||
const revokes: string[] = [];
|
||||
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/devices")) {
|
||||
return json({
|
||||
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
|
||||
revoked = true;
|
||||
revokes.push(url);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return json({ code: "NOT_FOUND" }, 404);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
return { fetch, revokes };
|
||||
}
|
||||
|
||||
function query(testId: string): HTMLElement | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
async function click(testId: string): Promise<void> {
|
||||
await act(async () => {
|
||||
query(testId)?.click();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
|
||||
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
||||
*/
|
||||
describe("display device revocation", () => {
|
||||
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
|
||||
const platform = server();
|
||||
const confirm = vi.fn(() => true);
|
||||
Object.assign(window, { confirm });
|
||||
await act(async () => {
|
||||
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
||||
});
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
const dialog = container.querySelector('[role="dialog"]');
|
||||
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
|
||||
expect(dialog?.textContent).toContain("pairing code");
|
||||
expect(platform.revokes).toEqual([]);
|
||||
|
||||
await click("device-revoke-cancel");
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(platform.revokes).toEqual([]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes only after the destructive action is confirmed", async () => {
|
||||
const platform = server();
|
||||
await act(async () => {
|
||||
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
||||
});
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
await click("device-revoke-confirm");
|
||||
|
||||
expect(platform.revokes).toHaveLength(1);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(query("device-device-1")?.textContent).toContain("(revoked)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DisplaysSettingsPage } from "../src/browser/DisplaysSettingsPage.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into
|
||||
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
|
||||
*/
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
interface ServerOptions {
|
||||
readonly devices?: unknown[];
|
||||
readonly denied?: boolean;
|
||||
}
|
||||
|
||||
function server(options: ServerOptions = {}) {
|
||||
let devices: unknown[] = options.devices ?? [
|
||||
{
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
id: "device-1",
|
||||
lastSeenAt: "2026-10-15T11:50:00.000Z",
|
||||
name: "Foyer TV",
|
||||
permissions: [],
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
const requests: { path: string; body: unknown }[] = [];
|
||||
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
||||
requests.push({ body, path });
|
||||
if (path === "/api/v1/devices") {
|
||||
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
|
||||
}
|
||||
if (path === "/api/v1/devices/device-1/revoke") {
|
||||
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (path === "/api/v1/device-pairing/lookups") {
|
||||
return (body as { code: string }).code === "123456"
|
||||
? json({
|
||||
expiresAt: "2026-10-15T12:10:00.000Z",
|
||||
moduleId: "signage",
|
||||
permissionId: "signage.display",
|
||||
requestedAt: "2026-10-15T11:59:00.000Z",
|
||||
})
|
||||
: json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
|
||||
}
|
||||
if (path === "/api/v1/device-pairing/approvals") {
|
||||
devices = [
|
||||
...devices,
|
||||
{
|
||||
createdAt: "2026-10-15T12:00:00.000Z",
|
||||
id: "device-2",
|
||||
name: (body as { deviceName: string }).deviceName,
|
||||
permissions: [],
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
return json({ moduleId: "signage", permissionId: "signage.display" });
|
||||
}
|
||||
return json({ code: "NOT_FOUND" }, 404);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
return { fetch, requests };
|
||||
}
|
||||
|
||||
function query(testId: string): HTMLElement | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
function required(testId: string): HTMLElement {
|
||||
const element = query(testId);
|
||||
if (!element) {
|
||||
throw new Error(`missing ${testId}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
async function click(testId: string): Promise<void> {
|
||||
await act(async () => {
|
||||
required(testId).click();
|
||||
await settle();
|
||||
});
|
||||
}
|
||||
|
||||
async function type(testId: string, value: string): Promise<void> {
|
||||
const input = required(testId) as HTMLInputElement;
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(testId: string): Promise<void> {
|
||||
const form = (required(testId) as HTMLButtonElement).form;
|
||||
await act(async () => {
|
||||
form?.requestSubmit();
|
||||
await settle();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPage(options: ServerOptions = {}) {
|
||||
const platform = server(options);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DisplaysSettingsPage
|
||||
back={{ href: "/settings", label: "Back to Settings", onBack: () => undefined }}
|
||||
config={{ apiBaseUrl, fetch: platform.fetch }}
|
||||
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
||||
now={() => NOW}
|
||||
/>,
|
||||
);
|
||||
await settle();
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
|
||||
describe("Settings → Displays", () => {
|
||||
it("lists the paired displays with what is known about each, and takes no focus", async () => {
|
||||
const before = document.activeElement;
|
||||
await renderPage();
|
||||
|
||||
const card = required("device-device-1");
|
||||
expect(card.textContent).toContain("Foyer TV");
|
||||
expect(card.textContent).toContain("Paired");
|
||||
expect(card.textContent).toContain("Last seen within the last hour");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
||||
// No pairing form until someone asks for one, and nothing grabbed focus.
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
|
||||
await renderPage({ devices: [] });
|
||||
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await renderPage({ denied: true });
|
||||
expect(container.textContent).toContain("Displays aren't available to you");
|
||||
expect(query("add-display")).toBeNull();
|
||||
});
|
||||
|
||||
it("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
|
||||
const platform = await renderPage();
|
||||
|
||||
await click("add-display");
|
||||
const dialog = required("add-display-dialog");
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
// Typing the code is now plainly the next thing to do, so the field has focus.
|
||||
expect(document.activeElement).toBe(required("device-pairing-code-input"));
|
||||
|
||||
await type("device-pairing-code-input", "999999");
|
||||
await submit("device-pairing-lookup");
|
||||
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
||||
|
||||
await type("device-pairing-code-input", "123456");
|
||||
await submit("device-pairing-lookup");
|
||||
expect(required("device-pairing-found").textContent).toContain("Signage");
|
||||
expect(document.activeElement).toBe(required("device-pairing-name"));
|
||||
|
||||
await type("device-pairing-name", "Sanctuary left");
|
||||
await submit("device-pairing-approve");
|
||||
|
||||
expect(query("add-display-dialog")).toBeNull();
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: "123456", deviceName: "Sanctuary left" });
|
||||
// The new display appears without a page refresh, and the page says so.
|
||||
expect(required("device-device-2").textContent).toContain("Sanctuary left");
|
||||
expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired");
|
||||
// Focus is back on the control that opened the flow.
|
||||
expect(document.activeElement).toBe(required("add-display"));
|
||||
});
|
||||
|
||||
it("closes the Add display flow on Cancel without pairing anything", async () => {
|
||||
const platform = await renderPage();
|
||||
await click("add-display");
|
||||
await click("add-display-cancel");
|
||||
|
||||
expect(query("add-display-dialog")).toBeNull();
|
||||
expect(platform.requests.map((r) => r.path)).toEqual(["/api/v1/devices"]);
|
||||
expect(document.activeElement).toBe(required("add-display"));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Revoking a display strands that screen until someone re-pairs it in person, so the page explains
|
||||
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
||||
*/
|
||||
describe("display revocation", () => {
|
||||
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
|
||||
const confirm = vi.fn(() => true);
|
||||
Object.assign(window, { confirm });
|
||||
const platform = await renderPage();
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
const dialog = container.querySelector('[role="dialog"]');
|
||||
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
|
||||
expect(dialog?.textContent).toContain("pairing code");
|
||||
|
||||
await click("device-revoke-cancel");
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(platform.requests.some((r) => r.path.endsWith("/revoke"))).toBe(false);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes only after the destructive action is confirmed", async () => {
|
||||
const platform = await renderPage();
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
await click("device-revoke-confirm");
|
||||
|
||||
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(required("device-device-1").textContent).toContain("Revoked");
|
||||
expect(query("device-revoke-device-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -487,8 +487,8 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
"/api/v1/dashboard/settings",
|
||||
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
|
||||
"/api/v1/account",
|
||||
// The host-owned device panel loads with the rest of the shell for an administrator.
|
||||
"/api/v1/devices",
|
||||
// Nothing else: the sidebar is navigation only, so the shell no longer asks for the device
|
||||
// list on every page. Displays are Settings → Displays' own business (ADR 0064).
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,50 @@ const ADMINISTRATOR: DashboardSettings = {
|
||||
sections: ["general", "branding", "displays", "discover", "system"],
|
||||
};
|
||||
|
||||
const DEVICES = [
|
||||
{
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
id: "device-1",
|
||||
lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
name: "Foyer TV",
|
||||
permissions: ["signage.display"],
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
id: "device-2",
|
||||
name: "Old hall screen",
|
||||
permissions: ["signage.display"],
|
||||
revoked: true,
|
||||
},
|
||||
];
|
||||
|
||||
const LIFECYCLE = [
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "signage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "0.1.5",
|
||||
},
|
||||
{
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "archive",
|
||||
restartRequired: false,
|
||||
runtime: { state: "disabled" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
];
|
||||
|
||||
/** Lets pending requests settle, so the overview's summaries have answered. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface Rendered {
|
||||
readonly history: MemoryHostHistory;
|
||||
readonly calls: string[];
|
||||
@@ -84,7 +128,9 @@ async function render(
|
||||
options: {
|
||||
readonly settings?: DashboardSettings | "fails";
|
||||
readonly compact?: boolean;
|
||||
readonly collapsed?: boolean;
|
||||
readonly history?: MemoryHostHistory;
|
||||
readonly devices?: unknown[];
|
||||
} = {},
|
||||
): Promise<Rendered> {
|
||||
const history = options.history ?? createMemoryHostHistory(path);
|
||||
@@ -107,6 +153,19 @@ async function render(
|
||||
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
|
||||
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
|
||||
}
|
||||
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
|
||||
const visible = options.settings ?? ADMINISTRATOR;
|
||||
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
|
||||
if (url.endsWith("/api/v1/devices")) {
|
||||
return sections.includes("displays")
|
||||
? Response.json({ devices: options.devices ?? DEVICES })
|
||||
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||
}
|
||||
if (url.endsWith("/api/v1/module-lifecycle")) {
|
||||
return sections.includes("discover")
|
||||
? Response.json({ modules: LIFECYCLE })
|
||||
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||
}
|
||||
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||
}) as unknown as typeof fetch;
|
||||
await act(async () => {
|
||||
@@ -125,7 +184,9 @@ async function render(
|
||||
},
|
||||
})}
|
||||
setDocumentTitle={(title) => titles.push(title)}
|
||||
sidebarPreference={createMemorySidebarPreference()}
|
||||
sidebarPreference={createMemorySidebarPreference(
|
||||
options.collapsed ? "collapsed" : undefined,
|
||||
)}
|
||||
viewport={createMemoryHostViewport(options.compact ?? false)}
|
||||
/>,
|
||||
);
|
||||
@@ -157,33 +218,70 @@ const current = () =>
|
||||
);
|
||||
|
||||
describe("the Settings landing page", () => {
|
||||
it("is a real page, not a redirect, listing what this person may open", async () => {
|
||||
const { history, titles } = await render("/settings");
|
||||
it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => {
|
||||
const { history, titles, calls } = await render("/settings");
|
||||
await settle();
|
||||
|
||||
expect(history.entries).toEqual(["/settings"]);
|
||||
expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
|
||||
const home = required("settings-home");
|
||||
expect(home.querySelector("h1")?.textContent).toBe("Settings");
|
||||
// Each area is a summary of its state, read from what already exists (ADR 0064).
|
||||
expect(required("settings-summary-displays-status").textContent).toBe("1 display paired");
|
||||
expect(required("settings-summary-displays").textContent).toContain(
|
||||
"Foyer TV was last seen within the last hour.",
|
||||
);
|
||||
expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed");
|
||||
expect(required("settings-summary-modules").textContent).toContain("1 turned off");
|
||||
expect(required("settings-summary-branding-status").textContent).toBe(
|
||||
"Using the default name and colours",
|
||||
);
|
||||
expect(calls).toContain("/api/v1/devices");
|
||||
expect(calls).toContain("/api/v1/module-lifecycle");
|
||||
// The one link that changes each area, rather than the sidebar's list again.
|
||||
expect(
|
||||
[...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
|
||||
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/settings/displays",
|
||||
"/settings/discover/installed",
|
||||
"/settings/signage",
|
||||
"/settings/general",
|
||||
"/settings/branding",
|
||||
"/settings/displays",
|
||||
"/settings/discover/browse",
|
||||
"/settings/system",
|
||||
"/settings/signage",
|
||||
]);
|
||||
// Not one navigation item per section, the way the sidebar lists them.
|
||||
expect(home.querySelector(".ek-shell-settings-link")).toBeNull();
|
||||
// A disabled module's settings are not offered.
|
||||
expect(query("settings-home-module-archive")).toBeNull();
|
||||
// Each group is a section of the page, below its one h1.
|
||||
expect(
|
||||
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
|
||||
).toEqual(["Platform", "Modules"]);
|
||||
).toEqual(["Displays", "Modules", "General", "Branding", "System"]);
|
||||
// Nothing on it takes focus by itself.
|
||||
expect(document.activeElement?.tagName).not.toBe("INPUT");
|
||||
expect(titles.at(-1)).toBe("Settings");
|
||||
expect(current()).toEqual(["select-settings"]);
|
||||
});
|
||||
|
||||
it("summarises only what this person may see, and asks for nothing else", async () => {
|
||||
const { calls } = await render("/settings", {
|
||||
settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] },
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(query("settings-summary-displays")).toBeNull();
|
||||
expect(required("settings-summary-modules-status").textContent).toBe("Module settings");
|
||||
expect(calls).not.toContain("/api/v1/devices");
|
||||
expect(calls).not.toContain("/api/v1/module-lifecycle");
|
||||
});
|
||||
|
||||
it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => {
|
||||
await render("/settings", { devices: [] });
|
||||
await settle();
|
||||
expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet");
|
||||
expect(required("settings-home-displays").textContent).toBe("Add a display");
|
||||
});
|
||||
|
||||
it("says plainly when there is nothing for this person to change", async () => {
|
||||
await render("/settings", { settings: { modules: [], sections: [] } });
|
||||
|
||||
@@ -195,7 +293,7 @@ describe("the Settings landing page", () => {
|
||||
|
||||
describe("the platform sections", () => {
|
||||
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
|
||||
for (const section of ["general", "branding", "displays", "system"] as const) {
|
||||
for (const section of ["general", "branding", "system"] as const) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
@@ -215,10 +313,10 @@ describe("the platform sections", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
|
||||
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
|
||||
const { history, titles } = await render("/settings/discover");
|
||||
|
||||
expect(history.entries).toEqual(["/settings/discover/browse"]);
|
||||
expect(history.entries).toEqual(["/settings/discover"]);
|
||||
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
|
||||
"Discover modules",
|
||||
);
|
||||
@@ -226,38 +324,86 @@ describe("the platform sections", () => {
|
||||
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
|
||||
);
|
||||
expect(titles.at(-1)).toBe("Discover modules");
|
||||
expect(current()).toEqual(["select-discover-browse"]);
|
||||
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
|
||||
"/settings/discover/browse",
|
||||
);
|
||||
// Discover is the current page; it is not a parent of some separate Browse page.
|
||||
expect(current()).toEqual(["select-settings-discover"]);
|
||||
expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover");
|
||||
expect(query("select-discover-browse")).toBeNull();
|
||||
});
|
||||
|
||||
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
|
||||
const { history } = await render("/settings/discover/browse");
|
||||
it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => {
|
||||
const { history } = await render("/settings/discover/browse?return=%2Fsignage");
|
||||
|
||||
const views = [...required("discover-subnavigation").querySelectorAll("a")];
|
||||
expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]);
|
||||
expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]);
|
||||
expect(query("settings-discover-browse")).not.toBeNull();
|
||||
expect(current()).toEqual(["select-settings-discover"]);
|
||||
// Nothing links to the alias.
|
||||
expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => {
|
||||
const { history } = await render("/settings/discover");
|
||||
|
||||
const children = required("discover-subnavigation");
|
||||
const views = [...children.querySelectorAll("a")];
|
||||
expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]);
|
||||
expect(views.map((view) => view.getAttribute("href"))).toEqual([
|
||||
"/settings/discover/browse",
|
||||
"/settings/discover/installed",
|
||||
"/settings/discover/updates",
|
||||
]);
|
||||
// Children: text only, marked as children, and listed beneath Discover by name.
|
||||
expect(children.getAttribute("aria-label")).toBe("Discover");
|
||||
expect(children.className).toContain("ek-shell-nav__children");
|
||||
for (const view of views) {
|
||||
expect(view.dataset.navLevel).toBe("child");
|
||||
expect(view.querySelector("svg")).toBeNull();
|
||||
}
|
||||
expect(required("select-settings-discover-disclosure").dataset.open).toBe("true");
|
||||
|
||||
await click(required("select-discover-installed"));
|
||||
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
|
||||
"Installed modules",
|
||||
);
|
||||
// The child is the current page and Discover is marked as its parent, never also current.
|
||||
expect(current()).toEqual(["select-discover-installed"]);
|
||||
expect(required("select-settings-discover").dataset.navAncestor).toBe("");
|
||||
await click(required("select-discover-updates"));
|
||||
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
|
||||
"Module updates",
|
||||
);
|
||||
await click(required("select-settings-discover"));
|
||||
expect(current()).toEqual(["select-settings-discover"]);
|
||||
expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined();
|
||||
expect(history.entries).toEqual([
|
||||
"/settings/discover/browse",
|
||||
"/settings/discover",
|
||||
"/settings/discover/installed",
|
||||
"/settings/discover/updates",
|
||||
"/settings/discover",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows Discover closed, with no children, while another Settings page is open", async () => {
|
||||
await render("/settings/general");
|
||||
|
||||
expect(query("discover-subnavigation")).toBeNull();
|
||||
expect(required("select-settings-discover-disclosure").dataset.open).toBe("false");
|
||||
});
|
||||
|
||||
it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => {
|
||||
for (const path of ["/settings/discover", "/settings/discover/installed"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
await render(path, { collapsed: true });
|
||||
|
||||
expect(query("discover-subnavigation"), path).toBeNull();
|
||||
expect(query("select-discover-installed"), path).toBeNull();
|
||||
expect(current(), path).toEqual(["select-settings-discover"]);
|
||||
// No disclosure chevron in the rail: it has no room for the children it would announce.
|
||||
expect(query("select-settings-discover-disclosure"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
|
||||
await render("/settings/discover/installed", {
|
||||
settings: { modules: [], sections: ["displays"] },
|
||||
@@ -383,13 +529,13 @@ describe("Settings in the navigation", () => {
|
||||
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
|
||||
const history = createMemoryHostHistory("/settings");
|
||||
await render("/settings", { history });
|
||||
await click(required("select-settings-displays"));
|
||||
await click(required("select-settings-branding"));
|
||||
await click(required("select-module-settings-signage"));
|
||||
|
||||
await act(async () => {
|
||||
history.go(-1);
|
||||
});
|
||||
expect(query("settings-section-displays")).not.toBeNull();
|
||||
expect(query("settings-section-branding")).not.toBeNull();
|
||||
await act(async () => {
|
||||
history.go(1);
|
||||
});
|
||||
@@ -436,3 +582,142 @@ describe("Settings in the navigation", () => {
|
||||
expect(query("settings-section-system")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sidebar is navigation only (ADR 0064)", () => {
|
||||
it("has no display-pairing block, expanded or in the phone drawer", async () => {
|
||||
await render("/settings");
|
||||
const sidebar = required("host-sidebar");
|
||||
expect(query("host-devices")).toBeNull();
|
||||
expect(sidebar.querySelector("input, form")).toBeNull();
|
||||
expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
await render("/settings", { compact: true });
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
const drawer = required("navigation-drawer");
|
||||
expect(drawer.querySelector("input, form")).toBeNull();
|
||||
expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu);
|
||||
});
|
||||
|
||||
it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => {
|
||||
for (const path of ["/settings", "/settings/displays", "/signage", "/"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
await render(path);
|
||||
await settle();
|
||||
expect(document.activeElement?.tagName, path).not.toBe("INPUT");
|
||||
expect(container.querySelector("input"), path).toBeNull();
|
||||
if (path === "/settings/displays") {
|
||||
expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe(
|
||||
"Displays",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Moving to Displays from the navigation moves focus to the page, never into a field.
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
await render("/settings");
|
||||
await click(required("select-settings-displays"));
|
||||
await settle();
|
||||
expect(document.activeElement).toBe(required("host-main-region"));
|
||||
expect(required("add-display").textContent).toContain("Add display");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsed navigation geometry (ADR 0064)", () => {
|
||||
it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => {
|
||||
await render("/settings/general", { collapsed: true });
|
||||
|
||||
const sidebar = required("host-sidebar");
|
||||
expect(sidebar.dataset.sidebar).toBe("collapsed");
|
||||
const items = [...sidebar.querySelectorAll<HTMLElement>("nav a")];
|
||||
expect(items.map((item) => item.dataset.testid)).toEqual([
|
||||
"select-overview",
|
||||
"select-signage",
|
||||
"select-calendar",
|
||||
"select-settings",
|
||||
"select-settings-general",
|
||||
"select-settings-branding",
|
||||
"select-settings-displays",
|
||||
"select-settings-discover",
|
||||
"select-settings-system",
|
||||
"select-module-settings-signage",
|
||||
]);
|
||||
for (const item of items) {
|
||||
// One class carries the geometry; no inline layout can outrank the collapsed rule.
|
||||
expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true);
|
||||
expect(item.style.justifyContent, item.dataset.testid).toBe("");
|
||||
expect(item.style.width, item.dataset.testid).toBe("");
|
||||
expect(item.style.padding, item.dataset.testid).toBe("");
|
||||
// Its name stays for assistive technology; only one glyph is drawn.
|
||||
expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1);
|
||||
expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy();
|
||||
}
|
||||
const stylesheet = [...container.querySelectorAll("style")]
|
||||
.map((style) => style.textContent)
|
||||
.join("\n");
|
||||
const collapsedRule =
|
||||
/\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec(
|
||||
stylesheet,
|
||||
)?.[1];
|
||||
expect(collapsedRule).toContain("justify-content: center");
|
||||
expect(collapsedRule).toContain("margin-inline: auto");
|
||||
expect(collapsedRule).toContain("padding: 0");
|
||||
expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)");
|
||||
expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the phone header (ADR 0064)", () => {
|
||||
it("is a sticky header holding the menu trigger and the open page's name", async () => {
|
||||
await render("/settings/displays", { compact: true });
|
||||
|
||||
const header = required("mobile-header");
|
||||
expect(header.tagName).toBe("HEADER");
|
||||
expect(header.className).toBe("ek-shell-mobile-bar");
|
||||
expect(header.contains(required("mobile-navigation-trigger"))).toBe(true);
|
||||
// A visual echo of the page title; the page's own h1 is what assistive technology reads.
|
||||
const title = required("mobile-header-title");
|
||||
expect(title.textContent).toBe("Displays settings");
|
||||
expect(title.getAttribute("aria-hidden")).toBe("true");
|
||||
const trigger = required("mobile-navigation-trigger");
|
||||
expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
||||
expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)");
|
||||
|
||||
const stylesheet = [...container.querySelectorAll("style")]
|
||||
.map((style) => style.textContent)
|
||||
.join("\n");
|
||||
const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1];
|
||||
expect(rule).toContain("position: sticky");
|
||||
expect(rule).toContain("top: 0");
|
||||
expect(rule).toContain("env(safe-area-inset-top, 0px)");
|
||||
});
|
||||
|
||||
it("follows the open page, and the drawer still opens from it and returns focus to it", async () => {
|
||||
await render("/settings", { compact: true });
|
||||
expect(required("mobile-header-title").textContent).toBe("Settings");
|
||||
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
const drawer = required("navigation-drawer");
|
||||
expect(document.activeElement).toBe(required("navigation-drawer-close"));
|
||||
await act(async () => {
|
||||
drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
});
|
||||
expect(query("navigation-drawer")).toBeNull();
|
||||
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
|
||||
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
await click(required("select-settings-discover"));
|
||||
expect(query("navigation-drawer")).toBeNull();
|
||||
expect(required("mobile-header-title").textContent).toBe("Discover modules");
|
||||
expect(document.activeElement).toBe(required("host-main-region"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user