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
@@ -11,7 +11,7 @@ import {
|
||||
PageHeader,
|
||||
type PageHeaderBack,
|
||||
Panel,
|
||||
Select,
|
||||
SelectMenu,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import {
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
listHostDisplayModules,
|
||||
lookupDevicePairing,
|
||||
revokeHostDevice,
|
||||
setHostDisplayTarget,
|
||||
} from "../host-protocol.js";
|
||||
import { calendarDate, lastSeenPhrase } from "./display-time.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. */
|
||||
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 {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
/** 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;
|
||||
/** For tests: the clock "last seen" is measured against. */
|
||||
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>
|
||||
{asked === undefined && modules !== undefined && modules.length > 0 ? (
|
||||
<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"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
<SelectMenu
|
||||
{...control}
|
||||
data-testid="device-pairing-shows"
|
||||
disabled={busy}
|
||||
onChange={(event) => setShows(event.target.value)}
|
||||
onChange={setShows}
|
||||
options={targetOptions(modules)}
|
||||
value={shows}
|
||||
>
|
||||
<option value={NO_MODULE}>Nothing yet</option>
|
||||
{modules.map((module) => (
|
||||
<option key={module.moduleId} value={module.moduleId}>
|
||||
{module.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : 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: {
|
||||
readonly device: HostDeviceSummary;
|
||||
readonly now: Date;
|
||||
readonly modules: DisplayModules;
|
||||
readonly onRevoke: () => void;
|
||||
readonly onChange: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
readonly onRevoke: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}): 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[] = [];
|
||||
if (!device.revoked && props.modules !== undefined) {
|
||||
const shows = props.modules.filter((module) =>
|
||||
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 (seen !== undefined) {
|
||||
facts.push(`Last seen ${lastSeenPhrase(seen, now)}`);
|
||||
}
|
||||
if (device.pairedAt !== undefined) {
|
||||
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__title">
|
||||
<span className="ek-shell-display-card__name">{device.name}</span>
|
||||
<Badge tone={device.revoked ? "neutral" : "success"}>
|
||||
{device.revoked ? "Revoked" : "Paired"}
|
||||
</Badge>
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
</div>
|
||||
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
|
||||
{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>
|
||||
)}
|
||||
</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">
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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 {
|
||||
const { config } = props;
|
||||
const pollMs = props.pollMs ?? 1000;
|
||||
const refreshMs = props.refreshMs ?? LIST_REFRESH_MS;
|
||||
const [list, setList] = useState<ListState>({ kind: "loading" });
|
||||
const [modules, setModules] = useState<DisplayModules>(undefined);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [paired, setPaired] = useState<
|
||||
{ readonly name: string; readonly shows: string | undefined } | undefined
|
||||
>(undefined);
|
||||
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined);
|
||||
const [awaiting, setAwaiting] = useState<Awaiting | 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. */
|
||||
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const outcomeRef = useRef<HTMLDivElement>(null);
|
||||
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
|
||||
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 {
|
||||
setList({ devices: await listHostDevices(config), kind: "ready" });
|
||||
const devices = await listHostDevices(config);
|
||||
setList({ devices, kind: "ready" });
|
||||
return devices;
|
||||
} catch (error) {
|
||||
setList({
|
||||
kind:
|
||||
@@ -463,6 +619,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
? "denied"
|
||||
: "failed",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -470,6 +627,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
void 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(() => {
|
||||
const controller = new AbortController();
|
||||
// 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();
|
||||
}, [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);
|
||||
try {
|
||||
await revokeHostDevice(config, deviceId);
|
||||
await revokeHostDevice(config, device.id);
|
||||
} catch {
|
||||
// Left to the refresh below rather than asserting a failure the operator cannot act on.
|
||||
}
|
||||
await refresh();
|
||||
const devices = await refresh();
|
||||
setRevoking(false);
|
||||
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 => {
|
||||
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).
|
||||
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 canAdd = list.kind === "ready";
|
||||
return (
|
||||
@@ -517,30 +746,40 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
title="Displays"
|
||||
/>
|
||||
|
||||
{paired === undefined ? undefined : (
|
||||
<InlineNotice title="Display paired" tone="success">
|
||||
<span data-testid="device-pairing-success">
|
||||
{paired.shows === undefined
|
||||
? `${paired.name} is paired. It shows its name until a module is set to show on it.`
|
||||
: `${paired.name} is paired and shows ${
|
||||
modules?.find((module) => module.moduleId === paired.shows)?.name ??
|
||||
props.moduleName(paired.shows) ??
|
||||
paired.shows
|
||||
}.`}
|
||||
</span>
|
||||
</InlineNotice>
|
||||
{outcome === undefined ? undefined : (
|
||||
<div data-testid="device-outcome" ref={outcomeRef} tabIndex={-1}>
|
||||
{outcome.kind === "paired" ? (
|
||||
<InlineNotice title="Display paired" tone="success">
|
||||
<span data-testid="device-pairing-success">
|
||||
{outcome.shows === undefined
|
||||
? `${outcome.name} is paired. It shows its name until a module is set to show on it.`
|
||||
: `${outcome.name} is paired and shows ${moduleLabel(outcome.shows)}.`}
|
||||
</span>
|
||||
</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 ? (
|
||||
<DisplaySetupPanel
|
||||
onAdd={(event) => {
|
||||
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a button
|
||||
// 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);
|
||||
onAdd={opener(() => {
|
||||
setOutcome(undefined);
|
||||
setAdding(true);
|
||||
}}
|
||||
})}
|
||||
url={displaySetupUrl(config)}
|
||||
/>
|
||||
) : undefined}
|
||||
@@ -570,7 +809,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
) : undefined}
|
||||
{list.kind === "ready" ? (
|
||||
<Panel title="Paired displays">
|
||||
{list.devices.length === 0 ? (
|
||||
{active.length === 0 && awaiting === undefined ? (
|
||||
<div data-testid="device-list-empty">
|
||||
<EmptyState
|
||||
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>
|
||||
) : (
|
||||
<ul className="ek-shell-display-list" data-testid="device-list">
|
||||
{list.devices.map((device) => (
|
||||
{active.map((device) => (
|
||||
<DisplayCard
|
||||
device={device}
|
||||
key={device.id}
|
||||
modules={modules}
|
||||
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>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -602,6 +861,16 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
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
|
||||
spelled out before it happens — never a browser-native confirm. */}
|
||||
<Dialog
|
||||
@@ -617,7 +886,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
||||
<Button
|
||||
data-testid="device-revoke-confirm"
|
||||
disabled={revoking}
|
||||
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
|
||||
onClick={() => (confirming ? void revoke(confirming) : undefined)}
|
||||
variant="danger"
|
||||
>
|
||||
Revoke display
|
||||
|
||||
@@ -338,6 +338,24 @@ export const SHELL_STYLESHEET = `
|
||||
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 {
|
||||
color: ${designToken("text.secondary")};
|
||||
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 {
|
||||
bootstrapDisplayHost,
|
||||
DISPLAY_SETUP_PATH,
|
||||
type DisplayHostBootstrap,
|
||||
type EkklesiaHostConfig,
|
||||
HostProtocolError,
|
||||
loadDisplaySelf,
|
||||
} from "../host-protocol.js";
|
||||
import { DisplayUiFrame } from "./DisplayUiFrame.js";
|
||||
import {
|
||||
@@ -26,6 +28,9 @@ type DisplayState =
|
||||
}
|
||||
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
||||
| { 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: "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
|
||||
* only. */
|
||||
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
|
||||
* an outage that outlasts this is worth the corner indicator. */
|
||||
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
|
||||
@@ -210,7 +224,32 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
|
||||
retryBaseMs = DISPLAY_RETRY_BASE_MS,
|
||||
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
||||
openDisplay,
|
||||
assignmentCheckMs = DISPLAY_ASSIGNMENT_CHECK_MS,
|
||||
} = 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. */
|
||||
const failures = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
@@ -261,7 +300,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!abort.signal.aborted && isDenied(error)) {
|
||||
setState({ status: "pairing" });
|
||||
void onDenied();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -270,7 +309,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
}
|
||||
});
|
||||
},
|
||||
[config, moduleId, revalidationIntervalMs],
|
||||
[config, moduleId, onDenied, revalidationIntervalMs],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -292,6 +331,40 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
return () => clearInterval(timer);
|
||||
}, [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
|
||||
// polling — and a ready display has nothing to retry.
|
||||
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
|
||||
// paired yet, not an error worth logging on every wall in the building.
|
||||
if (isDenied(error)) {
|
||||
setState({ status: "pairing" });
|
||||
void onDenied();
|
||||
return;
|
||||
}
|
||||
console.error("[ekklesia] display host bootstrap failed", error);
|
||||
@@ -360,7 +433,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [attempt, config, moduleId]);
|
||||
}, [attempt, config, moduleId, onDenied]);
|
||||
|
||||
const retry = (): void => setAttempt((current) => current + 1);
|
||||
const showOffline = useSustained(
|
||||
@@ -377,6 +450,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
{state.status === "pairing" ? (
|
||||
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
|
||||
) : undefined}
|
||||
{state.status === "moving" ? (
|
||||
<DisplayMessage testId="display-host-moving">Switching this display…</DisplayMessage>
|
||||
) : undefined}
|
||||
{state.status === "unavailable" ? (
|
||||
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
|
||||
This display is unavailable.
|
||||
|
||||
@@ -76,7 +76,12 @@ createRoot(container).render(
|
||||
// display can live at this address.
|
||||
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
|
||||
) : moduleId ? (
|
||||
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
|
||||
<DisplayApp
|
||||
config={config}
|
||||
connectivity={connectivity}
|
||||
moduleId={moduleId}
|
||||
openDisplay={(url) => window.location.replace(url)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
data-testid="display-route-invalid"
|
||||
|
||||
@@ -417,7 +417,7 @@ async function requestPlatformMutation(
|
||||
rootRelativePath: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
method: "PATCH" | "POST" = "POST",
|
||||
method: "PATCH" | "POST" | "PUT" = "POST",
|
||||
): Promise<unknown> {
|
||||
const performRequest = config.fetch ?? fetch;
|
||||
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. */
|
||||
export interface HostModuleLifecycleSummary {
|
||||
readonly id: string;
|
||||
|
||||
@@ -1239,3 +1239,133 @@ describe("display host — continuity across reconnects and revocation", () => {
|
||||
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 — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
|
||||
* Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen,
|
||||
* pairing it, changing what it shows, and revoking it.
|
||||
*/
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
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 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 {
|
||||
readonly devices?: unknown[];
|
||||
readonly devices?: Device[];
|
||||
readonly denied?: boolean;
|
||||
/** What `GET /api/v1/devices/display-modules` answers: the installed display modules (by
|
||||
* default Signage), none, or a failure. */
|
||||
/** What `GET /api/v1/devices/display-modules` answers. */
|
||||
readonly modules?: unknown[] | "fails";
|
||||
}
|
||||
|
||||
/** 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 (ADR 0065). */
|
||||
const PLATFORM_CODE = "654321";
|
||||
const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" };
|
||||
const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" };
|
||||
|
||||
/**
|
||||
* 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 = {}) {
|
||||
let devices: unknown[] = options.devices ?? [
|
||||
const devices: Device[] = options.devices ?? [
|
||||
{
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
id: "device-1",
|
||||
@@ -62,26 +77,52 @@ function server(options: ServerOptions = {}) {
|
||||
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 path = new URL(String(input)).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
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 (options.modules === "fails") {
|
||||
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
|
||||
}
|
||||
return json({
|
||||
modules: options.modules ?? [
|
||||
{ moduleId: "signage", name: "Signage", permissionId: "signage.display" },
|
||||
],
|
||||
});
|
||||
return options.modules === "fails"
|
||||
? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503)
|
||||
: json({ modules });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
if (path === "/api/v1/devices/device-1/revoke") {
|
||||
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (path === "/api/v1/device-pairing/lookups") {
|
||||
@@ -100,24 +141,22 @@ function server(options: ServerOptions = {}) {
|
||||
if (path === "/api/v1/device-pairing/approvals") {
|
||||
const approval = body as { code: string; deviceName: string; moduleId?: string };
|
||||
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
|
||||
devices = [
|
||||
...devices,
|
||||
{
|
||||
const module = modules.find((candidate) => candidate.moduleId === shows);
|
||||
pending = {
|
||||
device: {
|
||||
createdAt: "2026-10-15T12:00:00.000Z",
|
||||
id: "device-2",
|
||||
id: `device-${devices.length + 1}`,
|
||||
name: approval.deviceName,
|
||||
permissions: shows === "signage" ? ["signage.display"] : [],
|
||||
permissions: module ? [module.permissionId] : [],
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
return json({
|
||||
moduleId: shows ?? null,
|
||||
permissionId: shows === undefined ? null : `${shows}.display`,
|
||||
});
|
||||
listsUntilClaimed: 2,
|
||||
};
|
||||
return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null });
|
||||
}
|
||||
return json({ code: "NOT_FOUND" }, 404);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
return { fetch, requests };
|
||||
return { devices, fetch, requests };
|
||||
}
|
||||
|
||||
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> {
|
||||
await act(async () => {
|
||||
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> {
|
||||
const form = (required(testId) as HTMLButtonElement).form;
|
||||
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 = {}) {
|
||||
const platform = server(options);
|
||||
await act(async () => {
|
||||
@@ -180,6 +239,8 @@ async function renderPage(options: ServerOptions = {}) {
|
||||
config={{ apiBaseUrl, fetch: platform.fetch }}
|
||||
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
||||
now={() => NOW}
|
||||
pollMs={5}
|
||||
refreshMs={0}
|
||||
/>,
|
||||
);
|
||||
await settle();
|
||||
@@ -188,34 +249,61 @@ async function renderPage(options: ServerOptions = {}) {
|
||||
}
|
||||
|
||||
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;
|
||||
await renderPage();
|
||||
|
||||
const card = required("device-device-1");
|
||||
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("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");
|
||||
// How to set up a new screen comes first, in one place: the platform's own setup page on the
|
||||
// API origin, then Add display right under it, with the step-by-step version folded away.
|
||||
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
||||
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(setup.contains(required("add-display"))).toBe(true);
|
||||
const steps = required("display-setup-steps");
|
||||
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);
|
||||
expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false);
|
||||
// No pairing form until someone asks for one, and nothing grabbed focus.
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
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 () => {
|
||||
await renderPage({ devices: [] });
|
||||
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
||||
@@ -227,40 +315,37 @@ describe("Settings → Displays", () => {
|
||||
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();
|
||||
|
||||
await click("add-display");
|
||||
const dialog = required("add-display-dialog");
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
// Typing the code is now plainly the next thing to do, so the field has focus.
|
||||
expect(document.activeElement).toBe(required("device-pairing-code-input"));
|
||||
|
||||
await type("device-pairing-code-input", "999999");
|
||||
await submit("device-pairing-lookup");
|
||||
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
||||
|
||||
await type("device-pairing-code-input", MODULE_CODE);
|
||||
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(query("device-pairing-shows")).toBeNull();
|
||||
expect(document.activeElement).toBe(required("device-pairing-name"));
|
||||
|
||||
await type("device-pairing-name", "Sanctuary left");
|
||||
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(
|
||||
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(
|
||||
"Sanctuary left is paired and shows Signage.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
||||
// Focus is back on the control that opened the flow.
|
||||
expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect");
|
||||
|
||||
// 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"));
|
||||
});
|
||||
|
||||
@@ -295,15 +380,11 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
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: [] });
|
||||
|
||||
// The generic platform address, and nothing that names a module.
|
||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||
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 type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
@@ -320,40 +401,35 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
|
||||
expect(required("device-pairing-success").textContent).toContain(
|
||||
"Foyer TV 2 is paired. It shows its name until a module is set to show on it.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Not showing a module yet");
|
||||
await eventually(() => query("device-device-2") !== null);
|
||||
expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing 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();
|
||||
await click("add-display");
|
||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
|
||||
const shows = required("device-pairing-shows") as HTMLSelectElement;
|
||||
expect([...shows.options].map((option) => option.textContent)).toEqual([
|
||||
"Nothing yet",
|
||||
"Signage",
|
||||
]);
|
||||
expect(shows.value).toBe("");
|
||||
await choose("device-pairing-shows", "signage");
|
||||
const dialog = required("add-display-dialog");
|
||||
expect(dialog.querySelector("select")).toBeNull();
|
||||
const shows = required("device-pairing-shows");
|
||||
expect(shows.getAttribute("role")).toBe("combobox");
|
||||
expect(shows.textContent).toBe("Nothing yet");
|
||||
await choose("device-pairing-shows", "Signage");
|
||||
expect(shows.textContent).toBe("Signage");
|
||||
await type("device-pairing-name", "Lobby screen");
|
||||
await submit("device-pairing-approve");
|
||||
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
|
||||
expect(required("device-pairing-success").textContent).toContain(
|
||||
"Lobby screen is paired and shows Signage.",
|
||||
);
|
||||
expect(required("device-device-2").textContent).toContain("Shows Signage");
|
||||
await eventually(() => query("device-device-2") !== null);
|
||||
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
||||
});
|
||||
|
||||
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
|
||||
const platform = await renderPage({ modules: "fails" });
|
||||
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
||||
await click("add-display");
|
||||
await type("device-pairing-code-input", PLATFORM_CODE);
|
||||
await submit("device-pairing-lookup");
|
||||
@@ -363,8 +439,66 @@ describe("setting up a new screen (ADR 0065)", () => {
|
||||
expect(
|
||||
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
||||
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
|
||||
// Without the module list the card does not guess what the display shows.
|
||||
expect(required("device-device-1").textContent).not.toContain("showing");
|
||||
// Without the module list the card neither guesses what it shows nor offers to change it.
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
await click("device-revoke-device-1");
|
||||
await click("device-revoke-confirm");
|
||||
await act(async () => settle());
|
||||
|
||||
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(required("device-device-1").textContent).toContain("Revoked");
|
||||
expect(query("device-revoke-device-1")).toBeNull();
|
||||
// Gone from the active list without a reload, though the server keeps it as history.
|
||||
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