fix(ui): keep Signage state consistent across tabs, and confirm destructive actions
Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
67d89f1020
commit
7c17282616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js";
|
||||
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
||||
import {
|
||||
createFakeHost,
|
||||
FAKE_SETTINGS,
|
||||
type FakeHostRoute,
|
||||
jsonResponse,
|
||||
} from "./support/fake-host.js";
|
||||
|
||||
/**
|
||||
* Stage 0 production-readiness regressions for the management screen.
|
||||
*
|
||||
* Each case is a defect an operator met: an activation that looked lost after changing tabs, a
|
||||
* screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an
|
||||
* internal key presented as something to fill in, and test displays nobody could remove.
|
||||
*/
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function click(element: Element | null | undefined): Promise<void> {
|
||||
if (!element) {
|
||||
throw new Error("Expected an element to click.");
|
||||
}
|
||||
await act(async () => (element as HTMLElement).click());
|
||||
await settle();
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement | undefined {
|
||||
return [...container.querySelectorAll("button")].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function tab(label: string): Element | undefined {
|
||||
return [...container.querySelectorAll("[role='tab']")].find(
|
||||
(candidate) => candidate.textContent === label,
|
||||
);
|
||||
}
|
||||
|
||||
async function choose(select: HTMLSelectElement, value: string): Promise<void> {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||
await act(async () => {
|
||||
setter?.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function timeline(id: string, name: string) {
|
||||
return {
|
||||
description: null,
|
||||
eligibleFrom: null,
|
||||
eligibleUntil: null,
|
||||
id,
|
||||
name,
|
||||
revision: 1,
|
||||
updatedAt: "2026-10-01T15:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function group(activeTimelineId: string | null) {
|
||||
return {
|
||||
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
|
||||
id: "group-1",
|
||||
name: "Main",
|
||||
playback: {
|
||||
activeTimelineId,
|
||||
blackout: false,
|
||||
currentSelectionId: null,
|
||||
currentSlotId: null,
|
||||
deadlineAtMs: null,
|
||||
groupId: "group-1",
|
||||
pendingTimelineId: null,
|
||||
remainingMs: null,
|
||||
revision: activeTimelineId === null ? 1 : 2,
|
||||
scheduledActivationAtMs: null,
|
||||
scheduledTimelineId: null,
|
||||
selectionRevision: null,
|
||||
serverNowMs: 1_700_000_000_000,
|
||||
startedAtMs: null,
|
||||
staticAssetId: null,
|
||||
transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const),
|
||||
},
|
||||
revision: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** A small, stateful Signage backend: activation really changes what later reads return. */
|
||||
function backend(extra: readonly FakeHostRoute[] = []) {
|
||||
let active: string | null = null;
|
||||
const sent: string[] = [];
|
||||
const routes: FakeHostRoute[] = [
|
||||
...extra,
|
||||
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||
{
|
||||
method: "GET",
|
||||
path: "channel-groups",
|
||||
respond: () => jsonResponse({ groups: [group(active)] }),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "timelines",
|
||||
respond: () =>
|
||||
jsonResponse({
|
||||
timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")],
|
||||
}),
|
||||
},
|
||||
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
|
||||
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
|
||||
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
|
||||
{
|
||||
method: "GET",
|
||||
path: "playback/group-1",
|
||||
respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }),
|
||||
},
|
||||
{ method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "playback/group-1/activate",
|
||||
respond: ({ init }) => {
|
||||
active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId;
|
||||
sent.push(`activate ${active}`);
|
||||
return jsonResponse(group(active).playback);
|
||||
},
|
||||
},
|
||||
];
|
||||
return { routes, sent };
|
||||
}
|
||||
|
||||
function mount(host: ReturnType<typeof createFakeHost>) {
|
||||
return act(async () => {
|
||||
root.render(<SignageDashboard context={host.context} copyText={() => Promise.resolve()} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Signage dashboard — Stage 0 regressions", () => {
|
||||
it("shows a timeline activated on Playback as active on Timelines too", async () => {
|
||||
const server = backend();
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"],
|
||||
routes: server.routes,
|
||||
});
|
||||
await mount(host);
|
||||
await settle();
|
||||
|
||||
const activate = [...container.querySelectorAll("select")].find((select) =>
|
||||
[...select.options].some((option) => option.textContent === "Choose a timeline…"),
|
||||
) as HTMLSelectElement;
|
||||
await choose(activate, "timeline-2");
|
||||
await click(button("Now"));
|
||||
expect(server.sent).toEqual(["activate timeline-2"]);
|
||||
|
||||
await click(tab("Timelines"));
|
||||
const row = [...container.querySelectorAll("tr")].find((candidate) =>
|
||||
candidate.textContent?.includes("Easter"),
|
||||
);
|
||||
expect(row?.textContent).toContain("Active in Main");
|
||||
expect(container.querySelector("tr")?.parentElement).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => {
|
||||
let slow = false;
|
||||
const server = backend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "timelines",
|
||||
respond: () =>
|
||||
slow
|
||||
? new Promise<Response>(() => undefined)
|
||||
: jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }),
|
||||
},
|
||||
]);
|
||||
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
|
||||
await mount(host);
|
||||
await settle();
|
||||
await click(tab("Timelines"));
|
||||
expect(container.textContent).toContain("Sunday");
|
||||
|
||||
// The next load never finishes. Coming back must still show the last known list.
|
||||
slow = true;
|
||||
await click(tab("Images"));
|
||||
await click(tab("Timelines"));
|
||||
expect(container.textContent).toContain("Sunday");
|
||||
expect(container.textContent).not.toContain("Loading timelines…");
|
||||
});
|
||||
|
||||
it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => {
|
||||
const deleted: string[] = [];
|
||||
const image = {
|
||||
byteSize: 1000,
|
||||
contentType: "image/png",
|
||||
createdAt: "2026-10-01T15:00:00.000Z",
|
||||
displayName: "Welcome",
|
||||
expiresAt: null,
|
||||
height: 1080,
|
||||
id: "image-1",
|
||||
originalFilename: "welcome.png",
|
||||
renditions: [],
|
||||
status: "ready",
|
||||
width: 1920,
|
||||
};
|
||||
const server = backend([
|
||||
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) },
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "images/image-1",
|
||||
respond: ({ path }) => {
|
||||
deleted.push(path);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
]);
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.view", "signage.assets.manage"],
|
||||
routes: server.routes,
|
||||
});
|
||||
await mount(host);
|
||||
await settle();
|
||||
await click(tab("Images"));
|
||||
|
||||
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
|
||||
expect(deleted).toEqual([]);
|
||||
expect(button("Delete this image")).toBeDefined();
|
||||
await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']"));
|
||||
expect(button("Delete this image")).toBeUndefined();
|
||||
expect(deleted).toEqual([]);
|
||||
|
||||
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
|
||||
await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']"));
|
||||
expect(deleted).toEqual(["images/image-1"]);
|
||||
});
|
||||
|
||||
it("adds an audience from its name alone, deriving the content key", async () => {
|
||||
const posted: unknown[] = [];
|
||||
const server = backend([
|
||||
{
|
||||
method: "POST",
|
||||
path: "channel-groups/group-1/channels",
|
||||
respond: ({ init }) => {
|
||||
posted.push(JSON.parse(String(init?.body)));
|
||||
return jsonResponse({}, 201);
|
||||
},
|
||||
},
|
||||
]);
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.view", "signage.displays.manage"],
|
||||
routes: server.routes,
|
||||
});
|
||||
await mount(host);
|
||||
await settle();
|
||||
await click(tab("Displays"));
|
||||
|
||||
const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement;
|
||||
await type(name, "Watching Online");
|
||||
await click(button("Add audience"));
|
||||
expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]);
|
||||
});
|
||||
|
||||
it("labels a signed-in browser and removes an offline entry only after confirmation", async () => {
|
||||
const removed: string[] = [];
|
||||
const viewer = {
|
||||
cachedAssetCount: 0,
|
||||
channelId: "chan-1",
|
||||
deviceId: null,
|
||||
id: "viewer-1",
|
||||
lastError: null,
|
||||
lastSeenAtMs: Date.now() - 600_000,
|
||||
name: "New display",
|
||||
offlineReady: false,
|
||||
online: false,
|
||||
reportedContributionId: null,
|
||||
reportedProviderId: null,
|
||||
reportedSelectionId: null,
|
||||
reportedTimelineId: null,
|
||||
};
|
||||
const server = backend([
|
||||
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) },
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "viewers/viewer-1",
|
||||
respond: ({ path }) => {
|
||||
removed.push(path);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
]);
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.view", "signage.displays.manage"],
|
||||
routes: server.routes,
|
||||
});
|
||||
await mount(host);
|
||||
await settle();
|
||||
await click(tab("Displays"));
|
||||
|
||||
expect(container.textContent).toContain("Signed-in browser");
|
||||
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']"));
|
||||
expect(removed).toEqual([]);
|
||||
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']"));
|
||||
expect(removed).toEqual(["viewers/viewer-1"]);
|
||||
});
|
||||
|
||||
it("follows playback changes on the management channel, and stops when Playback is left", async () => {
|
||||
const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] =
|
||||
[];
|
||||
const server = backend();
|
||||
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
|
||||
Object.assign(host.context.realtime, {
|
||||
subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) {
|
||||
const entry = { closed: false, onUpdate: handlers.onUpdate };
|
||||
if (channel === "management-state") {
|
||||
subscriptions.push(entry);
|
||||
}
|
||||
return { close: () => (entry.closed = true) };
|
||||
},
|
||||
});
|
||||
await mount(host);
|
||||
await settle();
|
||||
const detailReads = () =>
|
||||
host.requests.filter((request) => request.path === "playback/group-1").length;
|
||||
const before = detailReads();
|
||||
|
||||
const live = subscriptions.find((entry) => !entry.closed);
|
||||
await act(async () => {
|
||||
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] });
|
||||
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] });
|
||||
});
|
||||
await settle();
|
||||
expect(detailReads()).toBeGreaterThan(before);
|
||||
|
||||
await click(tab("Images"));
|
||||
expect(subscriptions.every((entry) => entry.closed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audience content keys", () => {
|
||||
it("derives a valid, unique key that is never the reserved default", () => {
|
||||
expect(audienceKeyFor("Online", [])).toBe("online");
|
||||
expect(audienceKeyFor("Online", ["online"])).toBe("online-2");
|
||||
expect(audienceKeyFor("Default", [])).toBe("default-2");
|
||||
expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service");
|
||||
expect(audienceKeyFor(" ", [])).toBe("audience");
|
||||
expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room");
|
||||
});
|
||||
});
|
||||
|
||||
/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */
|
||||
describe("product rules in the browser source", () => {
|
||||
function sources(directory: string): string[] {
|
||||
return readdirSync(directory).flatMap((entry) => {
|
||||
const path = join(directory, entry);
|
||||
if (statSync(path).isDirectory()) {
|
||||
return sources(path);
|
||||
}
|
||||
return /\.(ts|tsx)$/u.test(entry) ? [path] : [];
|
||||
});
|
||||
}
|
||||
const files = ["src/ui", "src/browser", "src/display"].flatMap(sources);
|
||||
|
||||
it("never uses a browser-native alert, confirm, or prompt", () => {
|
||||
for (const file of files) {
|
||||
expect(readFileSync(file, "utf8"), file).not.toMatch(
|
||||
/\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never puts the platform's name in user-facing text", () => {
|
||||
for (const file of files) {
|
||||
const code = readFileSync(file, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
||||
.replace(/^\s*\/\/.*$/gmu, "")
|
||||
.replace(/\{\/\*[\s\S]*?\*\/\}/gu, "");
|
||||
// Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text.
|
||||
expect(code, file).not.toMatch(
|
||||
/["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+103
-113
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
ModuleContributionDescriptor,
|
||||
ModuleUiApiClient,
|
||||
ModuleUiContributions,
|
||||
ModuleUiHostContext,
|
||||
MountedModuleContribution,
|
||||
MountModuleContributionOptions,
|
||||
ModuleContributionDescriptor,
|
||||
ModuleUiApiClient,
|
||||
ModuleUiContributions,
|
||||
ModuleUiHostContext,
|
||||
MountedModuleContribution,
|
||||
MountModuleContributionOptions,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
@@ -15,132 +15,122 @@ import type {
|
||||
* simply never match.
|
||||
*/
|
||||
export interface FakeHostRoute {
|
||||
readonly method: string;
|
||||
readonly path: string | RegExp;
|
||||
readonly respond: (request: {
|
||||
readonly path: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}) => Response | Promise<Response>;
|
||||
readonly method: string;
|
||||
readonly path: string | RegExp;
|
||||
readonly respond: (request: {
|
||||
readonly path: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface FakeHost {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly requests: { method: string; path: string }[];
|
||||
/** Contributions this host published against Signage's renderer capability. */
|
||||
readonly mounted: { contributionId: string; input: unknown }[];
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly requests: { method: string; path: string }[];
|
||||
/** Contributions this host published against Signage's renderer capability. */
|
||||
readonly mounted: { contributionId: string; input: unknown }[];
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 400,
|
||||
): Response {
|
||||
return jsonResponse({ code, message }, status);
|
||||
export function errorResponse(code: string, message: string, status = 400): Response {
|
||||
return jsonResponse({ code, message }, status);
|
||||
}
|
||||
|
||||
export interface FakeHostOptions {
|
||||
readonly permissions: readonly string[];
|
||||
readonly routes: readonly FakeHostRoute[];
|
||||
readonly contributions?: readonly ModuleContributionDescriptor[];
|
||||
readonly displayUrl?: string;
|
||||
readonly principal?: ModuleUiHostContext["principal"];
|
||||
readonly mount?: (
|
||||
options: MountModuleContributionOptions,
|
||||
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
||||
readonly permissions: readonly string[];
|
||||
readonly routes: readonly FakeHostRoute[];
|
||||
readonly contributions?: readonly ModuleContributionDescriptor[];
|
||||
readonly displayUrl?: string;
|
||||
readonly principal?: ModuleUiHostContext["principal"];
|
||||
readonly mount?: (
|
||||
options: MountModuleContributionOptions,
|
||||
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
||||
}
|
||||
|
||||
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
||||
const requests: { method: string; path: string }[] = [];
|
||||
const mounted: { contributionId: string; input: unknown }[] = [];
|
||||
const requests: { method: string; path: string }[] = [];
|
||||
const mounted: { contributionId: string; input: unknown }[] = [];
|
||||
|
||||
const api: ModuleUiApiClient = {
|
||||
async request(path, init) {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const normalized = path.replace(/^\//u, "");
|
||||
requests.push({ method, path: normalized });
|
||||
for (const route of options.routes) {
|
||||
const matches =
|
||||
typeof route.path === "string"
|
||||
? route.path === normalized.split("?")[0]
|
||||
: route.path.test(normalized);
|
||||
if (route.method === method && matches) {
|
||||
return route.respond({ init, path: normalized });
|
||||
}
|
||||
}
|
||||
return errorResponse(
|
||||
"SIGNAGE_NOT_FOUND",
|
||||
"The requested resource was not found.",
|
||||
404,
|
||||
);
|
||||
},
|
||||
};
|
||||
const api: ModuleUiApiClient = {
|
||||
async request(path, init) {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const normalized = path.replace(/^\//u, "");
|
||||
requests.push({ method, path: normalized });
|
||||
for (const route of options.routes) {
|
||||
const matches =
|
||||
typeof route.path === "string"
|
||||
? route.path === normalized.split("?")[0]
|
||||
: route.path.test(normalized);
|
||||
if (route.method === method && matches) {
|
||||
return route.respond({ init, path: normalized });
|
||||
}
|
||||
}
|
||||
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
||||
},
|
||||
};
|
||||
|
||||
const contributions: ModuleUiContributions = {
|
||||
list: (capabilityId) =>
|
||||
capabilityId === "signage.slide-renderer"
|
||||
? (options.contributions ?? [])
|
||||
: [],
|
||||
async mount(mountOptions) {
|
||||
mounted.push({
|
||||
contributionId: mountOptions.contributionId,
|
||||
input: mountOptions.input,
|
||||
});
|
||||
if (options.mount) {
|
||||
return options.mount(mountOptions);
|
||||
}
|
||||
return {
|
||||
contributionId: mountOptions.contributionId,
|
||||
mounted: true,
|
||||
unmount: () => Promise.resolve(),
|
||||
update: () => Promise.resolve(),
|
||||
version: "1.0.0",
|
||||
};
|
||||
},
|
||||
};
|
||||
const contributions: ModuleUiContributions = {
|
||||
list: (capabilityId) =>
|
||||
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
||||
async mount(mountOptions) {
|
||||
mounted.push({
|
||||
contributionId: mountOptions.contributionId,
|
||||
input: mountOptions.input,
|
||||
});
|
||||
if (options.mount) {
|
||||
return options.mount(mountOptions);
|
||||
}
|
||||
return {
|
||||
contributionId: mountOptions.contributionId,
|
||||
mounted: true,
|
||||
unmount: () => Promise.resolve(),
|
||||
update: () => Promise.resolve(),
|
||||
version: "1.0.0",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
context: {
|
||||
api,
|
||||
contributions,
|
||||
display: {
|
||||
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
||||
},
|
||||
moduleId: "signage",
|
||||
permissions: {
|
||||
has: (permission) => options.permissions.includes(permission),
|
||||
},
|
||||
principal: options.principal ?? { kind: "anonymous" },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
},
|
||||
mounted,
|
||||
requests,
|
||||
};
|
||||
return {
|
||||
context: {
|
||||
api,
|
||||
contributions,
|
||||
display: {
|
||||
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
||||
},
|
||||
moduleId: "signage",
|
||||
permissions: {
|
||||
has: (permission) => options.permissions.includes(permission),
|
||||
},
|
||||
principal: options.principal ?? { kind: "anonymous" },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
},
|
||||
mounted,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
/** The settings payload every screen loads first. */
|
||||
export const FAKE_SETTINGS = Object.freeze({
|
||||
crossfadeMs: 1000,
|
||||
moduleVersion: "0.1.4",
|
||||
nativeImageDurationMs: 7000,
|
||||
nativePresentation: {
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 20,
|
||||
},
|
||||
rendererInputVersion: "1.0.0",
|
||||
timezone: "America/Chicago",
|
||||
uploads: {
|
||||
batchMaxFiles: 20,
|
||||
concurrency: 3,
|
||||
maxBytes: 26_214_400,
|
||||
maxPixels: 40_000_000,
|
||||
},
|
||||
crossfadeMs: 1000,
|
||||
moduleVersion: "0.1.4",
|
||||
nativeImageDurationMs: 7000,
|
||||
nativePresentation: {
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 20,
|
||||
},
|
||||
rendererInputVersion: "1.0.0",
|
||||
timezone: "America/Chicago",
|
||||
uploads: {
|
||||
batchMaxFiles: 20,
|
||||
concurrency: 3,
|
||||
maxBytes: 26_214_400,
|
||||
maxPixels: 40_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
|
||||
};
|
||||
return {
|
||||
database: {
|
||||
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
|
||||
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
|
||||
operation(transaction),
|
||||
} as unknown as ModuleDrizzleDatabase,
|
||||
positions,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user