feat: make display setup actionable
This commit is contained in:
@@ -24,8 +24,12 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
|||||||
*/
|
*/
|
||||||
export function SignageDashboard({
|
export function SignageDashboard({
|
||||||
context,
|
context,
|
||||||
|
copyText,
|
||||||
|
displaySetupUrl,
|
||||||
}: {
|
}: {
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
|
readonly displaySetupUrl: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||||
@@ -135,6 +139,8 @@ export function SignageDashboard({
|
|||||||
<DisplaysPanel
|
<DisplaysPanel
|
||||||
api={api}
|
api={api}
|
||||||
canManage={mayManageDisplays}
|
canManage={mayManageDisplays}
|
||||||
|
copyText={copyText}
|
||||||
|
displaySetupUrl={displaySetupUrl}
|
||||||
onGroupsChanged={() => {
|
onGroupsChanged={() => {
|
||||||
setGroupId(undefined);
|
setGroupId(undefined);
|
||||||
groups.reload();
|
groups.reload();
|
||||||
|
|||||||
+13
-1
@@ -19,7 +19,19 @@ export default defineModuleUi({
|
|||||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(<SignageDashboard context={context} />);
|
const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href;
|
||||||
|
root.render(
|
||||||
|
<SignageDashboard
|
||||||
|
context={context}
|
||||||
|
copyText={async (value) => {
|
||||||
|
if (globalThis.navigator.clipboard === undefined) {
|
||||||
|
throw new Error("Clipboard access is not available in this browser.");
|
||||||
|
}
|
||||||
|
await globalThis.navigator.clipboard.writeText(value);
|
||||||
|
}}
|
||||||
|
displaySetupUrl={displaySetupUrl}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
return () => root.unmount();
|
return () => root.unmount();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+276
-199
@@ -1,17 +1,4 @@
|
|||||||
import {
|
import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
EmptyState,
|
|
||||||
Field,
|
|
||||||
Panel,
|
|
||||||
Select,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextInput,
|
|
||||||
} from "@ekklesia/ui";
|
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource, useTick } from "../hooks.js";
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
@@ -20,30 +7,93 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.
|
|||||||
|
|
||||||
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
|
function DisplayRow({
|
||||||
|
canManage,
|
||||||
|
groups,
|
||||||
|
nowMs,
|
||||||
|
onChannelChange,
|
||||||
|
onNameChange,
|
||||||
|
viewer,
|
||||||
|
}: {
|
||||||
|
readonly canManage: boolean;
|
||||||
|
readonly groups: readonly ChannelGroupView[];
|
||||||
|
readonly nowMs: number;
|
||||||
|
readonly onChannelChange: (channelId: string) => void;
|
||||||
|
readonly onNameChange: (name: string) => void;
|
||||||
|
readonly viewer: ViewerHealthView;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="signage-display-row" data-testid={`signage-display-${viewer.id}`}>
|
||||||
|
<div className="signage-display-row__identity">
|
||||||
|
{canManage ? (
|
||||||
|
<TextInput
|
||||||
|
aria-label={`Name for ${viewer.name}`}
|
||||||
|
defaultValue={viewer.name}
|
||||||
|
onBlur={(event) => event.target.value !== viewer.name && onNameChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<strong>{viewer.name}</strong>
|
||||||
|
)}
|
||||||
|
<OnlineBadge online={viewer.online} />
|
||||||
|
</div>
|
||||||
|
<div className="signage-display-row__assignment">
|
||||||
|
{canManage ? (
|
||||||
|
<Field label="Audience channel">
|
||||||
|
{(control) => (
|
||||||
|
<Select
|
||||||
|
{...control}
|
||||||
|
aria-label={`Channel for ${viewer.name}`}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
value={viewer.channelId ?? ""}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<optgroup key={group.id} label={group.name}>
|
||||||
|
{group.channels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{channel.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<div className="signage-display-row__health">
|
||||||
|
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
|
||||||
|
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||||
|
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"}
|
||||||
|
</Badge>
|
||||||
|
{viewer.lastError === null ? undefined : (
|
||||||
|
<span className="signage-display-row__error" title={viewer.lastError.detail ?? undefined}>
|
||||||
|
{viewer.lastError.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel groups, audience channels, and the display roster.
|
* Channel groups, audience channels, and displays, presented in the same hierarchy they use at run
|
||||||
*
|
* time: one group clock, one audience variant per channel, then the screens assigned to it.
|
||||||
* A church starts with one group and one default channel. `in-person` and `online` are created here,
|
|
||||||
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
|
|
||||||
* understand and ignore (ADR 0004).
|
|
||||||
*/
|
*/
|
||||||
export function DisplaysPanel({
|
export function DisplaysPanel({
|
||||||
api,
|
api,
|
||||||
canManage,
|
canManage,
|
||||||
|
copyText,
|
||||||
|
displaySetupUrl,
|
||||||
onGroupsChanged,
|
onGroupsChanged,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
|
readonly displaySetupUrl: string;
|
||||||
readonly onGroupsChanged?: () => void;
|
readonly onGroupsChanged?: () => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const groups = useResource(
|
const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []);
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
const viewers = useResource(
|
|
||||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const edit = useAction(() => {
|
const edit = useAction(() => {
|
||||||
groups.reload();
|
groups.reload();
|
||||||
viewers.reload();
|
viewers.reload();
|
||||||
@@ -51,30 +101,87 @@ export function DisplaysPanel({
|
|||||||
});
|
});
|
||||||
const viewerEdit = useAction(viewers.reload);
|
const viewerEdit = useAction(viewers.reload);
|
||||||
const [groupName, setGroupName] = useState("");
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle");
|
||||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
const [channelDraft, setChannelDraft] = useState<{
|
||||||
|
groupId: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
groupId: "",
|
groupId: "",
|
||||||
key: "",
|
key: "",
|
||||||
name: "",
|
name: "",
|
||||||
});
|
});
|
||||||
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
|
|
||||||
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
|
|
||||||
useTick(5000);
|
useTick(5000);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : [];
|
||||||
|
|
||||||
const channels =
|
const updateViewer = (viewerId: string, changes: Record<string, string>): void => {
|
||||||
groups.state.status === "ready"
|
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
||||||
? groups.state.value.groups.flatMap((group) =>
|
};
|
||||||
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signage-columns">
|
<div className="signage-columns">
|
||||||
|
<Panel
|
||||||
|
actions={
|
||||||
|
<div className="signage-pairing-actions">
|
||||||
|
<a
|
||||||
|
className="ek-button ek-focusable ek-button--primary"
|
||||||
|
href={displaySetupUrl}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open display setup
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCopyStatus("idle");
|
||||||
|
void copyText(displaySetupUrl).then(
|
||||||
|
() => setCopyStatus("copied"),
|
||||||
|
() => setCopyStatus("failed"),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description="Connect a screen in three short steps."
|
||||||
|
title="Pair a display"
|
||||||
|
>
|
||||||
|
<ol className="signage-pairing-steps" aria-label="Display pairing steps">
|
||||||
|
<li>
|
||||||
|
<span className="signage-pairing-steps__number">1</span>
|
||||||
|
<span>Open display setup on the screen that will show signage.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="signage-pairing-steps__number">2</span>
|
||||||
|
<span>Enter its six-digit code under Display devices in the Dashboard sidebar.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="signage-pairing-steps__number">3</span>
|
||||||
|
<span>Name the display and choose its audience channel below.</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<span className="signage-copy-status" aria-live="polite">
|
||||||
|
{copyStatus === "copied"
|
||||||
|
? "Display setup URL copied."
|
||||||
|
: copyStatus === "failed"
|
||||||
|
? "Could not copy the URL. Copy it from the newly opened setup page."
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
|
||||||
|
<div className="signage-display-empty">
|
||||||
|
<strong>No displays paired yet</strong>
|
||||||
|
<span>A newly approved screen appears below in its assigned channel.</span>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<div className="signage-inline-form">
|
<div className="signage-inline-form signage-new-group">
|
||||||
<Field label="New channel group">
|
<Field label="New channel group">
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -89,46 +196,54 @@ export function DisplaysPanel({
|
|||||||
disabled={edit.pending || groupName.trim().length === 0}
|
disabled={edit.pending || groupName.trim().length === 0}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(async () => {
|
edit.run(async () => {
|
||||||
await api.send("POST", "channel-groups", { name: groupName.trim() });
|
await api.send("POST", "channel-groups", {
|
||||||
|
name: groupName.trim(),
|
||||||
|
});
|
||||||
setGroupName("");
|
setGroupName("");
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
Create
|
Create group
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
description="A group is one clock. An audience channel is one rendering of it."
|
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
|
||||||
title="Channel groups"
|
title="Channel groups and displays"
|
||||||
>
|
>
|
||||||
{renderLoadable(
|
{renderLoadable(
|
||||||
groups.state,
|
groups.state,
|
||||||
(value) => (
|
(value) => {
|
||||||
|
const knownChannelIds = new Set(
|
||||||
|
value.groups.flatMap((group) => group.channels.map((channel) => channel.id)),
|
||||||
|
);
|
||||||
|
const unassigned = readyViewers.filter(
|
||||||
|
(viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId),
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div className="signage-groups" data-testid="signage-groups">
|
<div className="signage-groups" data-testid="signage-groups">
|
||||||
{value.groups.map((group) => (
|
{value.groups.map((group) => (
|
||||||
<div className="signage-group" key={group.id}>
|
<article className="signage-group" key={group.id}>
|
||||||
<div className="signage-group__header">
|
<header className="signage-group__header">
|
||||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
<div className="signage-group__identity">
|
||||||
|
<span className="signage-hierarchy-label">Channel group</span>
|
||||||
|
<h3>{group.name}</h3>
|
||||||
|
</div>
|
||||||
<Badge tone="info">{group.playback.transport}</Badge>
|
<Badge tone="info">{group.playback.transport}</Badge>
|
||||||
{canManage && value.groups.length > 1 ? (
|
{canManage && value.groups.length > 1 ? (
|
||||||
<Button
|
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(group.id)} variant="danger">
|
||||||
disabled={edit.pending}
|
|
||||||
onClick={() => setConfirmGroupId(group.id)}
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
Delete group
|
Delete group
|
||||||
</Button>
|
</Button>
|
||||||
) : canManage ? (
|
) : canManage ? (
|
||||||
<Badge tone="neutral">At least one group required</Badge>
|
<Badge tone="neutral">Required group</Badge>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</header>
|
||||||
{confirmGroupId === group.id ? (
|
{confirmGroupId === group.id ? (
|
||||||
<div className="signage-confirm" role="alert">
|
<div className="signage-confirm" role="alert">
|
||||||
<p>
|
<p>
|
||||||
Delete {group.name}? Its playback history will be removed. Displays assigned
|
Delete {group.name}? Its playback history will be removed. Displays assigned here will move to
|
||||||
here will move to the default channel of another group.
|
the default channel of another group.
|
||||||
</p>
|
</p>
|
||||||
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -147,32 +262,91 @@ export function DisplaysPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<ul className="ek-list">
|
|
||||||
{group.channels.map((channel) => (
|
<section className="signage-audiences">
|
||||||
<li className="ek-list__item signage-row" key={channel.id}>
|
<div className="signage-audiences__heading">
|
||||||
<span className="signage-row__name">{channel.name}</span>
|
<div>
|
||||||
<Badge tone={channel.isDefault ? "success" : "neutral"}>
|
<span className="signage-hierarchy-label">Audience channels</span>
|
||||||
{channel.key}
|
<h4>Who this group renders for</h4>
|
||||||
|
</div>
|
||||||
|
<span>Displays inherit the channel they are assigned to.</span>
|
||||||
|
</div>
|
||||||
|
<div className="signage-channel-list">
|
||||||
|
{group.channels.map((channel) => {
|
||||||
|
const assigned = readyViewers.filter((viewer) => viewer.channelId === channel.id);
|
||||||
|
return (
|
||||||
|
<article className="signage-channel" key={channel.id}>
|
||||||
|
<header className="signage-channel__header">
|
||||||
|
<div>
|
||||||
|
<h5>{channel.name}</h5>
|
||||||
|
<div className="signage-channel__meta">
|
||||||
|
{channel.isDefault ? <Badge tone="success">Default channel</Badge> : undefined}
|
||||||
|
<span>Audience key</span>
|
||||||
|
<code>{channel.key}</code>
|
||||||
|
<Badge tone="neutral">
|
||||||
|
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{canManage && !channel.isDefault ? (
|
{canManage && !channel.isDefault ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() => edit.run(() => api.send("DELETE", `channels/${channel.id}`))}
|
||||||
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
|
||||||
}
|
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Remove
|
Remove channel
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</li>
|
</header>
|
||||||
))}
|
<div className="signage-channel__displays">
|
||||||
</ul>
|
<span className="signage-hierarchy-label">Displays</span>
|
||||||
|
{assigned.length === 0 ? (
|
||||||
|
<p className="signage-channel__empty">No displays assigned.</p>
|
||||||
|
) : (
|
||||||
|
assigned.map((viewer) => (
|
||||||
|
<DisplayRow
|
||||||
|
canManage={canManage}
|
||||||
|
groups={value.groups}
|
||||||
|
key={viewer.id}
|
||||||
|
nowMs={nowMs}
|
||||||
|
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
|
||||||
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
||||||
|
viewer={viewer}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
<div className="signage-channel-form" data-testid="signage-add-channel">
|
||||||
|
<div className="signage-channel-form__heading">
|
||||||
|
<h5>Add an audience channel</h5>
|
||||||
|
<span>The default channel always remains available.</span>
|
||||||
|
</div>
|
||||||
|
<Field hint="Shown to administrators and when assigning displays." label="Channel name">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannelDraft((current) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
key: current.groupId === group.id ? current.key : "",
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Online audience"
|
||||||
|
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
|
hint="Advanced: lowercase ID sent to content providers, e.g. online."
|
||||||
label="Provider audience key"
|
label="Audience key"
|
||||||
>
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -191,26 +365,6 @@ export function DisplaysPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
|
||||||
hint="Friendly label shown to administrators and used when assigning displays."
|
|
||||||
label="Dashboard channel name"
|
|
||||||
>
|
|
||||||
{(control) => (
|
|
||||||
<TextInput
|
|
||||||
{...control}
|
|
||||||
maxLength={120}
|
|
||||||
onChange={(event) =>
|
|
||||||
setChannelDraft((current) => ({
|
|
||||||
groupId: group.id,
|
|
||||||
key: current.groupId === group.id ? current.key : "",
|
|
||||||
name: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Online audience"
|
|
||||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
edit.pending ||
|
edit.pending ||
|
||||||
@@ -225,125 +379,48 @@ export function DisplaysPanel({
|
|||||||
key: channelDraft.key.trim(),
|
key: channelDraft.key.trim(),
|
||||||
name: channelDraft.name.trim(),
|
name: channelDraft.name.trim(),
|
||||||
});
|
});
|
||||||
setChannelDraft({ groupId: "", key: "", name: "" });
|
setChannelDraft({
|
||||||
|
groupId: "",
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
variant="primary"
|
||||||
>
|
>
|
||||||
Add audience channel
|
Add channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</section>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{unassigned.length === 0 ? undefined : (
|
||||||
|
<section className="signage-unassigned">
|
||||||
|
<span className="signage-hierarchy-label">Needs assignment</span>
|
||||||
|
{unassigned.map((viewer) => (
|
||||||
|
<DisplayRow
|
||||||
|
canManage={canManage}
|
||||||
|
groups={value.groups}
|
||||||
|
key={viewer.id}
|
||||||
|
nowMs={nowMs}
|
||||||
|
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
|
||||||
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
||||||
|
viewer={viewer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
{ label: "Loading channel groups…", onRetry: groups.reload },
|
{ label: "Loading channel groups…", onRetry: groups.reload },
|
||||||
)}
|
)}
|
||||||
</Panel>
|
{renderLoadable(viewers.state, () => null, {
|
||||||
|
label: "Loading displays…",
|
||||||
<Panel
|
onRetry: viewers.reload,
|
||||||
description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows."
|
})}
|
||||||
title="Displays"
|
|
||||||
>
|
|
||||||
{canManage ? (
|
|
||||||
<div className="signage-registration-guide">
|
|
||||||
<h4 className="signage-editor__subtitle">Register a display</h4>
|
|
||||||
<ol>
|
|
||||||
<li>Open the Ekklesia path /display/signage on the screen.</li>
|
|
||||||
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
|
|
||||||
<li>When it appears below, give it a clear name and assign its channel.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
{renderLoadable(
|
|
||||||
viewers.state,
|
|
||||||
(value) =>
|
|
||||||
value.viewers.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
|
|
||||||
title="No displays yet"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Table label="Display health">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell header>Name</TableCell>
|
|
||||||
<TableCell header>State</TableCell>
|
|
||||||
<TableCell header>Channel</TableCell>
|
|
||||||
<TableCell header>Last seen</TableCell>
|
|
||||||
<TableCell header>Offline ready</TableCell>
|
|
||||||
<TableCell header>Most recent error</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{value.viewers.map((viewer) => (
|
|
||||||
<TableRow key={viewer.id}>
|
|
||||||
<TableCell>
|
|
||||||
{canManage ? (
|
|
||||||
<TextInput
|
|
||||||
aria-label={`Name for ${viewer.name}`}
|
|
||||||
defaultValue={viewer.name}
|
|
||||||
onBlur={(event) =>
|
|
||||||
event.target.value !== viewer.name &&
|
|
||||||
viewerEdit.run(() =>
|
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
|
||||||
name: event.target.value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
viewer.name
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<OnlineBadge online={viewer.online} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{canManage ? (
|
|
||||||
<Select
|
|
||||||
aria-label={`Channel for ${viewer.name}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
viewerEdit.run(() =>
|
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
|
||||||
channelId: event.target.value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
value={viewer.channelId ?? ""}
|
|
||||||
>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<option key={channel.id} value={channel.id}>
|
|
||||||
{`${channel.groupName} · ${channel.name}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
|
||||||
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{viewer.lastError === null ? (
|
|
||||||
"—"
|
|
||||||
) : (
|
|
||||||
<span title={viewer.lastError.detail ?? undefined}>
|
|
||||||
{viewer.lastError.code}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
),
|
|
||||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
|
||||||
)}
|
|
||||||
<FailureNote failure={edit.failure} />
|
<FailureNote failure={edit.failure} />
|
||||||
<FailureNote failure={viewerEdit.failure} />
|
<FailureNote failure={viewerEdit.failure} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
+240
-10
@@ -335,24 +335,112 @@
|
|||||||
gap: var(--signage-gap);
|
gap: var(--signage-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-group {
|
.signage-pairing-steps {
|
||||||
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
|
||||||
border-radius: var(--ekklesia-radius-md);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-pairing-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-pairing-actions a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-copy-status {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
display: block;
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
min-height: 1.25em;
|
||||||
|
margin-block-start: var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-pairing-steps li {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
min-width: 0;
|
||||||
padding: var(--ekklesia-spacing-md);
|
padding: var(--ekklesia-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signage-pairing-steps__number {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--ekklesia-accent-base);
|
||||||
|
border-radius: var(--ekklesia-radius-pill);
|
||||||
|
color: var(--ekklesia-accent-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 var(--ekklesia-control-height-sm);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-bold);
|
||||||
|
height: var(--ekklesia-control-height-sm);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-empty {
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight);
|
||||||
|
margin-block-start: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-empty strong {
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-new-group > .ek-field {
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-group {
|
||||||
|
background: var(--ekklesia-background-canvas);
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
border-radius: var(--signage-panel-radius);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
padding: var(--ekklesia-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.signage-group__header {
|
.signage-group__header {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-group__header .ek-button:first-of-type {
|
.signage-group__header > .ek-button {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signage-group__identity {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-group__identity h3,
|
||||||
|
.signage-audiences__heading h4,
|
||||||
|
.signage-channel__header h5,
|
||||||
|
.signage-channel-form__heading h5 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-hierarchy-label {
|
||||||
|
color: var(--ekklesia-text-muted);
|
||||||
|
font-size: var(--ekklesia-typography-size-xs);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-medium);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.signage-confirm {
|
.signage-confirm {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--ekklesia-status-danger-background);
|
background: var(--ekklesia-status-danger-background);
|
||||||
@@ -369,18 +457,160 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-registration-guide {
|
.signage-audiences {
|
||||||
background: var(--ekklesia-background-inset);
|
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
padding-block-start: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-audiences__heading {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-audiences__heading > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-audiences__heading > span {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel {
|
||||||
|
background: var(--ekklesia-background-surface);
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base);
|
||||||
border-radius: var(--ekklesia-radius-md);
|
border-radius: var(--ekklesia-radius-md);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--ekklesia-spacing-xs);
|
gap: var(--signage-gap-tight);
|
||||||
margin-block-end: var(--signage-gap);
|
margin-inline-start: var(--ekklesia-spacing-sm);
|
||||||
padding: var(--ekklesia-spacing-md);
|
padding: var(--ekklesia-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-registration-guide ol {
|
.signage-channel__header {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel__header > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel__meta {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel__meta code,
|
||||||
|
.signage-pairing-steps code {
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-sm);
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel__displays {
|
||||||
|
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
padding-block-start: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel__empty {
|
||||||
|
color: var(--ekklesia-text-muted);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-inline-start: var(--ekklesia-spacing-xl);
|
}
|
||||||
|
|
||||||
|
.signage-display-row {
|
||||||
|
align-items: end;
|
||||||
|
background: var(--ekklesia-background-canvas);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr);
|
||||||
|
padding: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-row__identity,
|
||||||
|
.signage-display-row__health {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-row__identity .ek-control,
|
||||||
|
.signage-display-row__assignment .ek-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-row__health {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display-row__error {
|
||||||
|
color: var(--ekklesia-text-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form,
|
||||||
|
.signage-unassigned {
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
margin-inline-start: var(--ekklesia-spacing-sm);
|
||||||
|
padding: var(--ekklesia-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form {
|
||||||
|
align-items: start;
|
||||||
|
grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form__heading,
|
||||||
|
.signage-channel-form > .ek-button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form__heading {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form__heading span {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-channel-form > .ek-button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.signage-pairing-steps,
|
||||||
|
.signage-channel-form,
|
||||||
|
.signage-display-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signage-provider__id {
|
.signage-provider__id {
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su
|
|||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage";
|
||||||
|
const copiedText: string[] = [];
|
||||||
|
|
||||||
|
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<SignageDashboard
|
||||||
|
context={context}
|
||||||
|
copyText={(value) => {
|
||||||
|
copiedText.push(value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}}
|
||||||
|
displaySetupUrl={DISPLAY_SETUP_URL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function render(element: React.ReactElement): Promise<void> {
|
async function render(element: React.ReactElement): Promise<void> {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -131,6 +146,7 @@ const TIMELINE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
copiedText.length = 0;
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
@@ -168,7 +184,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
|
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
|
||||||
"welcome",
|
"welcome",
|
||||||
@@ -197,7 +213,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
||||||
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
|
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
|
||||||
@@ -220,7 +236,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
expect(container.textContent).toContain("Active: Sunday morning");
|
expect(container.textContent).toContain("Active: Sunday morning");
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
@@ -262,7 +278,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(candidate) => candidate.textContent === "Timelines",
|
(candidate) => candidate.textContent === "Timelines",
|
||||||
@@ -293,7 +309,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(tab) => tab.textContent === "Timelines",
|
(tab) => tab.textContent === "Timelines",
|
||||||
@@ -357,7 +373,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(tab) => tab.textContent === "Timelines",
|
(tab) => tab.textContent === "Timelines",
|
||||||
@@ -407,7 +423,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(tab) => tab.textContent === "Providers",
|
(tab) => tab.textContent === "Providers",
|
||||||
@@ -430,7 +446,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(tab) => tab.textContent === "Providers",
|
(tab) => tab.textContent === "Providers",
|
||||||
@@ -476,13 +492,15 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.textContent).toContain("Online");
|
expect(container.textContent).toContain("Online");
|
||||||
expect(container.textContent).toContain("12 cached");
|
expect(container.textContent).toContain("12 cached");
|
||||||
|
const display = container.querySelector("[data-testid='signage-display-viewer-1']");
|
||||||
|
expect(display?.closest(".signage-channel")?.textContent).toContain("Default");
|
||||||
// A sanitized code, never a stack or a message a browser happened to have.
|
// A sanitized code, never a stack or a message a browser happened to have.
|
||||||
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
||||||
});
|
});
|
||||||
@@ -521,15 +539,27 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find(
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
(candidate) => candidate.textContent === "Displays",
|
(candidate) => candidate.textContent === "Displays",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.textContent).toContain("/display/signage");
|
const setupLink = [...container.querySelectorAll("a")].find(
|
||||||
expect(container.textContent).toContain("Provider audience key");
|
(candidate) => candidate.textContent?.trim() === "Open display setup",
|
||||||
|
);
|
||||||
|
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
|
||||||
|
expect(setupLink?.target).toBe("_blank");
|
||||||
|
expect(setupLink?.rel).toContain("noopener");
|
||||||
|
await click(buttonNamed("Copy URL"));
|
||||||
|
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
|
||||||
|
expect(container.textContent).toContain("Display setup URL copied.");
|
||||||
|
expect(container.textContent).toContain("Channel groups and displays");
|
||||||
|
expect(container.textContent).toContain("Audience channels");
|
||||||
|
expect(container.textContent).toContain("Channel name");
|
||||||
|
expect(container.textContent).toContain("Audience key");
|
||||||
|
expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers");
|
||||||
await click(buttonNamed("Delete group"));
|
await click(buttonNamed("Delete group"));
|
||||||
expect(deleted).toEqual([]);
|
expect(deleted).toEqual([]);
|
||||||
expect(container.textContent).toContain("Displays assigned here will move");
|
expect(container.textContent).toContain("Displays assigned here will move");
|
||||||
@@ -564,7 +594,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
await click(
|
await click(
|
||||||
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
|
||||||
);
|
);
|
||||||
@@ -615,7 +645,7 @@ describe("Signage dashboard", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await render(<SignageDashboard context={host.context} />);
|
await render(dashboard(host.context));
|
||||||
|
|
||||||
expect(container.textContent).toContain("Signage could not load this");
|
expect(container.textContent).toContain("Signage could not load this");
|
||||||
expect(container.textContent).toContain("The operation could not be completed.");
|
expect(container.textContent).toContain("The operation could not be completed.");
|
||||||
|
|||||||
Reference in New Issue
Block a user