Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Real-device review of the platform display flow found four defects in display management: - Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn single choice following the WAI-ARIA select-only combobox pattern, replacing the native select for "Show on this screen". It has a themed list, a chevron inside the control, keyboard support (arrows, Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only the list), pointer and touch support, and focus that stays on the control. It is an ek-control, so it keeps the 16px phone size. - Newly paired displays appear without a reload. Approval doesn't create the display; the screen does when it claims its credential a few seconds later. The page now shows a "Waiting for the screen to connect" placeholder and refetches every second until the new display appears (for up to a minute), and refreshes the list every 30 seconds. - What a display shows can change after pairing. In the single-target Stage 1 model, a display's current target is its one active display permission, so no migration is needed. PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted origin and a bounded body, revokes its other active permission rows (kept as history) and grants the chosen module's declared display permission, in one transaction on the same device. The screen notices within about 30 seconds: a paired device on a module page that is no longer set to show it goes to /display/setup instead of re-pairing, and /display/setup opens the new target. - Revoked displays leave the normal list at once. Their rows stay in the database as history. Each display card now shows its name, Online/Offline/Not connected yet, "Showing: ...", last seen and paired dates, and Change and Revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
300d9eab51
commit
d9dafcb0f7
@@ -432,6 +432,56 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes what a paired display shows (ADR 0065): `{ "moduleId": "<id>" }` for a running display
|
||||||
|
* module, or `{ "moduleId": null }` for nothing. The permission granted is that module's declared
|
||||||
|
* display permission, never one the request names. It is the same display — the same identity,
|
||||||
|
* name, and credential — before and after; the screen notices on its next check and opens the new
|
||||||
|
* module's page, or the platform's setup page, without being paired again.
|
||||||
|
*/
|
||||||
|
routes.put(
|
||||||
|
`${DEVICES_PREFIX}/:deviceId/display`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
if (!parsed.success || body === undefined || !("moduleId" in body)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
let permissionId: PermissionId | undefined;
|
||||||
|
if (body.moduleId !== null) {
|
||||||
|
const moduleId = readString(body, "moduleId", 128);
|
||||||
|
const display = moduleId === undefined ? undefined : findDisplay(moduleId);
|
||||||
|
if (display === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
permissionId = display.permissionId;
|
||||||
|
}
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
if (principal.kind !== "authenticated") {
|
||||||
|
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const changed = await options.devices.setDisplayTarget({
|
||||||
|
changedBy: principal.userId,
|
||||||
|
deviceId: parsed.data,
|
||||||
|
now: now(),
|
||||||
|
permissionId,
|
||||||
|
});
|
||||||
|
if (!changed) {
|
||||||
|
// Unknown and revoked alike: neither is a display whose content can change.
|
||||||
|
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.json({ moduleId: body.moduleId === null ? null : body.moduleId }, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||||
try {
|
try {
|
||||||
const devices = await options.devices.listDevices();
|
const devices = await options.devices.listDevices();
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ async function createEmptyModulesDirectory(): Promise<string> {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDisplayModuleDirectory(): Promise<string> {
|
/** A second display module's permission, for moving a display from one module to another. */
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
|
const bulletinPermission = "bulletin.display.view";
|
||||||
trackedDirectories.push(directory);
|
|
||||||
const moduleDirectory = path.join(directory, "announcements");
|
async function writeDisplayModule(
|
||||||
|
directory: string,
|
||||||
|
module: { readonly id: string; readonly name: string; readonly permission: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(directory, module.id);
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -116,7 +120,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
path.join(moduleDirectory, "package.json"),
|
path.join(moduleDirectory, "package.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ekklesia: { layer: "module" },
|
ekklesia: { layer: "module" },
|
||||||
name: "@test/module-announcements",
|
name: `@test/module-${module.id}`,
|
||||||
type: "module",
|
type: "module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -126,17 +130,17 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
display: { artifact: "./dist/ui/display.js", permission: module.permission },
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: module.id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
name: "Announcements fixture",
|
name: module.name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
baseline: "restricted",
|
baseline: "restricted",
|
||||||
description: "View the announcement slideshow.",
|
description: "View this module's display.",
|
||||||
id: displayPermission,
|
id: module.permission,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -145,10 +149,10 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
id: "announcements",
|
id: ${JSON.stringify(module.id)},
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
register({ realtime }) {
|
register({ realtime }) {
|
||||||
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
|
realtime.channel({ channel: "state", permission: ${JSON.stringify(module.permission)} });
|
||||||
},
|
},
|
||||||
start({ realtime }) {
|
start({ realtime }) {
|
||||||
realtime.publish("state", { slides: [] });
|
realtime.publish("state", { slides: [] });
|
||||||
@@ -158,6 +162,25 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
),
|
),
|
||||||
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisplayModuleDirectory(
|
||||||
|
options: { readonly second?: boolean } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
|
||||||
|
trackedDirectories.push(directory);
|
||||||
|
await writeDisplayModule(directory, {
|
||||||
|
id: "announcements",
|
||||||
|
name: "Announcements fixture",
|
||||||
|
permission: displayPermission,
|
||||||
|
});
|
||||||
|
if (options.second === true) {
|
||||||
|
await writeDisplayModule(directory, {
|
||||||
|
id: "bulletin",
|
||||||
|
name: "Bulletin fixture",
|
||||||
|
permission: bulletinPermission,
|
||||||
|
});
|
||||||
|
}
|
||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +298,7 @@ interface Platform {
|
|||||||
|
|
||||||
async function platform(
|
async function platform(
|
||||||
label: string,
|
label: string,
|
||||||
options: { readonly modules?: boolean } = {},
|
options: { readonly modules?: boolean | "two" } = {},
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
const url = await createMigratedDatabase(label);
|
const url = await createMigratedDatabase(label);
|
||||||
// `modules: false` is an installation with no module at all — nothing that shows content on a
|
// `modules: false` is an installation with no module at all — nothing that shows content on a
|
||||||
@@ -283,7 +306,7 @@ async function platform(
|
|||||||
const modulesDirectory =
|
const modulesDirectory =
|
||||||
options.modules === false
|
options.modules === false
|
||||||
? await createEmptyModulesDirectory()
|
? await createEmptyModulesDirectory()
|
||||||
: await createDisplayModuleDirectory();
|
: await createDisplayModuleDirectory({ second: options.modules === "two" });
|
||||||
const api = await startPlatform(url, modulesDirectory);
|
const api = await startPlatform(url, modulesDirectory);
|
||||||
const origin = apiOrigin(api);
|
const origin = apiOrigin(api);
|
||||||
const ownerCookie = await createOwner(url, origin);
|
const ownerCookie = await createOwner(url, origin);
|
||||||
@@ -1147,4 +1170,106 @@ describe.sequential("platform display pairing (ADR 0065)", () => {
|
|||||||
await running.close();
|
await running.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("changes what a paired display shows, on the same display, without pairing it again", async () => {
|
||||||
|
const running = await platform("platform-change", { modules: "two" });
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
const put = (deviceId: string, moduleId: string | null) =>
|
||||||
|
adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${deviceId}/display`,
|
||||||
|
{ moduleId },
|
||||||
|
{ method: "PUT" },
|
||||||
|
);
|
||||||
|
const listed = async () =>
|
||||||
|
(
|
||||||
|
(await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> }
|
||||||
|
).devices;
|
||||||
|
const self = async () =>
|
||||||
|
(await (await device.request("/api/v1/device-pairing/self")).json()) as {
|
||||||
|
displays: Array<{ moduleId: string }>;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// Paired from /display/setup with nothing to show.
|
||||||
|
const { code } = (await (
|
||||||
|
await device.post("/api/v1/device-pairing/requests", {})
|
||||||
|
).json()) as { code: string };
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Foyer TV",
|
||||||
|
});
|
||||||
|
await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
const [paired] = await listed();
|
||||||
|
const id = paired?.id ?? "";
|
||||||
|
expect(paired?.permissions).toEqual([]);
|
||||||
|
|
||||||
|
// Nothing yet → a module: it may now show it, and the screen learns so.
|
||||||
|
expect((await put(id, "announcements")).status).toBe(200);
|
||||||
|
expect((await self()).displays.map((display) => display.moduleId)).toEqual(["announcements"]);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
||||||
|
|
||||||
|
// Module A → module B: A's permission is withdrawn, B's granted, in one change.
|
||||||
|
expect((await put(id, "bulletin")).status).toBe(200);
|
||||||
|
expect((await self()).displays.map((display) => display.moduleId)).toEqual(["bulletin"]);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||||
|
expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(200);
|
||||||
|
|
||||||
|
// Module → nothing: it shows nothing, and is still the same paired display.
|
||||||
|
expect((await put(id, null)).status).toBe(200);
|
||||||
|
expect((await self()).displays).toEqual([]);
|
||||||
|
expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(403);
|
||||||
|
|
||||||
|
// Always the one identity, the one name, and the one credential.
|
||||||
|
const after = await listed();
|
||||||
|
expect(after).toHaveLength(1);
|
||||||
|
expect(after[0]).toMatchObject({ id, name: "Foyer TV", permissions: [] });
|
||||||
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||||
|
// Every change is kept as history: granted rows are revoked, never deleted.
|
||||||
|
const rows = await query<{ permission_id: string; revoked: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select permission_id, revoked_at is not null as revoked from ekklesia_core_device_permission order by granted_at`,
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ permission_id: displayPermission, revoked: true },
|
||||||
|
{ permission_id: bulletinPermission, revoked: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A module that is not a running display, or a missing body, is refused; so is a revoked
|
||||||
|
// display, and a person without a session.
|
||||||
|
expect((await put(id, "not-a-display")).status).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${id}/display`,
|
||||||
|
{},
|
||||||
|
{ method: "PUT" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${running.origin}/api/v1/devices/${id}/display`, {
|
||||||
|
body: JSON.stringify({ moduleId: null }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, `/api/v1/devices/${id}/revoke`, {});
|
||||||
|
expect((await put(id, "announcements")).status).toBe(404);
|
||||||
|
// Revoking keeps the identity as history: the row is still there, marked revoked.
|
||||||
|
const revoked = await query<{ revoked: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select revoked_at is not null as revoked from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(revoked).toEqual([{ revoked: true }]);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash, randomBytes, randomInt } from "node:crypto";
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||||
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||||
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
|
import { and, eq, gt, isNull, lt, ne, or, sql } from "drizzle-orm";
|
||||||
import type { DeviceDrizzleDatabase } from "./database.js";
|
import type { DeviceDrizzleDatabase } from "./database.js";
|
||||||
import {
|
import {
|
||||||
ekklesiaCoreDevice,
|
ekklesiaCoreDevice,
|
||||||
@@ -184,6 +184,22 @@ export interface DeviceStore {
|
|||||||
listDevices(): Promise<readonly DeviceSummary[]>;
|
listDevices(): Promise<readonly DeviceSummary[]>;
|
||||||
/** A live, unrevoked device's own name and active permissions, or `undefined`. */
|
/** A live, unrevoked device's own name and active permissions, or `undefined`. */
|
||||||
describeDevice(deviceId: EkklesiaDeviceId): Promise<DeviceSelf | undefined>;
|
describeDevice(deviceId: EkklesiaDeviceId): Promise<DeviceSelf | undefined>;
|
||||||
|
/**
|
||||||
|
* Sets what a live display shows (ADR 0065): the one module display permission it holds, or none.
|
||||||
|
*
|
||||||
|
* In this single-target model a display's active display permission *is* its current target, so
|
||||||
|
* there is one source of truth for "may it show this" and "is it showing this". The change is one
|
||||||
|
* transaction on the same device: every other active permission row is revoked — kept, with its
|
||||||
|
* `revokedAt`, as history — and the new permission is granted if the device does not already hold
|
||||||
|
* it. The device row, its name, and its credential never change. Returns `false` for a device that
|
||||||
|
* does not exist or is revoked.
|
||||||
|
*/
|
||||||
|
setDisplayTarget(input: {
|
||||||
|
readonly deviceId: EkklesiaDeviceId;
|
||||||
|
readonly permissionId: PermissionId | undefined;
|
||||||
|
readonly changedBy: EkklesiaUserId;
|
||||||
|
readonly now: Date;
|
||||||
|
}): Promise<boolean>;
|
||||||
revokeDevice(input: {
|
revokeDevice(input: {
|
||||||
readonly deviceId: EkklesiaDeviceId;
|
readonly deviceId: EkklesiaDeviceId;
|
||||||
readonly revokedBy: EkklesiaUserId;
|
readonly revokedBy: EkklesiaUserId;
|
||||||
@@ -576,6 +592,61 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setDisplayTarget(input): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
// Locks the device row, so a concurrent revoke or change waits rather than interleaving.
|
||||||
|
const devices = await tx
|
||||||
|
.select({ id: ekklesiaCoreDevice.id })
|
||||||
|
.from(ekklesiaCoreDevice)
|
||||||
|
.where(
|
||||||
|
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
|
||||||
|
)
|
||||||
|
.for("update")
|
||||||
|
.limit(1);
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = input.permissionId;
|
||||||
|
await tx
|
||||||
|
.update(ekklesiaCoreDevicePermission)
|
||||||
|
.set({ revokedAt: input.now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
|
||||||
|
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||||
|
...(target === undefined
|
||||||
|
? []
|
||||||
|
: [ne(ekklesiaCoreDevicePermission.permissionId, target)]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (target !== undefined) {
|
||||||
|
const held = await tx
|
||||||
|
.select({ id: ekklesiaCoreDevicePermission.id })
|
||||||
|
.from(ekklesiaCoreDevicePermission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
|
||||||
|
eq(ekklesiaCoreDevicePermission.permissionId, target),
|
||||||
|
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (held.length === 0) {
|
||||||
|
await tx.insert(ekklesiaCoreDevicePermission).values({
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
grantedByUserId: input.changedBy,
|
||||||
|
permissionId: target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async revokeDevice(input): Promise<void> {
|
async revokeDevice(input): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
+85
-3
@@ -133,6 +133,77 @@ If the module list cannot be read, the choice is simply not offered and the disp
|
|||||||
nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never
|
nothing yet. **Copy setup link** reaches the clipboard through the clicked button's own window, never
|
||||||
a browser global (ADR 0058).
|
a browser global (ADR 0058).
|
||||||
|
|
||||||
|
**Show on this screen** is `@ekklesia/ui`'s `SelectMenu`, not the browser's native select. That is
|
||||||
|
a shared, Ekklesia-drawn single choice: the WAI-ARIA select-only combobox (a `role="combobox"` button
|
||||||
|
and a `role="listbox"` of options).
|
||||||
|
|
||||||
|
- **Look:** a token-themed list surface, a chevron inside the control's own padding, and hover,
|
||||||
|
active, and selected states in both themes. The control is an `ek-control`, so it keeps the 16px
|
||||||
|
phone size.
|
||||||
|
- **Keyboard:** the arrows, Home, End, and type-ahead move; Enter, Space, or Tab chooses; Escape
|
||||||
|
closes without closing a dialog it sits in.
|
||||||
|
- **Pointer and touch:** a press chooses, and a press outside closes.
|
||||||
|
- **Focus:** stays on the control throughout.
|
||||||
|
|
||||||
|
### The normal list shows active displays, current as they change
|
||||||
|
|
||||||
|
The Paired displays list shows **active** displays only. A revoked display leaves it at once — the
|
||||||
|
page asks for the list again right after revoking — and the page says so, with focus on that notice
|
||||||
|
because the display's own card is gone. The revoked device row and its permission rows stay in the
|
||||||
|
database, marked revoked, as security history; a history view can show them later.
|
||||||
|
|
||||||
|
A newly paired display appears without a reload. Approval does not create the display: the screen
|
||||||
|
does, when it collects its credential on its next poll, a few seconds later (ADR 0036). So after
|
||||||
|
approving, the page shows a "Waiting for the screen to connect…" placeholder and asks for the list
|
||||||
|
again every second until a display it did not have appears, for up to a minute. Each answer replaces
|
||||||
|
the list, so the display can never appear twice. The page also refreshes the list every 30 seconds
|
||||||
|
on its own.
|
||||||
|
|
||||||
|
Each active display shows:
|
||||||
|
|
||||||
|
- its one name;
|
||||||
|
- **Online** (seen in the last 30 minutes), **Offline**, or **Not connected yet**. This is coarse on
|
||||||
|
purpose: last-seen is recorded at most every 15 minutes;
|
||||||
|
- **Showing:** a module, or *Nothing yet*;
|
||||||
|
- last seen and paired dates;
|
||||||
|
- **Change** and **Revoke**.
|
||||||
|
|
||||||
|
### Changing what a display shows: one target, the active display permission
|
||||||
|
|
||||||
|
In this Stage 1 model a display shows **at most one** module, and its current target **is** the one
|
||||||
|
active display permission it holds. There is no second "assignment" record: authorization ("may it
|
||||||
|
show Signage?") and assignment ("is it set to show Signage?") cannot disagree, because they are the
|
||||||
|
same row. No new migration is needed.
|
||||||
|
|
||||||
|
**Change** opens *Change what Foyer TV shows*, with the same **Show on this screen** choice. Saving
|
||||||
|
calls `PUT /api/v1/devices/<id>/display` with `{ "moduleId": "<id>" | null }`. That route requires
|
||||||
|
`core.devices.manage`, an exact trusted `Origin`, and a bounded body. The module must be a running
|
||||||
|
display, and the permission is that module's declared display permission.
|
||||||
|
|
||||||
|
The store applies the change in one transaction on the same device row (locked):
|
||||||
|
|
||||||
|
- every other active permission row is revoked — kept, with its `revokedAt`, as history;
|
||||||
|
- the new one is granted, unless the display already holds it.
|
||||||
|
|
||||||
|
The device's identity, name, and credential never change, and a revoked or unknown display is a
|
||||||
|
`404`. Nothing yet → module, module A → module B, and module → nothing are all this one operation.
|
||||||
|
|
||||||
|
**The screen notices on its own,** within about 30 seconds, whichever page it is on:
|
||||||
|
|
||||||
|
- **On `/display/setup`, waiting:** it rechecks `/api/v1/device-pairing/self` every 30 seconds, and
|
||||||
|
opens the module as soon as it holds one.
|
||||||
|
- **On a module's display page:** a paired device asks `/api/v1/device-pairing/self` every 30
|
||||||
|
seconds. If it is no longer set to show that module, it goes to `/display/setup`, which opens what
|
||||||
|
it shows now or waits, named. A refused descriptor for a screen that is still paired does the same,
|
||||||
|
instead of asking to be paired again. Only an unpaired or revoked screen shows a pairing code, as
|
||||||
|
before.
|
||||||
|
- **A person previewing a display page is never moved.**
|
||||||
|
|
||||||
|
This does not decide how a screen is shared between modules later. Showing more than one module, or
|
||||||
|
arbitrating between them (DESIGN.md §11.6), may add a separate target or arbitration record beside
|
||||||
|
the permissions then. What stays fixed is the ownership: the platform owns the physical display
|
||||||
|
identity and its current target, and a module never creates its own display identity.
|
||||||
|
|
||||||
### Module display routes are unchanged
|
### Module display routes are unchanged
|
||||||
|
|
||||||
`/display/<module-id>` keeps working exactly as it did: a screen that opens it directly still pairs
|
`/display/<module-id>` keeps working exactly as it did: a screen that opens it directly still pairs
|
||||||
@@ -198,9 +269,16 @@ unchanged.
|
|||||||
PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to
|
PR.** Rejected by the maintainer: with Signage installed, the platform's own setup flow would lead to
|
||||||
a screen that could not show Signage, while Signage's page still worked. Choosing at approval
|
a screen that could not show Signage, while Signage's page still worked. Choosing at approval
|
||||||
closes the loop with the permission table that already exists.
|
closes the loop with the permission table that already exists.
|
||||||
- **Changing or reassigning a paired display's module after pairing.** Deferred: it needs revocation
|
- **A separate "current target" column beside the permissions.** Rejected for the single-target Stage
|
||||||
and arbitration rules between modules (DESIGN.md §11.6). Today a display's module is chosen once, at
|
1 model: it would be a second source of truth that must be kept equal to the permission that
|
||||||
approval, or by the module page it was opened on.
|
actually authorizes the screen. If displays later show more than one module, a target or
|
||||||
|
arbitration record can be added then.
|
||||||
|
- **Revoking and re-pairing to change what a display shows.** Rejected: it would mint a new identity
|
||||||
|
for the same physical screen, and send someone to it with a new code.
|
||||||
|
- **Deleting revoked displays.** Rejected: revocation is security history. They leave the normal list
|
||||||
|
but stay in the database.
|
||||||
|
- **Polishing the native select.** Rejected: its open menu is drawn by the browser and cannot be
|
||||||
|
themed. `SelectMenu` is shared, so later choices get the same treatment.
|
||||||
- **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and
|
- **`maximum-scale=1` or `user-scalable=no`.** Rejected: it removes zoom for people who need it, and
|
||||||
hides the cause instead of fixing it.
|
hides the cause instead of fixing it.
|
||||||
- **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design.
|
- **16px controls everywhere.** Rejected: desktop does not zoom, and the compact size is the design.
|
||||||
@@ -222,6 +300,10 @@ unchanged.
|
|||||||
address still works; Settings now sends administrators to `/display/setup`.
|
address still works; Settings now sends administrators to `/display/setup`.
|
||||||
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
|
- Text fields are 16px on phones and touch screens everywhere `@ekklesia/ui` is used, module UIs
|
||||||
included.
|
included.
|
||||||
|
- `@ekklesia/ui` gains `SelectMenu`, additively. The native `Select` remains for anything that wants
|
||||||
|
the browser's own control.
|
||||||
|
- A display's target changes without re-pairing. A paired screen on a module page now asks the
|
||||||
|
platform every 30 seconds whether it should still show that module.
|
||||||
- A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's
|
- A real-iPhone recheck remains necessary. Headless Chromium does not reproduce iOS Safari's
|
||||||
focus-zoom or pull-to-refresh, so automated checks prove the computed font size and the cancelled
|
focus-zoom or pull-to-refresh, so automated checks prove the computed font size and the cancelled
|
||||||
gestures, not Safari's own behaviour.
|
gestures, not Safari's own behaviour.
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@ sets, never the code):
|
|||||||
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. |
|
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName, moduleId?}`. The administrator names the screen; the screen never names itself. `moduleId`, for a platform display, is the running display module it shows; its declared display permission is granted. A module display's own request cannot be given a different module. |
|
||||||
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
|
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
|
||||||
| `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. |
|
| `GET /api/v1/devices/display-modules` | `{modules: [{moduleId, name, permissionId}]}`: the running modules a display can be set to show. |
|
||||||
|
| `PUT /api/v1/devices/:deviceId/display` | Body `{moduleId}` (a running display module, or `null`). Sets what the display shows: its other active permissions are revoked (kept as history) and that module's declared display permission is granted. Same identity, name, and credential. `404` for a revoked or unknown display. |
|
||||||
| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. |
|
| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. |
|
||||||
|
|
||||||
The pairing code travels in request **bodies**, never a path or query, so it does not land in access
|
The pairing code travels in request **bodies**, never a path or query, so it does not land in access
|
||||||
|
|||||||
@@ -497,6 +497,19 @@ proven here:
|
|||||||
- `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with
|
- `apps/api/tests/module-display-routes.test.ts` serves `/display/setup` and the display host with
|
||||||
zero display modules, and only with device support.
|
zero display modules, and only with device support.
|
||||||
- `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements.
|
- `core/tests/device-pairing-schema.test.ts` pins migration `0009` to its three additive statements.
|
||||||
|
- `apps/api/tests/device-pairing.integration.ts` also changes a display from nothing to a module,
|
||||||
|
between two modules, and back. It checks that the display keeps one identity, name, and credential,
|
||||||
|
that the module descriptors follow, that the history rows are kept, and that revoked displays and
|
||||||
|
bad requests are refused.
|
||||||
|
- `packages/ui/tests/select-menu.test.tsx` covers `SelectMenu`:
|
||||||
|
- its combobox and listbox semantics, and no native select;
|
||||||
|
- the keyboard: arrows, Home, End, type-ahead, Enter, Space, and Tab;
|
||||||
|
- Escape closing it without closing a surrounding dialog;
|
||||||
|
- pointer and outside presses, the disabled state, and token-only styling.
|
||||||
|
- `surfaces/shell/tests/display-app.test.tsx` covers a running module display:
|
||||||
|
- moved to another module, or refused while still paired, it goes to `/display/setup`;
|
||||||
|
- an unpaired screen still shows a code;
|
||||||
|
- a person previewing the page is never moved.
|
||||||
- `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page:
|
- `surfaces/shell/tests/display-setup-app.test.tsx` covers the setup page:
|
||||||
- it pairs with no module and waits, named;
|
- it pairs with no module and waits, named;
|
||||||
- it opens the module it is set to show;
|
- it opens the module it is set to show;
|
||||||
@@ -504,6 +517,10 @@ proven here:
|
|||||||
- it returns to a code when revoked;
|
- it returns to a code when revoked;
|
||||||
- it never pairs on a server failure.
|
- it never pairs on a server failure.
|
||||||
- `surfaces/shell/tests/displays-settings.test.tsx` covers:
|
- `surfaces/shell/tests/displays-settings.test.tsx` covers:
|
||||||
|
- active displays only, with status and what each shows;
|
||||||
|
- a newly paired display appearing without a reload, and never twice;
|
||||||
|
- **Change** moving a display between nothing and modules as the same display;
|
||||||
|
- a revoked display leaving the list at once;
|
||||||
- the platform setup address, and **Copy setup link**;
|
- the platform setup address, and **Copy setup link**;
|
||||||
- **Add display** with zero display modules;
|
- **Add display** with zero display modules;
|
||||||
- the **Show on this screen** choice;
|
- the **Show on this screen** choice;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export {
|
|||||||
type ConfirmationTone,
|
type ConfirmationTone,
|
||||||
} from "./confirmation.js";
|
} from "./confirmation.js";
|
||||||
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
||||||
|
export { SelectMenu, type SelectMenuOption, type SelectMenuProps } from "./select-menu.js";
|
||||||
export {
|
export {
|
||||||
Badge,
|
Badge,
|
||||||
type BadgeProps,
|
type BadgeProps,
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { classNames } from "./class-names.js";
|
||||||
|
|
||||||
|
/** One choice in a {@link SelectMenu}. */
|
||||||
|
export interface SelectMenuOption<Value extends string> {
|
||||||
|
readonly value: Value;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectMenuProps<Value extends string> {
|
||||||
|
readonly value: Value;
|
||||||
|
readonly options: readonly SelectMenuOption<Value>[];
|
||||||
|
readonly onChange: (value: Value) => void;
|
||||||
|
/** The control's id, so a `Field` label names it. */
|
||||||
|
readonly id?: string;
|
||||||
|
readonly disabled?: boolean | undefined;
|
||||||
|
readonly "aria-describedby"?: string | undefined;
|
||||||
|
readonly "aria-invalid"?: boolean | undefined;
|
||||||
|
readonly className?: string;
|
||||||
|
readonly "data-testid"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single choice from a short list, drawn by Ekklesia rather than the browser.
|
||||||
|
*
|
||||||
|
* A native `<select>` opens a menu the browser draws itself — a square popup, the platform's own
|
||||||
|
* selection colour, an arrow against the edge — that matches neither theme. This is the WAI-ARIA
|
||||||
|
* select-only combobox: a `role="combobox"` button showing the current choice, which opens a
|
||||||
|
* `role="listbox"` of `role="option"`s. It renders in place, like every element here (never
|
||||||
|
* portalled), so it works inside a module's isolated root and inside a `Dialog`.
|
||||||
|
*
|
||||||
|
* - Closed: Enter, Space, ArrowDown, or ArrowUp opens it on the current choice; Home and End open it
|
||||||
|
* on the first or last.
|
||||||
|
* - Open: the arrows, Home, and End move; typing a letter moves to the next choice starting with it;
|
||||||
|
* Enter or Space chooses and closes; Escape closes without choosing; Tab chooses and moves on.
|
||||||
|
* - Pointer and touch: pressing the control toggles it, pressing a choice chooses it, and pressing
|
||||||
|
* anywhere else closes it.
|
||||||
|
*
|
||||||
|
* Focus stays on the control throughout — the active choice is `aria-activedescendant` — so closing
|
||||||
|
* never has to move focus back. Escape stops at the control, so a dialog it sits in stays open. Its
|
||||||
|
* size and type follow the shared control (`ek-control`), including the 16px phone size that keeps
|
||||||
|
* iOS from zooming (ADR 0065).
|
||||||
|
*/
|
||||||
|
export function SelectMenu<Value extends string>(props: SelectMenuProps<Value>): ReactElement {
|
||||||
|
const { options, value, onChange } = props;
|
||||||
|
const generatedId = useId();
|
||||||
|
const triggerId = props.id ?? `${generatedId}-trigger`;
|
||||||
|
const listId = `${generatedId}-list`;
|
||||||
|
const optionId = (index: number): string => `${generatedId}-option-${index}`;
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedIndex = Math.max(
|
||||||
|
0,
|
||||||
|
options.findIndex((option) => option.value === value),
|
||||||
|
);
|
||||||
|
const [active, setActive] = useState(selectedIndex);
|
||||||
|
const current = options[selectedIndex];
|
||||||
|
|
||||||
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
const openAt = (index: number): void => {
|
||||||
|
setActive(Math.min(Math.max(index, 0), options.length - 1));
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const choose = (index: number): void => {
|
||||||
|
const option = options[index];
|
||||||
|
if (option !== undefined && option.value !== value) {
|
||||||
|
onChange(option.value);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A press anywhere outside closes it. Listened for on the control's own root — its shadow root
|
||||||
|
// inside an isolated module artifact, or the document — never on a global.
|
||||||
|
useEffect(() => {
|
||||||
|
const element = rootRef.current;
|
||||||
|
if (!open || !element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = element.getRootNode() as Document | ShadowRoot;
|
||||||
|
const onPointerDown = (event: Event): void => {
|
||||||
|
const target = event.composedPath()[0];
|
||||||
|
if (!(target instanceof Node) || !element.contains(target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
root.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => root.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Keeps the active choice visible by scrolling the list itself, never the page.
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
const item = list?.children.item(active) as HTMLElement | null | undefined;
|
||||||
|
if (!open || !list || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.offsetTop < list.scrollTop) {
|
||||||
|
list.scrollTop = item.offsetTop;
|
||||||
|
} else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) {
|
||||||
|
list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight;
|
||||||
|
}
|
||||||
|
}, [active, open]);
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const last = options.length - 1;
|
||||||
|
if (!open) {
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
openAt(selectedIndex);
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
openAt(0);
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
openAt(last);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
setActive((index) => Math.min(index + 1, last));
|
||||||
|
return;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.altKey) {
|
||||||
|
choose(active);
|
||||||
|
} else {
|
||||||
|
setActive((index) => Math.max(index - 1, 0));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Home":
|
||||||
|
event.preventDefault();
|
||||||
|
setActive(0);
|
||||||
|
return;
|
||||||
|
case "End":
|
||||||
|
event.preventDefault();
|
||||||
|
setActive(last);
|
||||||
|
return;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
choose(active);
|
||||||
|
return;
|
||||||
|
case "Escape":
|
||||||
|
// Closes the list only: a dialog this sits in stays open.
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
case "Tab":
|
||||||
|
choose(active);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
if (event.key.length === 1 && /\S/u.test(event.key)) {
|
||||||
|
const letter = event.key.toLocaleLowerCase();
|
||||||
|
const order = [...options.keys()].map((offset) => (active + 1 + offset) % options.length);
|
||||||
|
const match = order.find((index) =>
|
||||||
|
options[index]?.label.toLocaleLowerCase().startsWith(letter),
|
||||||
|
);
|
||||||
|
if (match !== undefined) {
|
||||||
|
setActive(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = (event: FocusEvent<HTMLButtonElement>): void => {
|
||||||
|
const next = event.relatedTarget;
|
||||||
|
if (!(next instanceof Node) || !rootRef.current?.contains(next)) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames("ek-select-menu", props.className)}
|
||||||
|
data-open={open ? "true" : undefined}
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-activedescendant={open ? optionId(active) : undefined}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-describedby={props["aria-describedby"]}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-invalid={props["aria-invalid"]}
|
||||||
|
className="ek-control ek-focusable ek-select-menu__trigger"
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
disabled={props.disabled}
|
||||||
|
id={triggerId}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onClick={(event) => {
|
||||||
|
// Safari does not focus a pressed button; the keyboard (and Escape) belong to the control
|
||||||
|
// once it is open, so it takes focus itself — without scrolling the page.
|
||||||
|
event.currentTarget.focus({ preventScroll: true });
|
||||||
|
if (open) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
openAt(selectedIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
role="combobox"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="ek-select-menu__value">{current?.label ?? ""}</span>
|
||||||
|
<span aria-hidden="true" className="ek-select-menu__chevron" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
aria-labelledby={triggerId}
|
||||||
|
className="ek-select-menu__list"
|
||||||
|
hidden={!open}
|
||||||
|
id={listId}
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
// biome-ignore lint/a11y/useFocusableInteractive: in the select-only combobox pattern focus stays on the combobox and the active option is aria-activedescendant, so options are deliberately not focusable.
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: the keyboard is handled on the combobox, which owns focus; this click is the pointer and touch path.
|
||||||
|
<div
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
className="ek-select-menu__option"
|
||||||
|
data-active={open && index === active ? "true" : undefined}
|
||||||
|
data-value={option.value}
|
||||||
|
id={optionId(index)}
|
||||||
|
key={option.value}
|
||||||
|
// The choice is taken on click; pressing must not move focus off the control first.
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => choose(index)}
|
||||||
|
onPointerEnter={() => setActive(index)}
|
||||||
|
role="option"
|
||||||
|
>
|
||||||
|
<span className="ek-select-menu__label">{option.label}</span>
|
||||||
|
<span aria-hidden="true" className="ek-select-menu__check" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -190,6 +190,131 @@ export const EKKLESIA_UI_STYLESHEET = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SelectMenu: a single choice drawn by Ekklesia, never the browser's own menu. The control is an
|
||||||
|
* ek-control, so it shares the field's border, radius, height, and the 16px phone size; the chevron
|
||||||
|
* sits inside its padding, and the list is a raised, themed surface below it.
|
||||||
|
*/
|
||||||
|
.ek-select-menu {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__trigger {
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: ${designToken("spacing.sm")};
|
||||||
|
justify-content: space-between;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__trigger:hover:not(:disabled) {
|
||||||
|
border-color: ${designToken("border.strong")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__trigger:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: ${designToken("state.disabled-opacity")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__chevron {
|
||||||
|
border-bottom: calc(2 * ${designToken("border.width")}) solid ${designToken("text.secondary")};
|
||||||
|
border-right: calc(2 * ${designToken("border.width")}) solid ${designToken("text.secondary")};
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: ${designToken("spacing.sm")};
|
||||||
|
margin-inline: ${designToken("spacing.xs")} ${designToken("spacing.2xs")};
|
||||||
|
transform: translateY(-25%) rotate(45deg);
|
||||||
|
transition: transform ${designToken("motion.duration-short")} ease;
|
||||||
|
width: ${designToken("spacing.sm")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu[data-open] .ek-select-menu__chevron {
|
||||||
|
transform: translateY(25%) rotate(225deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__list {
|
||||||
|
background: ${designToken("background.raised")};
|
||||||
|
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||||
|
border-radius: ${designToken("radius.md")};
|
||||||
|
box-shadow: ${designToken("shadow.md")};
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
gap: ${designToken("spacing.2xs")};
|
||||||
|
inset-inline: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: ${designToken("spacing.2xs")} 0 0;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: ${designToken("spacing.xs")};
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__list[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__option {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: ${designToken("radius.sm")};
|
||||||
|
color: ${designToken("text.primary")};
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-size: ${designToken("typography.size-sm")};
|
||||||
|
gap: ${designToken("spacing.sm")};
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: ${designToken("control.height-md")};
|
||||||
|
padding: 0 ${designToken("control.padding-inline")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__option[data-active] {
|
||||||
|
background: ${designToken("state.hover-background")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__option[aria-selected="true"] {
|
||||||
|
background: ${designToken("state.selected-background")};
|
||||||
|
color: ${designToken("state.selected-foreground")};
|
||||||
|
font-weight: ${designToken("typography.weight-bold")};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The keyboard's active choice is outlined, so it reads apart from the selected one. */
|
||||||
|
.ek-select-menu__option[data-active][aria-selected="true"],
|
||||||
|
.ek-select-menu__option[data-active] {
|
||||||
|
box-shadow: inset 0 0 0 ${designToken("state.focus-visible-width")} ${designToken("accent.focus")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__check {
|
||||||
|
border-bottom: calc(2 * ${designToken("border.width")}) solid currentColor;
|
||||||
|
border-right: calc(2 * ${designToken("border.width")}) solid currentColor;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: ${designToken("spacing.md")};
|
||||||
|
transform: translateY(-15%) rotate(45deg);
|
||||||
|
visibility: hidden;
|
||||||
|
width: ${designToken("spacing.xs")};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-select-menu__option[aria-selected="true"] .ek-select-menu__check {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.ek-select-menu__option[data-active] {
|
||||||
|
outline: ${designToken("state.focus-visible-width")} solid Highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ek-control::placeholder {
|
.ek-control::placeholder {
|
||||||
color: ${designToken("text.muted")};
|
color: ${designToken("text.muted")};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { act, type ReactElement, useState } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Dialog, Field, SelectMenu } from "../src/index.js";
|
||||||
|
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectMenu (ADR 0065): a single choice drawn by Ekklesia — the WAI-ARIA select-only combobox —
|
||||||
|
* rather than the browser's native menu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ label: "Nothing yet", value: "" },
|
||||||
|
{ label: "Signage", value: "signage" },
|
||||||
|
{ label: "Bulletin", value: "bulletin" },
|
||||||
|
{ label: "Parent Call", value: "parent-call" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Harness(props: {
|
||||||
|
readonly onChange?: (value: string) => void;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Show on this screen">
|
||||||
|
{(control) => (
|
||||||
|
<SelectMenu
|
||||||
|
{...control}
|
||||||
|
data-testid="menu"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(next) => {
|
||||||
|
setValue(next);
|
||||||
|
props.onChange?.(next);
|
||||||
|
}}
|
||||||
|
options={OPTIONS}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<button data-testid="elsewhere" type="button">
|
||||||
|
Elsewhere
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(node: ReactElement): void {
|
||||||
|
act(() => root.render(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(): HTMLButtonElement {
|
||||||
|
return container.querySelector('[data-testid="menu"]') as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(): HTMLElement {
|
||||||
|
return container.querySelector('[role="listbox"]') as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||||
|
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
|
||||||
|
act(() => {
|
||||||
|
trigger().dispatchEvent(event);
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeLabel(): string | undefined {
|
||||||
|
const id = trigger().getAttribute("aria-activedescendant");
|
||||||
|
return id === null
|
||||||
|
? undefined
|
||||||
|
: (container.querySelector(`#${CSS.escape(id)}`)?.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SelectMenu", () => {
|
||||||
|
it("is a labelled combobox controlling a listbox of options, never a native select", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
expect(container.querySelector("select")).toBeNull();
|
||||||
|
const control = trigger();
|
||||||
|
expect(control.tagName).toBe("BUTTON");
|
||||||
|
// The shared control, so its border, height, and 16px phone size (no iOS zoom) are the field's.
|
||||||
|
expect(control.classList.contains("ek-control")).toBe(true);
|
||||||
|
expect(control.getAttribute("role")).toBe("combobox");
|
||||||
|
expect(control.getAttribute("aria-haspopup")).toBe("listbox");
|
||||||
|
expect(control.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(control.getAttribute("aria-controls")).toBe(list().id);
|
||||||
|
// The Field's label names it.
|
||||||
|
expect(container.querySelector("label")?.getAttribute("for")).toBe(control.id);
|
||||||
|
expect(control.textContent).toBe("Nothing yet");
|
||||||
|
expect(list().hidden).toBe(true);
|
||||||
|
const options = [...list().querySelectorAll('[role="option"]')];
|
||||||
|
expect(options.map((option) => option.textContent)).toEqual(OPTIONS.map((o) => o.label));
|
||||||
|
expect(options.map((option) => option.getAttribute("aria-selected"))).toEqual([
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"false",
|
||||||
|
"false",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens, moves, and chooses from the keyboard", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Harness onChange={onChange} />);
|
||||||
|
act(() => trigger().focus());
|
||||||
|
|
||||||
|
expect(press("ArrowDown").defaultPrevented).toBe(true);
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(list().hidden).toBe(false);
|
||||||
|
// It opens on the current choice.
|
||||||
|
expect(activeLabel()).toBe("Nothing yet");
|
||||||
|
press("ArrowDown");
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(activeLabel()).toBe("Bulletin");
|
||||||
|
press("ArrowUp");
|
||||||
|
expect(activeLabel()).toBe("Signage");
|
||||||
|
press("End");
|
||||||
|
expect(activeLabel()).toBe("Parent Call");
|
||||||
|
press("Home");
|
||||||
|
expect(activeLabel()).toBe("Nothing yet");
|
||||||
|
// Typing a letter moves to the next choice that starts with it.
|
||||||
|
press("b");
|
||||||
|
expect(activeLabel()).toBe("Bulletin");
|
||||||
|
|
||||||
|
press("Enter");
|
||||||
|
expect(onChange).toHaveBeenCalledWith("bulletin");
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(trigger().textContent).toBe("Bulletin");
|
||||||
|
// Focus never left the control.
|
||||||
|
expect(document.activeElement).toBe(trigger());
|
||||||
|
|
||||||
|
press(" ");
|
||||||
|
expect(activeLabel()).toBe("Bulletin");
|
||||||
|
press("ArrowDown");
|
||||||
|
press(" ");
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith("parent-call");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape without choosing, and without closing a dialog it sits in", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(
|
||||||
|
<Dialog onDismiss={onDismiss} open title="Change">
|
||||||
|
<Harness onChange={onChange} />
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
act(() => trigger().focus());
|
||||||
|
press("ArrowDown");
|
||||||
|
press("ArrowDown");
|
||||||
|
|
||||||
|
const escapeKey = press("Escape");
|
||||||
|
expect(escapeKey.defaultPrevented).toBe(true);
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).toBe(trigger());
|
||||||
|
|
||||||
|
// Closed, Escape is the dialog's again.
|
||||||
|
press("Escape");
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chooses the active option on Tab, as a native select does", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Harness onChange={onChange} />);
|
||||||
|
act(() => trigger().focus());
|
||||||
|
press("ArrowDown");
|
||||||
|
press("ArrowDown");
|
||||||
|
const tab = press("Tab");
|
||||||
|
expect(tab.defaultPrevented).toBe(false);
|
||||||
|
expect(onChange).toHaveBeenCalledWith("signage");
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with a pointer: press to open, press an option to choose, press outside to close", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Harness onChange={onChange} />);
|
||||||
|
|
||||||
|
act(() => trigger().click());
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("true");
|
||||||
|
// Pressed, it takes focus (Safari would not), so the keyboard and Escape work from here.
|
||||||
|
expect(document.activeElement).toBe(trigger());
|
||||||
|
const signage = [...list().querySelectorAll<HTMLElement>('[role="option"]')][1] as HTMLElement;
|
||||||
|
act(() => signage.click());
|
||||||
|
expect(onChange).toHaveBeenCalledWith("signage");
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
act(() => trigger().click());
|
||||||
|
act(() => {
|
||||||
|
(container.querySelector('[data-testid="elsewhere"]') as HTMLElement).dispatchEvent(
|
||||||
|
new Event("pointerdown", { bubbles: true, composed: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while disabled", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Harness disabled onChange={onChange} />);
|
||||||
|
expect(trigger().disabled).toBe(true);
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is drawn from tokens: a themed list, and a chevron inside the control's own padding", () => {
|
||||||
|
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-select-menu__chevron {");
|
||||||
|
expect(EKKLESIA_UI_STYLESHEET).toMatch(
|
||||||
|
/\.ek-select-menu__list \{[^}]*background: var\(--ekklesia-background-raised\)/u,
|
||||||
|
);
|
||||||
|
expect(EKKLESIA_UI_STYLESHEET).toMatch(
|
||||||
|
/\.ek-select-menu__option\[aria-selected="true"\] \{[^}]*var\(--ekklesia-state-selected-background\)/u,
|
||||||
|
);
|
||||||
|
// The chevron's turn is a motion token, so reduced motion stills it.
|
||||||
|
expect(EKKLESIA_UI_STYLESHEET).toMatch(
|
||||||
|
/\.ek-select-menu__chevron \{[^}]*transition: transform var\(--ekklesia-motion-duration-short\)/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -639,14 +639,22 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
|
|||||||
);
|
);
|
||||||
return result.exceptionDetails ? undefined : result.result.value;
|
return result.exceptionDetails ? undefined : result.result.value;
|
||||||
};
|
};
|
||||||
const kioskWaitFor = async (expression) => {
|
const kioskWaitFor = async (expression, within = timeoutMs) => {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + within;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) {
|
if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
}
|
}
|
||||||
|
// What the screen was doing instead: its page, which display state it shows, and what the
|
||||||
|
// platform says it may show. Paths and test ids only, never a cookie or a body beyond that.
|
||||||
|
const state = await kioskEvaluate(`(async () => {
|
||||||
|
const shown = [...document.querySelectorAll("[data-testid]")].map((element) => element.dataset.testid).filter((id) => id.startsWith("display-")).slice(0, 8);
|
||||||
|
const self = await fetch("/api/v1/device-pairing/self", { credentials: "include" }).then(async (response) => response.status + " " + JSON.stringify((await response.json()).displays ?? null)).catch(() => "unreachable");
|
||||||
|
return { path: location.pathname, shown, self, visibility: document.visibilityState };
|
||||||
|
})()`).catch(() => "unavailable");
|
||||||
|
console.log(`info kiosk ${JSON.stringify(state)}`);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession);
|
await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession);
|
||||||
@@ -672,23 +680,37 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
|
|||||||
),
|
),
|
||||||
"Add display finds the screen from /display/setup as a new platform display, with no module",
|
"Add display finds the screen from /display/setup as a new platform display, with no module",
|
||||||
);
|
);
|
||||||
|
// "Show on this screen" is Ekklesia's own select (a combobox and listbox), never a native one:
|
||||||
|
// it is opened, and an option pressed, as a person would.
|
||||||
|
check(
|
||||||
|
!(await evaluate(`Boolean(${selector("add-display-dialog")}.querySelector("select"))`)),
|
||||||
|
"Add display uses the themed select, not the browser's native one",
|
||||||
|
);
|
||||||
const chosen = await evaluate(`(() => {
|
const chosen = await evaluate(`(() => {
|
||||||
const name = ${selector("device-pairing-name")};
|
const name = ${selector("device-pairing-name")};
|
||||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display");
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display");
|
||||||
name.dispatchEvent(new Event("input", { bubbles: true }));
|
name.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
const shows = ${selector("device-pairing-shows")};
|
const shows = ${selector("device-pairing-shows")};
|
||||||
const option = shows ? [...shows.options].find((candidate) => candidate.value !== "") : undefined;
|
if (!shows) {
|
||||||
if (option) {
|
return "";
|
||||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(shows, option.value);
|
|
||||||
shows.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
return option ? option.value : "";
|
shows.click();
|
||||||
|
const list = document.getElementById(shows.getAttribute("aria-controls"));
|
||||||
|
const option = [...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value !== "");
|
||||||
|
if (!option) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
option.click();
|
||||||
|
return option.dataset.value;
|
||||||
})()`);
|
})()`);
|
||||||
await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled");
|
await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled");
|
||||||
await evaluate(`${selector("device-pairing-approve")}.click()`);
|
await evaluate(`${selector("device-pairing-approve")}.click()`);
|
||||||
|
// The display's own card — keyed by its device id — not the success notice or the "waiting for
|
||||||
|
// the screen" placeholder, which carry its name before the display exists.
|
||||||
|
const newestCard = `[...document.querySelectorAll('li[data-testid^="device-"]')].filter((card) => /^device-[0-9a-f-]{36}$/.test(card.dataset.testid) && card.textContent.includes("Smoke platform display")).at(-1)`;
|
||||||
check(
|
check(
|
||||||
await waitFor(
|
await waitFor(
|
||||||
`!${selector("add-display-dialog")} && [...document.querySelectorAll('[data-testid^="device-"]')].some((card) => card.textContent.includes("Smoke platform display"))`,
|
`!${selector("add-display-dialog")} && ${newestCard}`,
|
||||||
"the new platform display in the list",
|
"the new platform display in the list",
|
||||||
),
|
),
|
||||||
"the paired platform display appears under Paired displays without a reload",
|
"the paired platform display appears under Paired displays without a reload",
|
||||||
@@ -703,6 +725,56 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
|
|||||||
? "with no module to show, the paired screen shows its name and waits"
|
? "with no module to show, the paired screen shows its name and waits"
|
||||||
: "the paired screen opens the module display it was set to show, by itself",
|
: "the paired screen opens the module display it was set to show, by itself",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What it shows can change later, on the same display: from Settings, through Change.
|
||||||
|
const cardId = await evaluate(`${newestCard}?.dataset.testid ?? ""`);
|
||||||
|
check(cardId !== "", "the paired display's own card is listed, to manage");
|
||||||
|
if (chosen !== "" && cardId !== "") {
|
||||||
|
await evaluate(`${selector(cardId.replace("device-", "device-change-"))}.click()`);
|
||||||
|
await waitFor(selector("device-change-shows"), "the Change dialog");
|
||||||
|
await evaluate(`(() => {
|
||||||
|
const shows = ${selector("device-change-shows")};
|
||||||
|
shows.click();
|
||||||
|
const list = document.getElementById(shows.getAttribute("aria-controls"));
|
||||||
|
[...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value === "").click();
|
||||||
|
})()`);
|
||||||
|
await evaluate(`${selector("device-change-save")}.click()`);
|
||||||
|
check(
|
||||||
|
await waitFor(
|
||||||
|
`!${selector("device-change-dialog")} && ${selector(`${cardId}-showing`)}?.textContent === "Showing: Nothing yet"`,
|
||||||
|
"the display showing nothing",
|
||||||
|
),
|
||||||
|
"Change sets what a paired display shows, and the list says so at once",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
await kioskWaitFor(
|
||||||
|
`location.pathname === "/display/setup" && ${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"`,
|
||||||
|
75_000,
|
||||||
|
),
|
||||||
|
"the screen notices the change on its own and leaves the module, as the same paired display",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoked, it leaves the normal list at once, and its screen returns to a pairing code.
|
||||||
|
if (cardId !== "") {
|
||||||
|
await evaluate(`${selector(cardId.replace("device-", "device-revoke-"))}.click()`);
|
||||||
|
await waitFor(selector("device-revoke-confirm"), "the revoke confirmation");
|
||||||
|
await evaluate(`${selector("device-revoke-confirm")}.click()`);
|
||||||
|
check(
|
||||||
|
await waitFor(
|
||||||
|
`!${selector(cardId)} && ${selector("device-revoke-success")}`,
|
||||||
|
"the revoked display gone from the list",
|
||||||
|
),
|
||||||
|
"a revoked display leaves Paired displays at once, without a reload",
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
await kioskWaitFor(
|
||||||
|
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
|
||||||
|
75_000,
|
||||||
|
),
|
||||||
|
"the revoked screen returns to a pairing code on its own",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await cdp.send("Target.closeTarget", { targetId: kiosk.targetId });
|
await cdp.send("Target.closeTarget", { targetId: kiosk.targetId });
|
||||||
await cdp.send("Target.disposeBrowserContext", { browserContextId });
|
await cdp.send("Target.disposeBrowserContext", { browserContextId });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
PageHeader,
|
PageHeader,
|
||||||
type PageHeaderBack,
|
type PageHeaderBack,
|
||||||
Panel,
|
Panel,
|
||||||
Select,
|
SelectMenu,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@ekklesia/ui";
|
} from "@ekklesia/ui";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
listHostDisplayModules,
|
listHostDisplayModules,
|
||||||
lookupDevicePairing,
|
lookupDevicePairing,
|
||||||
revokeHostDevice,
|
revokeHostDevice,
|
||||||
|
setHostDisplayTarget,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
import { calendarDate, lastSeenPhrase } from "./display-time.js";
|
import { calendarDate, lastSeenPhrase } from "./display-time.js";
|
||||||
import { ShellIcon } from "./shell-icons.js";
|
import { ShellIcon } from "./shell-icons.js";
|
||||||
@@ -81,6 +82,35 @@ type PairingStep =
|
|||||||
/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */
|
/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */
|
||||||
const NO_MODULE = "";
|
const NO_MODULE = "";
|
||||||
|
|
||||||
|
/** "Show on this screen": nothing yet, or one of the installed display modules. */
|
||||||
|
function targetOptions(modules: readonly HostDisplayModule[]) {
|
||||||
|
return [
|
||||||
|
{ label: "Nothing yet", value: NO_MODULE },
|
||||||
|
...modules.map((module) => ({ label: module.name, value: module.moduleId })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The module a display shows now: the one installed display module whose permission it holds. In
|
||||||
|
* this single-target model a display's active display permission is its current target (ADR 0065). */
|
||||||
|
function currentTarget(
|
||||||
|
device: HostDeviceSummary,
|
||||||
|
modules: readonly HostDisplayModule[],
|
||||||
|
): HostDisplayModule | undefined {
|
||||||
|
return modules.find((module) => device.permissions.includes(module.permissionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long after it was last in touch a display still counts as online. The server records
|
||||||
|
* last-seen at most every 15 minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so anything finer would
|
||||||
|
* claim more than is known. */
|
||||||
|
const ONLINE_WITHIN_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
/** How long after a pairing is approved the page keeps checking for the screen to collect its
|
||||||
|
* credential, which is when the display comes into being (ADR 0036). */
|
||||||
|
const AWAIT_PAIRED_MS = 60_000;
|
||||||
|
|
||||||
|
/** How often the page refreshes its list on its own, so status and changes made elsewhere show. */
|
||||||
|
const LIST_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
export interface DisplaysSettingsPageProps {
|
export interface DisplaysSettingsPageProps {
|
||||||
readonly config: EkklesiaHostConfig;
|
readonly config: EkklesiaHostConfig;
|
||||||
/** Back to Settings, or to where a validated return target says (ADR 0063). */
|
/** Back to Settings, or to where a validated return target says (ADR 0063). */
|
||||||
@@ -89,6 +119,10 @@ export interface DisplaysSettingsPageProps {
|
|||||||
readonly moduleName: (moduleId: string) => string | undefined;
|
readonly moduleName: (moduleId: string) => string | undefined;
|
||||||
/** For tests: the clock "last seen" is measured against. */
|
/** For tests: the clock "last seen" is measured against. */
|
||||||
readonly now?: () => Date;
|
readonly now?: () => Date;
|
||||||
|
/** For tests: how soon to check again for a display that was just approved. */
|
||||||
|
readonly pollMs?: number;
|
||||||
|
/** For tests: how often the list refreshes on its own (0 turns it off). */
|
||||||
|
readonly refreshMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,24 +297,18 @@ function AddDisplayDialog(props: {
|
|||||||
</Field>
|
</Field>
|
||||||
{asked === undefined && modules !== undefined && modules.length > 0 ? (
|
{asked === undefined && modules !== undefined && modules.length > 0 ? (
|
||||||
<Field
|
<Field
|
||||||
hint="You can pair it now and choose later; until then it shows its name and waits."
|
hint="You can change this later from the display's Change action."
|
||||||
label="Show on this screen"
|
label="Show on this screen"
|
||||||
>
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<Select
|
<SelectMenu
|
||||||
{...control}
|
{...control}
|
||||||
data-testid="device-pairing-shows"
|
data-testid="device-pairing-shows"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(event) => setShows(event.target.value)}
|
onChange={setShows}
|
||||||
|
options={targetOptions(modules)}
|
||||||
value={shows}
|
value={shows}
|
||||||
>
|
/>
|
||||||
<option value={NO_MODULE}>Nothing yet</option>
|
|
||||||
{modules.map((module) => (
|
|
||||||
<option key={module.moduleId} value={module.moduleId}>
|
|
||||||
{module.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -388,30 +416,30 @@ function DisplaySetupPanel(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One active display: its one name, whether it is online (coarsely — see {@link ONLINE_WITHIN_MS}),
|
||||||
|
* what it shows now, and the two things an administrator does with it — change what it shows, and
|
||||||
|
* revoke it. Revoked displays are not listed: they are history, not something to manage.
|
||||||
|
*/
|
||||||
function DisplayCard(props: {
|
function DisplayCard(props: {
|
||||||
readonly device: HostDeviceSummary;
|
readonly device: HostDeviceSummary;
|
||||||
readonly now: Date;
|
readonly now: Date;
|
||||||
readonly modules: DisplayModules;
|
readonly modules: DisplayModules;
|
||||||
readonly onRevoke: () => void;
|
readonly onChange: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
readonly onRevoke: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { device, now } = props;
|
const { device, now, modules } = props;
|
||||||
|
const seen = device.lastSeenAt;
|
||||||
|
const status =
|
||||||
|
seen === undefined
|
||||||
|
? { label: "Not connected yet", tone: "neutral" as const }
|
||||||
|
: now.getTime() - seen.getTime() <= ONLINE_WITHIN_MS
|
||||||
|
? { label: "Online", tone: "success" as const }
|
||||||
|
: { label: "Offline", tone: "warning" as const };
|
||||||
|
const target = modules === undefined ? undefined : currentTarget(device, modules);
|
||||||
const facts: string[] = [];
|
const facts: string[] = [];
|
||||||
if (!device.revoked && props.modules !== undefined) {
|
if (seen !== undefined) {
|
||||||
const shows = props.modules.filter((module) =>
|
facts.push(`Last seen ${lastSeenPhrase(seen, now)}`);
|
||||||
device.permissions.includes(module.permissionId),
|
|
||||||
);
|
|
||||||
facts.push(
|
|
||||||
shows.length === 0
|
|
||||||
? "Not showing a module yet"
|
|
||||||
: `Shows ${shows.map((module) => module.name).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (device.revoked) {
|
|
||||||
// The badge says so; nothing about when it was last seen is worth acting on any more.
|
|
||||||
} else if (device.lastSeenAt !== undefined) {
|
|
||||||
facts.push(`Last seen ${lastSeenPhrase(device.lastSeenAt, now)}`);
|
|
||||||
} else {
|
|
||||||
facts.push("Not seen since it was paired");
|
|
||||||
}
|
}
|
||||||
if (device.pairedAt !== undefined) {
|
if (device.pairedAt !== undefined) {
|
||||||
facts.push(`Paired ${calendarDate(device.pairedAt)}`);
|
facts.push(`Paired ${calendarDate(device.pairedAt)}`);
|
||||||
@@ -424,38 +452,166 @@ function DisplayCard(props: {
|
|||||||
<div className="ek-shell-display-card__copy">
|
<div className="ek-shell-display-card__copy">
|
||||||
<div className="ek-shell-display-card__title">
|
<div className="ek-shell-display-card__title">
|
||||||
<span className="ek-shell-display-card__name">{device.name}</span>
|
<span className="ek-shell-display-card__name">{device.name}</span>
|
||||||
<Badge tone={device.revoked ? "neutral" : "success"}>
|
<Badge tone={status.tone}>{status.label}</Badge>
|
||||||
{device.revoked ? "Revoked" : "Paired"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
{modules === undefined ? undefined : (
|
||||||
|
<p className="ek-shell-display-card__showing" data-testid={`device-${device.id}-showing`}>
|
||||||
|
Showing: <strong>{target?.name ?? "Nothing yet"}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{facts.length === 0 ? undefined : (
|
||||||
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
|
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{device.revoked ? undefined : (
|
<div className="ek-shell-display-card__actions">
|
||||||
|
{modules === undefined ? undefined : (
|
||||||
|
<Button data-testid={`device-change-${device.id}`} onClick={props.onChange} size="sm">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
|
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
|
||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Change what Foyer TV shows": the one current target, nothing yet or an installed module. */
|
||||||
|
function ChangeTargetDialog(props: {
|
||||||
|
readonly config: EkklesiaHostConfig;
|
||||||
|
readonly device: HostDeviceSummary | undefined;
|
||||||
|
readonly modules: readonly HostDisplayModule[];
|
||||||
|
readonly onDismiss: () => void;
|
||||||
|
readonly onChanged: (device: HostDeviceSummary, shows: HostDisplayModule | undefined) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const { config, device, modules, onChanged } = props;
|
||||||
|
const initial =
|
||||||
|
device === undefined ? NO_MODULE : (currentTarget(device, modules)?.moduleId ?? NO_MODULE);
|
||||||
|
const [shows, setShows] = useState<string>(initial);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const selectRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Each time it opens, it starts from what the display shows now.
|
||||||
|
useEffect(() => {
|
||||||
|
setShows(initial);
|
||||||
|
setFailed(false);
|
||||||
|
setSaving(false);
|
||||||
|
initialFocusRef.current =
|
||||||
|
selectRef.current?.querySelector<HTMLElement>('[role="combobox"]') ?? null;
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (device === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
await setHostDisplayTarget(config, device.id, shows === NO_MODULE ? undefined : shows);
|
||||||
|
onChanged(
|
||||||
|
device,
|
||||||
|
modules.find((module) => module.moduleId === shows),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button data-testid="device-change-cancel" disabled={saving} onClick={props.onDismiss}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="device-change-save"
|
||||||
|
disabled={saving || shows === initial}
|
||||||
|
onClick={() => void save()}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
data-testid="device-change-dialog"
|
||||||
|
description="The screen switches on its own within a minute. It stays the same display, with the same name."
|
||||||
|
dismissible={!saving}
|
||||||
|
initialFocusRef={initialFocusRef}
|
||||||
|
onDismiss={props.onDismiss}
|
||||||
|
open={device !== undefined}
|
||||||
|
title={`Change what ${device?.name ?? "this display"} shows`}
|
||||||
|
>
|
||||||
|
<div ref={selectRef}>
|
||||||
|
<Field label="Show on this screen">
|
||||||
|
{(control) => (
|
||||||
|
<SelectMenu
|
||||||
|
{...control}
|
||||||
|
data-testid="device-change-shows"
|
||||||
|
disabled={saving}
|
||||||
|
onChange={setShows}
|
||||||
|
options={targetOptions(modules)}
|
||||||
|
value={shows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{failed ? (
|
||||||
|
<InlineNotice tone="danger">
|
||||||
|
<span data-testid="device-change-error">
|
||||||
|
That change could not be saved. Try again in a moment.
|
||||||
|
</span>
|
||||||
|
</InlineNotice>
|
||||||
|
) : undefined}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the page last told the administrator, after a pairing, a change, or a revocation. */
|
||||||
|
type Outcome =
|
||||||
|
| { readonly kind: "paired"; readonly name: string; readonly shows: string | undefined }
|
||||||
|
| { readonly kind: "changed"; readonly name: string; readonly shows: string | undefined }
|
||||||
|
| { readonly kind: "revoked"; readonly name: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pairing approved a moment ago. The display itself comes into being only when the screen
|
||||||
|
* collects its credential on its next poll (ADR 0036), a few seconds later, so the page checks again
|
||||||
|
* until a display it did not have before appears — and shows a placeholder in its place meanwhile.
|
||||||
|
*/
|
||||||
|
interface Awaiting {
|
||||||
|
readonly name: string;
|
||||||
|
readonly known: ReadonlySet<string>;
|
||||||
|
readonly until: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
|
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
|
||||||
const { config } = props;
|
const { config } = props;
|
||||||
|
const pollMs = props.pollMs ?? 1000;
|
||||||
|
const refreshMs = props.refreshMs ?? LIST_REFRESH_MS;
|
||||||
const [list, setList] = useState<ListState>({ kind: "loading" });
|
const [list, setList] = useState<ListState>({ kind: "loading" });
|
||||||
const [modules, setModules] = useState<DisplayModules>(undefined);
|
const [modules, setModules] = useState<DisplayModules>(undefined);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [paired, setPaired] = useState<
|
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined);
|
||||||
{ readonly name: string; readonly shows: string | undefined } | undefined
|
const [awaiting, setAwaiting] = useState<Awaiting | undefined>(undefined);
|
||||||
>(undefined);
|
/** The display whose content an administrator is changing. */
|
||||||
|
const [changing, setChanging] = useState<HostDeviceSummary | undefined>(undefined);
|
||||||
/** The display an administrator asked to revoke and has not yet confirmed. */
|
/** The display an administrator asked to revoke and has not yet confirmed. */
|
||||||
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
|
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
|
||||||
const [revoking, setRevoking] = useState(false);
|
const [revoking, setRevoking] = useState(false);
|
||||||
|
const outcomeRef = useRef<HTMLDivElement>(null);
|
||||||
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
|
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
|
||||||
const dismissAdding = useCallback(() => setAdding(false), []);
|
const dismissAdding = useCallback(() => setAdding(false), []);
|
||||||
|
const dismissChanging = useCallback(() => setChanging(undefined), []);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<readonly HostDeviceSummary[] | undefined> => {
|
||||||
try {
|
try {
|
||||||
setList({ devices: await listHostDevices(config), kind: "ready" });
|
const devices = await listHostDevices(config);
|
||||||
|
setList({ devices, kind: "ready" });
|
||||||
|
return devices;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setList({
|
setList({
|
||||||
kind:
|
kind:
|
||||||
@@ -463,6 +619,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
? "denied"
|
? "denied"
|
||||||
: "failed",
|
: "failed",
|
||||||
});
|
});
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@@ -470,6 +627,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// The page keeps itself current — status, and changes made from another tab — on its own.
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => void refresh(), refreshMs);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [refresh, refreshMs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
// What a display can be set to show. If it cannot be read, pairing still works — the display is
|
// What a display can be set to show. If it cannot be read, pairing still works — the display is
|
||||||
@@ -485,25 +651,88 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const revoke = async (deviceId: string): Promise<void> => {
|
/** Revoked displays are history, not something to manage: the list shows active ones only. */
|
||||||
|
const active = list.kind === "ready" ? list.devices.filter((device) => !device.revoked) : [];
|
||||||
|
|
||||||
|
// While a just-approved display has not appeared, check again shortly — each answer replaces the
|
||||||
|
// list, so it can never appear twice — and stop once it is there, or after a while regardless.
|
||||||
|
const arrived = awaiting !== undefined && active.some((device) => !awaiting.known.has(device.id));
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: each new list answer is what schedules the next check, so `list` is the trigger even though it is not read here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (awaiting === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arrived || Date.now() > awaiting.until) {
|
||||||
|
setAwaiting(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => void refresh(), pollMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [arrived, awaiting, list, pollMs, refresh]);
|
||||||
|
|
||||||
|
// The control that started a revocation is gone with its card, so focus lands on what happened
|
||||||
|
// once it is on screen, without scrolling the page.
|
||||||
|
const focusOutcome = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (outcome !== undefined && focusOutcome.current) {
|
||||||
|
focusOutcome.current = false;
|
||||||
|
outcomeRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [outcome]);
|
||||||
|
const showOutcome = (next: Outcome): void => {
|
||||||
|
focusOutcome.current = true;
|
||||||
|
setOutcome(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (device: HostDeviceSummary): Promise<void> => {
|
||||||
setRevoking(true);
|
setRevoking(true);
|
||||||
try {
|
try {
|
||||||
await revokeHostDevice(config, deviceId);
|
await revokeHostDevice(config, device.id);
|
||||||
} catch {
|
} catch {
|
||||||
// Left to the refresh below rather than asserting a failure the operator cannot act on.
|
// Left to the refresh below rather than asserting a failure the operator cannot act on.
|
||||||
}
|
}
|
||||||
await refresh();
|
const devices = await refresh();
|
||||||
setRevoking(false);
|
setRevoking(false);
|
||||||
setConfirming(undefined);
|
setConfirming(undefined);
|
||||||
|
if (devices?.find((candidate) => candidate.id === device.id)?.revoked ?? true) {
|
||||||
|
showOutcome({ kind: "revoked", name: device.name });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPaired = (name: string, shows: string | undefined): void => {
|
const onPaired = (name: string, shows: string | undefined): void => {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
setPaired({ name, shows });
|
setOutcome({ kind: "paired", name, shows });
|
||||||
|
setAwaiting({
|
||||||
|
known: new Set(list.kind === "ready" ? list.devices.map((device) => device.id) : []),
|
||||||
|
name,
|
||||||
|
until: Date.now() + AWAIT_PAIRED_MS,
|
||||||
|
});
|
||||||
// The new display appears without a page refresh (DESIGN.md §11.4).
|
// The new display appears without a page refresh (DESIGN.md §11.4).
|
||||||
void refresh();
|
void refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onChanged = (device: HostDeviceSummary, shows: HostDisplayModule | undefined): void => {
|
||||||
|
setChanging(undefined);
|
||||||
|
setOutcome({ kind: "changed", name: device.name, shows: shows?.moduleId });
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Focuses the control that opens a dialog before it opens, so the dialog returns focus there —
|
||||||
|
* some browsers (Safari) do not focus a clicked button — without scrolling the page. */
|
||||||
|
const opener =
|
||||||
|
(open: () => void) =>
|
||||||
|
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||||
|
event.currentTarget.focus({ preventScroll: true });
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleLabel = (moduleId: string | undefined): string | undefined =>
|
||||||
|
moduleId === undefined
|
||||||
|
? undefined
|
||||||
|
: (modules?.find((module) => module.moduleId === moduleId)?.name ??
|
||||||
|
props.moduleName(moduleId) ??
|
||||||
|
moduleId);
|
||||||
|
|
||||||
const now = props.now?.() ?? new Date();
|
const now = props.now?.() ?? new Date();
|
||||||
const canAdd = list.kind === "ready";
|
const canAdd = list.kind === "ready";
|
||||||
return (
|
return (
|
||||||
@@ -517,30 +746,40 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
title="Displays"
|
title="Displays"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{paired === undefined ? undefined : (
|
{outcome === undefined ? undefined : (
|
||||||
|
<div data-testid="device-outcome" ref={outcomeRef} tabIndex={-1}>
|
||||||
|
{outcome.kind === "paired" ? (
|
||||||
<InlineNotice title="Display paired" tone="success">
|
<InlineNotice title="Display paired" tone="success">
|
||||||
<span data-testid="device-pairing-success">
|
<span data-testid="device-pairing-success">
|
||||||
{paired.shows === undefined
|
{outcome.shows === undefined
|
||||||
? `${paired.name} is paired. It shows its name until a module is set to show on it.`
|
? `${outcome.name} is paired. It shows its name until a module is set to show on it.`
|
||||||
: `${paired.name} is paired and shows ${
|
: `${outcome.name} is paired and shows ${moduleLabel(outcome.shows)}.`}
|
||||||
modules?.find((module) => module.moduleId === paired.shows)?.name ??
|
|
||||||
props.moduleName(paired.shows) ??
|
|
||||||
paired.shows
|
|
||||||
}.`}
|
|
||||||
</span>
|
</span>
|
||||||
</InlineNotice>
|
</InlineNotice>
|
||||||
|
) : outcome.kind === "changed" ? (
|
||||||
|
<InlineNotice title="Display changed" tone="success">
|
||||||
|
<span data-testid="device-change-success">
|
||||||
|
{outcome.shows === undefined
|
||||||
|
? `${outcome.name} now shows nothing. It shows its name until a module is set.`
|
||||||
|
: `${outcome.name} now shows ${moduleLabel(outcome.shows)}.`}
|
||||||
|
</span>
|
||||||
|
</InlineNotice>
|
||||||
|
) : (
|
||||||
|
<InlineNotice title="Display revoked" tone="info">
|
||||||
|
<span data-testid="device-revoke-success">
|
||||||
|
{outcome.name} is revoked. Its screen returns to its pairing code.
|
||||||
|
</span>
|
||||||
|
</InlineNotice>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canAdd ? (
|
{canAdd ? (
|
||||||
<DisplaySetupPanel
|
<DisplaySetupPanel
|
||||||
onAdd={(event) => {
|
onAdd={opener(() => {
|
||||||
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a button
|
setOutcome(undefined);
|
||||||
// that is clicked, so it is focused deliberately before the dialog opens — without
|
|
||||||
// scrolling, which would move a page whose button sits under the phone header.
|
|
||||||
event.currentTarget.focus({ preventScroll: true });
|
|
||||||
setPaired(undefined);
|
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
}}
|
})}
|
||||||
url={displaySetupUrl(config)}
|
url={displaySetupUrl(config)}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -570,7 +809,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
{list.kind === "ready" ? (
|
{list.kind === "ready" ? (
|
||||||
<Panel title="Paired displays">
|
<Panel title="Paired displays">
|
||||||
{list.devices.length === 0 ? (
|
{active.length === 0 && awaiting === undefined ? (
|
||||||
<div data-testid="device-list-empty">
|
<div data-testid="device-list-empty">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Set up a screen above, and it appears here once it's approved."
|
description="Set up a screen above, and it appears here once it's approved."
|
||||||
@@ -579,15 +818,35 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="ek-shell-display-list" data-testid="device-list">
|
<ul className="ek-shell-display-list" data-testid="device-list">
|
||||||
{list.devices.map((device) => (
|
{active.map((device) => (
|
||||||
<DisplayCard
|
<DisplayCard
|
||||||
device={device}
|
device={device}
|
||||||
key={device.id}
|
key={device.id}
|
||||||
modules={modules}
|
modules={modules}
|
||||||
now={now}
|
now={now}
|
||||||
onRevoke={() => setConfirming(device)}
|
onChange={opener(() => {
|
||||||
|
setOutcome(undefined);
|
||||||
|
setChanging(device);
|
||||||
|
})}
|
||||||
|
onRevoke={opener(() => setConfirming(device))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{awaiting !== undefined && !arrived ? (
|
||||||
|
<li
|
||||||
|
className="ek-shell-display-card ek-shell-display-card--pending"
|
||||||
|
data-testid="device-awaiting"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="ek-shell-display-card__icon">
|
||||||
|
<ShellIcon name="settingsDisplays" />
|
||||||
|
</span>
|
||||||
|
<div className="ek-shell-display-card__copy">
|
||||||
|
<span className="ek-shell-display-card__name">{awaiting.name}</span>
|
||||||
|
<p className="ek-shell-display-card__facts" role="status">
|
||||||
|
Waiting for the screen to connect…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -602,6 +861,16 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
open={adding}
|
open={adding}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{modules === undefined ? undefined : (
|
||||||
|
<ChangeTargetDialog
|
||||||
|
config={config}
|
||||||
|
device={changing}
|
||||||
|
modules={modules}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onDismiss={dismissChanging}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
|
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
|
||||||
spelled out before it happens — never a browser-native confirm. */}
|
spelled out before it happens — never a browser-native confirm. */}
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -617,7 +886,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
<Button
|
<Button
|
||||||
data-testid="device-revoke-confirm"
|
data-testid="device-revoke-confirm"
|
||||||
disabled={revoking}
|
disabled={revoking}
|
||||||
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
|
onClick={() => (confirming ? void revoke(confirming) : undefined)}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Revoke display
|
Revoke display
|
||||||
|
|||||||
@@ -338,6 +338,24 @@ export const SHELL_STYLESHEET = `
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ek-shell-display-card__showing {
|
||||||
|
color: ${designToken("text.primary")};
|
||||||
|
font-size: ${designToken("typography.size-sm")};
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ek-shell-display-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: ${designToken("spacing.xs")};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A display just approved, waiting for its screen to collect its credential. */
|
||||||
|
.ek-shell-display-card--pending {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
.ek-shell-display-card__facts {
|
.ek-shell-display-card__facts {
|
||||||
color: ${designToken("text.secondary")};
|
color: ${designToken("text.secondary")};
|
||||||
font-size: ${designToken("typography.size-sm")};
|
font-size: ${designToken("typography.size-sm")};
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
|||||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
|
DISPLAY_SETUP_PATH,
|
||||||
type DisplayHostBootstrap,
|
type DisplayHostBootstrap,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
HostProtocolError,
|
HostProtocolError,
|
||||||
|
loadDisplaySelf,
|
||||||
} from "../host-protocol.js";
|
} from "../host-protocol.js";
|
||||||
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,9 @@ type DisplayState =
|
|||||||
}
|
}
|
||||||
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
||||||
| { readonly status: "pairing" }
|
| { readonly status: "pairing" }
|
||||||
|
/** Still a paired display, but set to show something else now: on its way to the platform's
|
||||||
|
* setup page, which opens whatever it shows now (ADR 0065). */
|
||||||
|
| { readonly status: "moving" }
|
||||||
| { readonly status: "unavailable" }
|
| { readonly status: "unavailable" }
|
||||||
| { readonly status: "failed" };
|
| { readonly status: "failed" };
|
||||||
|
|
||||||
@@ -47,8 +52,17 @@ export interface DisplayAppProps {
|
|||||||
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
|
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
|
||||||
* only. */
|
* only. */
|
||||||
readonly retryBaseMs?: number;
|
readonly retryBaseMs?: number;
|
||||||
|
/** Opens another display page in place of this one. The entry point supplies `location.replace`;
|
||||||
|
* without it a display changed to show something else falls back to showing a pairing code. */
|
||||||
|
readonly openDisplay?: (url: string) => void;
|
||||||
|
/** How often a paired screen asks the platform what it should show. Test seam only. */
|
||||||
|
readonly assignmentCheckMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A paired screen asks the platform this often whether it should still show this module, so a
|
||||||
|
* change in Settings → Displays reaches it within this interval (ADR 0065). */
|
||||||
|
export const DISPLAY_ASSIGNMENT_CHECK_MS = 30_000;
|
||||||
|
|
||||||
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
|
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
|
||||||
* an outage that outlasts this is worth the corner indicator. */
|
* an outage that outlasts this is worth the corner indicator. */
|
||||||
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
|
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
|
||||||
@@ -210,7 +224,32 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
|
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
|
||||||
retryBaseMs = DISPLAY_RETRY_BASE_MS,
|
retryBaseMs = DISPLAY_RETRY_BASE_MS,
|
||||||
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
||||||
|
openDisplay,
|
||||||
|
assignmentCheckMs = DISPLAY_ASSIGNMENT_CHECK_MS,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refused (ADR 0065): either the screen is not paired at all — revoked, expired, or never — and
|
||||||
|
* shows a pairing code as it always has, or it is still a paired display that is now set to show
|
||||||
|
* something else, and goes to the platform's setup page, which opens whatever it shows now. Only
|
||||||
|
* the platform can tell the two apart, so this asks it; a person previewing the page is never a
|
||||||
|
* device, and keeps the old behaviour.
|
||||||
|
*/
|
||||||
|
const onDenied = useCallback(async (): Promise<void> => {
|
||||||
|
if (openDisplay !== undefined) {
|
||||||
|
try {
|
||||||
|
const self = await loadDisplaySelf(config);
|
||||||
|
if (!self.displays.some((display) => display.moduleId === moduleId)) {
|
||||||
|
setState({ status: "moving" });
|
||||||
|
openDisplay(DISPLAY_SETUP_PATH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a paired display: pair, as before.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState({ status: "pairing" });
|
||||||
|
}, [config, moduleId, openDisplay]);
|
||||||
/** Consecutive automatic retries since the display last started successfully. */
|
/** Consecutive automatic retries since the display last started successfully. */
|
||||||
const failures = useRef(0);
|
const failures = useRef(0);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
@@ -261,7 +300,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (!abort.signal.aborted && isDenied(error)) {
|
if (!abort.signal.aborted && isDenied(error)) {
|
||||||
setState({ status: "pairing" });
|
void onDenied();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -270,7 +309,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[config, moduleId, revalidationIntervalMs],
|
[config, moduleId, onDenied, revalidationIntervalMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -292,6 +331,40 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [periodicRevalidationMs, revalidate, state.status]);
|
}, [periodicRevalidationMs, revalidate, state.status]);
|
||||||
|
|
||||||
|
// A paired screen asks the platform, now and then, whether it should still show this module, so a
|
||||||
|
// change made in Settings → Displays reaches it without anyone touching the screen (ADR 0065). A
|
||||||
|
// person previewing the page is not a device, and is never moved.
|
||||||
|
const viewerIsDevice = state.status === "ready" && state.bootstrap.principal.kind === "device";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewerIsDevice || openDisplay === undefined || assignmentCheckMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const abort = new AbortController();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadDisplaySelf(config, abort.signal).then(
|
||||||
|
(self) => {
|
||||||
|
if (
|
||||||
|
!abort.signal.aborted &&
|
||||||
|
!self.displays.some((display) => display.moduleId === moduleId)
|
||||||
|
) {
|
||||||
|
setState({ status: "moving" });
|
||||||
|
openDisplay(DISPLAY_SETUP_PATH);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
// Revoked: the descriptor will say so too, and return the screen to pairing.
|
||||||
|
if (!abort.signal.aborted && isDenied(error)) {
|
||||||
|
revalidate("authorization");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, assignmentCheckMs);
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [assignmentCheckMs, config, moduleId, openDisplay, revalidate, viewerIsDevice]);
|
||||||
|
|
||||||
// Could not start: try again on its own, backing off. Pairing is not a failure — it has its own
|
// Could not start: try again on its own, backing off. Pairing is not a failure — it has its own
|
||||||
// polling — and a ready display has nothing to retry.
|
// polling — and a ready display has nothing to retry.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,7 +422,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
|
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
|
||||||
// paired yet, not an error worth logging on every wall in the building.
|
// paired yet, not an error worth logging on every wall in the building.
|
||||||
if (isDenied(error)) {
|
if (isDenied(error)) {
|
||||||
setState({ status: "pairing" });
|
void onDenied();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("[ekklesia] display host bootstrap failed", error);
|
console.error("[ekklesia] display host bootstrap failed", error);
|
||||||
@@ -360,7 +433,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return () => abort.abort();
|
return () => abort.abort();
|
||||||
}, [attempt, config, moduleId]);
|
}, [attempt, config, moduleId, onDenied]);
|
||||||
|
|
||||||
const retry = (): void => setAttempt((current) => current + 1);
|
const retry = (): void => setAttempt((current) => current + 1);
|
||||||
const showOffline = useSustained(
|
const showOffline = useSustained(
|
||||||
@@ -377,6 +450,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
{state.status === "pairing" ? (
|
{state.status === "pairing" ? (
|
||||||
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
|
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
{state.status === "moving" ? (
|
||||||
|
<DisplayMessage testId="display-host-moving">Switching this display…</DisplayMessage>
|
||||||
|
) : undefined}
|
||||||
{state.status === "unavailable" ? (
|
{state.status === "unavailable" ? (
|
||||||
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
|
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
|
||||||
This display is unavailable.
|
This display is unavailable.
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ createRoot(container).render(
|
|||||||
// display can live at this address.
|
// display can live at this address.
|
||||||
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
|
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
|
||||||
) : moduleId ? (
|
) : moduleId ? (
|
||||||
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
|
<DisplayApp
|
||||||
|
config={config}
|
||||||
|
connectivity={connectivity}
|
||||||
|
moduleId={moduleId}
|
||||||
|
openDisplay={(url) => window.location.replace(url)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
data-testid="display-route-invalid"
|
data-testid="display-route-invalid"
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ async function requestPlatformMutation(
|
|||||||
rootRelativePath: string,
|
rootRelativePath: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
method: "PATCH" | "POST" = "POST",
|
method: "PATCH" | "POST" | "PUT" = "POST",
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const performRequest = config.fetch ?? fetch;
|
const performRequest = config.fetch ?? fetch;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
@@ -737,6 +737,26 @@ export async function loadDisplaySelf(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes what a paired display shows (`PUT /api/v1/devices/<id>/display`, ADR 0065): an installed
|
||||||
|
* display module, or `undefined` for nothing. The same display — same identity, name, and
|
||||||
|
* credential — before and after; the screen picks the change up on its own.
|
||||||
|
*/
|
||||||
|
export async function setHostDisplayTarget(
|
||||||
|
config: EkklesiaHostConfig,
|
||||||
|
deviceId: string,
|
||||||
|
moduleId: string | undefined,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await requestPlatformMutation(
|
||||||
|
config,
|
||||||
|
`/api/v1/devices/${encodeURIComponent(deviceId)}/display`,
|
||||||
|
{ moduleId: moduleId ?? null },
|
||||||
|
signal,
|
||||||
|
"PUT",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
|
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
|
||||||
export interface HostModuleLifecycleSummary {
|
export interface HostModuleLifecycleSummary {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
@@ -1239,3 +1239,133 @@ describe("display host — continuity across reconnects and revocation", () => {
|
|||||||
expect(display.counts.mounts).toBe(2);
|
expect(display.counts.mounts).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("display host — following what the screen is set to show (ADR 0065)", () => {
|
||||||
|
async function wait(ms: number): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A paired display (`viewer.kind === "device"`) on the announcements page. `shows` is what the
|
||||||
|
* platform says it may show now; `null` means it is not a paired display at all (403).
|
||||||
|
*/
|
||||||
|
function assignedServer(initial: { shows: string[] | null; descriptor?: number }) {
|
||||||
|
const state = { descriptor: initial.descriptor ?? 200, shows: initial.shows };
|
||||||
|
const calls: string[] = [];
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = new URL(String(input)).pathname;
|
||||||
|
calls.push(url);
|
||||||
|
if (url === "/api/v1/module-displays/announcements") {
|
||||||
|
return state.descriptor === 200
|
||||||
|
? json(
|
||||||
|
descriptorPayload("announcements", "1.2.0", { deviceId: "device-1", kind: "device" }),
|
||||||
|
)
|
||||||
|
: json({ code: "AUTHORIZATION_DENIED" }, state.descriptor);
|
||||||
|
}
|
||||||
|
if (url === "/api/v1/device-pairing/self") {
|
||||||
|
return state.shows === null
|
||||||
|
? json({ code: "AUTHORIZATION_DENIED" }, 403)
|
||||||
|
: json({
|
||||||
|
displays: state.shows.map((moduleId) => ({ moduleId, url: `/display/${moduleId}` })),
|
||||||
|
name: "Foyer TV",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/v1/device-pairing/requests") {
|
||||||
|
return json(
|
||||||
|
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url === "/api/v1/device-pairing/claims") {
|
||||||
|
return json({ status: "pending" });
|
||||||
|
}
|
||||||
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
return { calls, fetch, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAssigned(server: ReturnType<typeof assignedServer>) {
|
||||||
|
const openDisplay = vi.fn();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<DisplayApp
|
||||||
|
assignmentCheckMs={20}
|
||||||
|
config={{ apiBaseUrl, fetch: server.fetch }}
|
||||||
|
importModule={async () => artifact()}
|
||||||
|
moduleId="announcements"
|
||||||
|
offlineIndicatorDelayMs={0}
|
||||||
|
openDisplay={openDisplay}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await wait(0);
|
||||||
|
return openDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("goes to the platform setup page when a running screen is set to show something else", async () => {
|
||||||
|
const server = assignedServer({ shows: ["announcements"] });
|
||||||
|
const openDisplay = await renderAssigned(server);
|
||||||
|
expect(query("display-module-container")).not.toBeNull();
|
||||||
|
|
||||||
|
// Still this module: nothing happens, however often it asks.
|
||||||
|
await wait(60);
|
||||||
|
expect(openDisplay).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Settings → Displays moves it to another module (or to nothing).
|
||||||
|
server.state.shows = ["bulletin"];
|
||||||
|
await wait(60);
|
||||||
|
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
|
||||||
|
expect(query("display-host-moving")).not.toBeNull();
|
||||||
|
// It was never asked to pair again.
|
||||||
|
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes to setup, not to a new pairing code, when its descriptor is refused but it is still paired", async () => {
|
||||||
|
const server = assignedServer({ descriptor: 403, shows: [] });
|
||||||
|
const openDisplay = await renderAssigned(server);
|
||||||
|
await wait(10);
|
||||||
|
|
||||||
|
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
|
||||||
|
expect(query("display-pairing")).toBeNull();
|
||||||
|
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows a pairing code when it is not a paired display at all", async () => {
|
||||||
|
const server = assignedServer({ descriptor: 403, shows: null });
|
||||||
|
const openDisplay = await renderAssigned(server);
|
||||||
|
await wait(10);
|
||||||
|
|
||||||
|
expect(openDisplay).not.toHaveBeenCalled();
|
||||||
|
expect(query("display-pairing")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never moves a person previewing the page", async () => {
|
||||||
|
const server = assignedServer({ shows: [] });
|
||||||
|
const fetch = server.fetch;
|
||||||
|
// The descriptor reports a person, not a device.
|
||||||
|
const personFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input).endsWith("/api/v1/module-displays/announcements")) {
|
||||||
|
return json(descriptorPayload());
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
const openDisplay = vi.fn();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<DisplayApp
|
||||||
|
assignmentCheckMs={20}
|
||||||
|
config={{ apiBaseUrl, fetch: personFetch }}
|
||||||
|
importModule={async () => artifact()}
|
||||||
|
moduleId="announcements"
|
||||||
|
offlineIndicatorDelayMs={0}
|
||||||
|
openDisplay={openDisplay}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await wait(80);
|
||||||
|
expect(openDisplay).not.toHaveBeenCalled();
|
||||||
|
expect(server.calls).not.toContain("/api/v1/device-pairing/self");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into
|
* Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen,
|
||||||
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
|
* pairing it, changing what it shows, and revoking it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const apiBaseUrl = "https://ekklesia.example.test";
|
const apiBaseUrl = "https://ekklesia.example.test";
|
||||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||||
|
|
||||||
|
/** A screen that asked from Signage's own display page. */
|
||||||
|
const MODULE_CODE = "123456";
|
||||||
|
/** A screen that asked from the platform's `/display/setup`, with no module. */
|
||||||
|
const PLATFORM_CODE = "654321";
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
@@ -38,21 +43,31 @@ function json(body: unknown, status = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt?: string;
|
||||||
|
permissions: string[];
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerOptions {
|
interface ServerOptions {
|
||||||
readonly devices?: unknown[];
|
readonly devices?: Device[];
|
||||||
readonly denied?: boolean;
|
readonly denied?: boolean;
|
||||||
/** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by
|
/** What `GET /api/v1/devices/display-modules` answers. */
|
||||||
* default Signage), none, or a failure. */
|
|
||||||
readonly modules?: unknown[] | "fails";
|
readonly modules?: unknown[] | "fails";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A screen that asked from Signage's own display page. */
|
const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" };
|
||||||
const MODULE_CODE = "123456";
|
const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" };
|
||||||
/** A screen that asked from the platform's `/display/setup`, with no module (ADR 0065). */
|
|
||||||
const PLATFORM_CODE = "654321";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake API that behaves like the real one where it matters: an approved display does not exist
|
||||||
|
* until its screen collects its credential, which here happens a couple of list requests later.
|
||||||
|
*/
|
||||||
function server(options: ServerOptions = {}) {
|
function server(options: ServerOptions = {}) {
|
||||||
let devices: unknown[] = options.devices ?? [
|
const devices: Device[] = options.devices ?? [
|
||||||
{
|
{
|
||||||
createdAt: "2026-10-01T09:00:00.000Z",
|
createdAt: "2026-10-01T09:00:00.000Z",
|
||||||
id: "device-1",
|
id: "device-1",
|
||||||
@@ -62,26 +77,52 @@ function server(options: ServerOptions = {}) {
|
|||||||
revoked: false,
|
revoked: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const requests: { path: string; body: unknown }[] = [];
|
const modules =
|
||||||
|
options.modules === "fails" ? [] : ((options.modules ?? [SIGNAGE]) as (typeof SIGNAGE)[]);
|
||||||
|
let pending: { device: Device; listsUntilClaimed: number } | undefined;
|
||||||
|
const requests: { path: string; method: string; body: unknown }[] = [];
|
||||||
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const path = new URL(String(input)).pathname;
|
const path = new URL(String(input)).pathname;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
||||||
requests.push({ body, path });
|
requests.push({ body, method, path });
|
||||||
if (path === "/api/v1/devices/display-modules") {
|
if (path === "/api/v1/devices/display-modules") {
|
||||||
if (options.modules === "fails") {
|
return options.modules === "fails"
|
||||||
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
|
? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503)
|
||||||
}
|
: json({ modules });
|
||||||
return json({
|
|
||||||
modules: options.modules ?? [
|
|
||||||
{ moduleId: "signage", name: "Signage", permissionId: "signage.display" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (path === "/api/v1/devices") {
|
if (path === "/api/v1/devices") {
|
||||||
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
|
if (options.denied) {
|
||||||
|
return json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||||
|
}
|
||||||
|
if (pending !== undefined) {
|
||||||
|
pending.listsUntilClaimed -= 1;
|
||||||
|
if (pending.listsUntilClaimed <= 0) {
|
||||||
|
devices.push(pending.device);
|
||||||
|
pending = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ devices });
|
||||||
|
}
|
||||||
|
const display = /^\/api\/v1\/devices\/([^/]+)\/display$/u.exec(path);
|
||||||
|
if (display && method === "PUT") {
|
||||||
|
const device = devices.find((candidate) => candidate.id === display[1]);
|
||||||
|
const moduleId = (body as { moduleId: string | null }).moduleId;
|
||||||
|
const module = modules.find((candidate) => candidate.moduleId === moduleId);
|
||||||
|
if (!device || device.revoked || (moduleId !== null && !module)) {
|
||||||
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
|
}
|
||||||
|
device.permissions = module ? [module.permissionId] : [];
|
||||||
|
return json({ moduleId });
|
||||||
|
}
|
||||||
|
const revoke = /^\/api\/v1\/devices\/([^/]+)\/revoke$/u.exec(path);
|
||||||
|
if (revoke) {
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device.id === revoke[1]) {
|
||||||
|
device.revoked = true;
|
||||||
|
device.permissions = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (path === "/api/v1/devices/device-1/revoke") {
|
|
||||||
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
if (path === "/api/v1/device-pairing/lookups") {
|
if (path === "/api/v1/device-pairing/lookups") {
|
||||||
@@ -100,24 +141,22 @@ function server(options: ServerOptions = {}) {
|
|||||||
if (path === "/api/v1/device-pairing/approvals") {
|
if (path === "/api/v1/device-pairing/approvals") {
|
||||||
const approval = body as { code: string; deviceName: string; moduleId?: string };
|
const approval = body as { code: string; deviceName: string; moduleId?: string };
|
||||||
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
|
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
|
||||||
devices = [
|
const module = modules.find((candidate) => candidate.moduleId === shows);
|
||||||
...devices,
|
pending = {
|
||||||
{
|
device: {
|
||||||
createdAt: "2026-10-15T12:00:00.000Z",
|
createdAt: "2026-10-15T12:00:00.000Z",
|
||||||
id: "device-2",
|
id: `device-${devices.length + 1}`,
|
||||||
name: approval.deviceName,
|
name: approval.deviceName,
|
||||||
permissions: shows === "signage" ? ["signage.display"] : [],
|
permissions: module ? [module.permissionId] : [],
|
||||||
revoked: false,
|
revoked: false,
|
||||||
},
|
},
|
||||||
];
|
listsUntilClaimed: 2,
|
||||||
return json({
|
};
|
||||||
moduleId: shows ?? null,
|
return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null });
|
||||||
permissionId: shows === undefined ? null : `${shows}.display`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return json({ code: "NOT_FOUND" }, 404);
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
}) as unknown as typeof globalThis.fetch;
|
}) as unknown as typeof globalThis.fetch;
|
||||||
return { fetch, requests };
|
return { devices, fetch, requests };
|
||||||
}
|
}
|
||||||
|
|
||||||
function query(testId: string): HTMLElement | null {
|
function query(testId: string): HTMLElement | null {
|
||||||
@@ -138,6 +177,19 @@ async function settle(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Waits, in real time, for something the page does on its own (a revalidation). */
|
||||||
|
async function eventually(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error("timed out");
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function click(testId: string): Promise<void> {
|
async function click(testId: string): Promise<void> {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
required(testId).click();
|
required(testId).click();
|
||||||
@@ -154,15 +206,6 @@ async function type(testId: string, value: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function choose(testId: string, value: string): Promise<void> {
|
|
||||||
const select = required(testId) as HTMLSelectElement;
|
|
||||||
await act(async () => {
|
|
||||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
|
||||||
setter?.call(select, value);
|
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(testId: string): Promise<void> {
|
async function submit(testId: string): Promise<void> {
|
||||||
const form = (required(testId) as HTMLButtonElement).form;
|
const form = (required(testId) as HTMLButtonElement).form;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -171,6 +214,22 @@ async function submit(testId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a SelectMenu and chooses an option by its label, as a pointer would. */
|
||||||
|
async function choose(testId: string, label: string): Promise<void> {
|
||||||
|
await click(testId);
|
||||||
|
const listId = required(testId).getAttribute("aria-controls") ?? "";
|
||||||
|
const option = [...(container.querySelector(`#${CSS.escape(listId)}`)?.children ?? [])].find(
|
||||||
|
(candidate) => candidate.textContent === label,
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
if (!option) {
|
||||||
|
throw new Error(`no option ${label}`);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
option.click();
|
||||||
|
await settle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function renderPage(options: ServerOptions = {}) {
|
async function renderPage(options: ServerOptions = {}) {
|
||||||
const platform = server(options);
|
const platform = server(options);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -180,6 +239,8 @@ async function renderPage(options: ServerOptions = {}) {
|
|||||||
config={{ apiBaseUrl, fetch: platform.fetch }}
|
config={{ apiBaseUrl, fetch: platform.fetch }}
|
||||||
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
||||||
now={() => NOW}
|
now={() => NOW}
|
||||||
|
pollMs={5}
|
||||||
|
refreshMs={0}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await settle();
|
await settle();
|
||||||
@@ -188,34 +249,61 @@ async function renderPage(options: ServerOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Settings → Displays", () => {
|
describe("Settings → Displays", () => {
|
||||||
it("lists the paired displays with what is known about each, and takes no focus", async () => {
|
it("lists each active display with its status, what it shows, and its actions", async () => {
|
||||||
const before = document.activeElement;
|
const before = document.activeElement;
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
const card = required("device-device-1");
|
const card = required("device-device-1");
|
||||||
expect(card.textContent).toContain("Foyer TV");
|
expect(card.textContent).toContain("Foyer TV");
|
||||||
expect(card.textContent).toContain("Paired");
|
expect(card.textContent).toContain("Online");
|
||||||
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||||
expect(card.textContent).toContain("Last seen within the last hour");
|
expect(card.textContent).toContain("Last seen within the last hour");
|
||||||
expect(card.textContent).toContain("Not showing a module yet");
|
expect(query("device-change-device-1")?.textContent).toBe("Change");
|
||||||
|
expect(query("device-revoke-device-1")?.textContent).toBe("Revoke");
|
||||||
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
||||||
// How to set up a new screen comes first, in one place: the platform's own setup page on the
|
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
||||||
// API origin, then Add display right under it, with the step-by-step version folded away.
|
|
||||||
const setup = required("display-setup");
|
const setup = required("display-setup");
|
||||||
expect(setup.textContent).toContain("Open this address on the new screen");
|
|
||||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||||
expect(setup.contains(required("add-display"))).toBe(true);
|
expect(setup.contains(required("add-display"))).toBe(true);
|
||||||
const steps = required("display-setup-steps");
|
expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false);
|
||||||
expect(steps.tagName).toBe("DETAILS");
|
|
||||||
expect((steps as HTMLDetailsElement).open).toBe(false);
|
|
||||||
expect(steps.querySelector("summary")?.textContent).toBe("Step-by-step instructions");
|
|
||||||
expect(steps.textContent).toContain("six-digit pairing code");
|
|
||||||
// Not a second Add display up in the header.
|
|
||||||
expect(container.querySelectorAll('[data-testid="add-display"]')).toHaveLength(1);
|
|
||||||
// No pairing form until someone asks for one, and nothing grabbed focus.
|
// No pairing form until someone asks for one, and nothing grabbed focus.
|
||||||
expect(container.querySelector("input")).toBeNull();
|
expect(container.querySelector("input")).toBeNull();
|
||||||
expect(document.activeElement).toBe(before);
|
expect(document.activeElement).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows only active displays: revoked ones are history, not the normal list", async () => {
|
||||||
|
await renderPage({
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
createdAt: "2026-10-01T09:00:00.000Z",
|
||||||
|
id: "a",
|
||||||
|
name: "Hall",
|
||||||
|
permissions: [],
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: "2026-09-01T09:00:00.000Z",
|
||||||
|
id: "b",
|
||||||
|
name: "Old test screen",
|
||||||
|
permissions: [],
|
||||||
|
revoked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: "2026-09-02T09:00:00.000Z",
|
||||||
|
id: "c",
|
||||||
|
name: "Another old one",
|
||||||
|
permissions: [],
|
||||||
|
revoked: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(query("device-a")).not.toBeNull();
|
||||||
|
expect(query("device-b")).toBeNull();
|
||||||
|
expect(query("device-c")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Revoked");
|
||||||
|
expect(required("device-a").textContent).toContain("Not connected yet");
|
||||||
|
});
|
||||||
|
|
||||||
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
|
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
|
||||||
await renderPage({ devices: [] });
|
await renderPage({ devices: [] });
|
||||||
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
||||||
@@ -227,40 +315,37 @@ describe("Settings → Displays", () => {
|
|||||||
expect(query("add-display")).toBeNull();
|
expect(query("add-display")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
|
it("shows a newly paired display without a page refresh, once its screen connects", async () => {
|
||||||
const platform = await renderPage();
|
const platform = await renderPage();
|
||||||
|
|
||||||
await click("add-display");
|
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"));
|
expect(document.activeElement).toBe(required("device-pairing-code-input"));
|
||||||
|
|
||||||
await type("device-pairing-code-input", "999999");
|
await type("device-pairing-code-input", "999999");
|
||||||
await submit("device-pairing-lookup");
|
await submit("device-pairing-lookup");
|
||||||
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
||||||
|
|
||||||
await type("device-pairing-code-input", MODULE_CODE);
|
await type("device-pairing-code-input", MODULE_CODE);
|
||||||
await submit("device-pairing-lookup");
|
await submit("device-pairing-lookup");
|
||||||
// A screen that asked from a module's own display already shows that module: no choice.
|
|
||||||
expect(required("device-pairing-found").textContent).toContain("Signage");
|
expect(required("device-pairing-found").textContent).toContain("Signage");
|
||||||
expect(query("device-pairing-shows")).toBeNull();
|
expect(query("device-pairing-shows")).toBeNull();
|
||||||
expect(document.activeElement).toBe(required("device-pairing-name"));
|
expect(document.activeElement).toBe(required("device-pairing-name"));
|
||||||
|
|
||||||
await type("device-pairing-name", "Sanctuary left");
|
await type("device-pairing-name", "Sanctuary left");
|
||||||
await submit("device-pairing-approve");
|
await submit("device-pairing-approve");
|
||||||
|
|
||||||
|
// The dialog closes and the page says so at once; the screen has not connected yet.
|
||||||
expect(query("add-display-dialog")).toBeNull();
|
expect(query("add-display-dialog")).toBeNull();
|
||||||
expect(
|
|
||||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
|
||||||
).toEqual({ code: MODULE_CODE, 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(
|
expect(required("device-pairing-success").textContent).toContain(
|
||||||
"Sanctuary left is paired and shows Signage.",
|
"Sanctuary left is paired and shows Signage.",
|
||||||
);
|
);
|
||||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect");
|
||||||
// Focus is back on the control that opened the flow.
|
|
||||||
|
// The page checks again on its own until the display exists — no reload.
|
||||||
|
await eventually(() => query("device-device-2") !== null);
|
||||||
|
expect(query("device-awaiting")).toBeNull();
|
||||||
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
||||||
|
// Never twice, however many times it is fetched again.
|
||||||
|
expect(container.querySelectorAll('[data-testid="device-device-2"]')).toHaveLength(1);
|
||||||
|
expect(platform.requests.filter((r) => r.path === "/api/v1/devices").length).toBeGreaterThan(2);
|
||||||
expect(document.activeElement).toBe(required("add-display"));
|
expect(document.activeElement).toBe(required("add-display"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,15 +380,11 @@ describe("setting up a new screen (ADR 0065)", () => {
|
|||||||
expect(required("display-setup").textContent).toContain("couldn't copy it");
|
expect(required("display-setup").textContent).toContain("couldn't copy it");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the same setup and Add display with zero display modules installed", async () => {
|
it("pairs a platform display with zero display modules installed", async () => {
|
||||||
const platform = await renderPage({ modules: [] });
|
const platform = await renderPage({ modules: [] });
|
||||||
|
|
||||||
// The generic platform address, and nothing that names a module.
|
|
||||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||||
expect(required("display-setup").textContent).not.toContain("Signage");
|
expect(required("display-setup").textContent).not.toContain("Signage");
|
||||||
expect(query("add-display")).not.toBeNull();
|
|
||||||
|
|
||||||
// A screen from /display/setup pairs as a platform display with no module.
|
|
||||||
await click("add-display");
|
await click("add-display");
|
||||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||||
await submit("device-pairing-lookup");
|
await submit("device-pairing-lookup");
|
||||||
@@ -320,40 +401,35 @@ describe("setting up a new screen (ADR 0065)", () => {
|
|||||||
expect(
|
expect(
|
||||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
|
||||||
expect(required("device-pairing-success").textContent).toContain(
|
await eventually(() => query("device-device-2") !== null);
|
||||||
"Foyer TV 2 is paired. It shows its name until a module is set to show on it.",
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing yet");
|
||||||
);
|
|
||||||
expect(required("device-device-2").textContent).toContain("Not showing a module yet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the administrator choose which installed module a new platform display shows", async () => {
|
it("chooses what a new platform display shows with the themed select, not a native one", async () => {
|
||||||
const platform = await renderPage();
|
const platform = await renderPage();
|
||||||
await click("add-display");
|
await click("add-display");
|
||||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||||
await submit("device-pairing-lookup");
|
await submit("device-pairing-lookup");
|
||||||
|
|
||||||
const shows = required("device-pairing-shows") as HTMLSelectElement;
|
const dialog = required("add-display-dialog");
|
||||||
expect([...shows.options].map((option) => option.textContent)).toEqual([
|
expect(dialog.querySelector("select")).toBeNull();
|
||||||
"Nothing yet",
|
const shows = required("device-pairing-shows");
|
||||||
"Signage",
|
expect(shows.getAttribute("role")).toBe("combobox");
|
||||||
]);
|
expect(shows.textContent).toBe("Nothing yet");
|
||||||
expect(shows.value).toBe("");
|
await choose("device-pairing-shows", "Signage");
|
||||||
await choose("device-pairing-shows", "signage");
|
expect(shows.textContent).toBe("Signage");
|
||||||
await type("device-pairing-name", "Lobby screen");
|
await type("device-pairing-name", "Lobby screen");
|
||||||
await submit("device-pairing-approve");
|
await submit("device-pairing-approve");
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
|
||||||
expect(required("device-pairing-success").textContent).toContain(
|
await eventually(() => query("device-device-2") !== null);
|
||||||
"Lobby screen is paired and shows Signage.",
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
||||||
);
|
|
||||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
|
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
|
||||||
const platform = await renderPage({ modules: "fails" });
|
const platform = await renderPage({ modules: "fails" });
|
||||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
|
||||||
await click("add-display");
|
await click("add-display");
|
||||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||||
await submit("device-pairing-lookup");
|
await submit("device-pairing-lookup");
|
||||||
@@ -363,8 +439,66 @@ describe("setting up a new screen (ADR 0065)", () => {
|
|||||||
expect(
|
expect(
|
||||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
|
||||||
// Without the module list the card does not guess what the display shows.
|
// Without the module list the card neither guesses what it shows nor offers to change it.
|
||||||
expect(required("device-device-1").textContent).not.toContain("showing");
|
expect(query("device-device-1-showing")).toBeNull();
|
||||||
|
expect(query("device-change-device-1")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changing what a display shows (ADR 0065)", () => {
|
||||||
|
it("moves a display from nothing to a module, between modules, and back, as the same display", async () => {
|
||||||
|
const platform = await renderPage({ modules: [SIGNAGE, BULLETIN] });
|
||||||
|
const puts = () =>
|
||||||
|
platform.requests.filter((r) => r.method === "PUT").map((r) => [r.path, r.body]);
|
||||||
|
|
||||||
|
// Nothing yet → Signage.
|
||||||
|
await click("device-change-device-1");
|
||||||
|
const dialog = required("device-change-dialog");
|
||||||
|
expect(dialog.textContent).toContain("Change what Foyer TV shows");
|
||||||
|
expect(dialog.querySelector("select")).toBeNull();
|
||||||
|
expect(required("device-change-shows").textContent).toBe("Nothing yet");
|
||||||
|
// Nothing to save until something changes.
|
||||||
|
expect((required("device-change-save") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await choose("device-change-shows", "Signage");
|
||||||
|
await click("device-change-save");
|
||||||
|
expect(query("device-change-dialog")).toBeNull();
|
||||||
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Signage");
|
||||||
|
expect(required("device-change-success").textContent).toBe("Foyer TV now shows Signage.");
|
||||||
|
expect(document.activeElement).toBe(required("device-change-device-1"));
|
||||||
|
|
||||||
|
// Signage → Bulletin.
|
||||||
|
await click("device-change-device-1");
|
||||||
|
expect(required("device-change-shows").textContent).toBe("Signage");
|
||||||
|
await choose("device-change-shows", "Bulletin");
|
||||||
|
await click("device-change-save");
|
||||||
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Bulletin");
|
||||||
|
|
||||||
|
// Bulletin → nothing.
|
||||||
|
await click("device-change-device-1");
|
||||||
|
await choose("device-change-shows", "Nothing yet");
|
||||||
|
await click("device-change-save");
|
||||||
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||||
|
expect(required("device-change-success").textContent).toContain("Foyer TV now shows nothing");
|
||||||
|
|
||||||
|
expect(puts()).toEqual([
|
||||||
|
["/api/v1/devices/device-1/display", { moduleId: "signage" }],
|
||||||
|
["/api/v1/devices/device-1/display", { moduleId: "bulletin" }],
|
||||||
|
["/api/v1/devices/device-1/display", { moduleId: null }],
|
||||||
|
]);
|
||||||
|
// Always the one display: no new identity, and the same name.
|
||||||
|
expect(platform.devices.map((device) => device.id)).toEqual(["device-1"]);
|
||||||
|
expect(container.querySelectorAll('[data-testid^="device-device-"]').length).toBeGreaterThan(0);
|
||||||
|
expect(container.querySelectorAll('[data-testid="device-device-1"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Cancel or Escape without changing anything", async () => {
|
||||||
|
const platform = await renderPage();
|
||||||
|
await click("device-change-device-1");
|
||||||
|
await choose("device-change-shows", "Signage");
|
||||||
|
await click("device-change-cancel");
|
||||||
|
expect(query("device-change-dialog")).toBeNull();
|
||||||
|
expect(platform.requests.some((r) => r.method === "PUT")).toBe(false);
|
||||||
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,15 +523,21 @@ describe("display revocation", () => {
|
|||||||
expect(confirm).not.toHaveBeenCalled();
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("revokes only after the destructive action is confirmed", async () => {
|
it("removes a revoked display from the list at once, and says what happened", async () => {
|
||||||
const platform = await renderPage();
|
const platform = await renderPage();
|
||||||
|
|
||||||
await click("device-revoke-device-1");
|
await click("device-revoke-device-1");
|
||||||
await click("device-revoke-confirm");
|
await click("device-revoke-confirm");
|
||||||
|
await act(async () => settle());
|
||||||
|
|
||||||
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
expect(required("device-device-1").textContent).toContain("Revoked");
|
// Gone from the active list without a reload, though the server keeps it as history.
|
||||||
expect(query("device-revoke-device-1")).toBeNull();
|
expect(query("device-device-1")).toBeNull();
|
||||||
|
expect(platform.devices[0]?.revoked).toBe(true);
|
||||||
|
expect(required("device-list-empty")).not.toBeNull();
|
||||||
|
expect(required("device-revoke-success").textContent).toContain("Foyer TV is revoked");
|
||||||
|
// Its card is gone, so focus lands on what happened rather than on the page.
|
||||||
|
expect(document.activeElement).toBe(required("device-outcome"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user