fix(workshop): stack widget install flow

This commit is contained in:
Thomas Camlong committed 2026-08-10 18:19:31 +02:00
1 parent 378bc0cd28
commit cf3c4b4152
10 files changed
+269 -65

No files matched your search

@@ -90,15 +90,17 @@ while the replacement exists; deleting the v2 record surfaces the v1 entry as mi
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
logs, or exports.
For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source
review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
you go back. After import, the new definition appears in the picker. Its tile
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
reload is required.
## Import and Workshop
Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
+2 -2
View File
@@ -78,8 +78,8 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
+1
View File
@@ -160,6 +160,7 @@ export default async function Layout(props: {
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { ModalProps } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -27,6 +28,8 @@ interface CustomWidgetImportDialogProps {
legacyId?: string;
stackId?: string;
zIndex?: number;
modalProps?: Pick<ModalProps, "size" | "styles">;
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
onClose(): void;
onImported?(result: { id: string }): void;
}
@@ -37,6 +40,8 @@ export function CustomWidgetImportDialog({
legacyId,
stackId,
zIndex,
modalProps,
labels,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
@@ -103,14 +108,17 @@ export function CustomWidgetImportDialog({
opened={opened}
stackId={stackId}
zIndex={zIndex}
{...modalProps}
review={review}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
description: legacyId
? t("importReview.migrationDescription")
: (labels?.description ?? t("importReview.description")),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -119,8 +127,8 @@ export function CustomWidgetImportDialog({
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
cancel: labels?.cancel ?? t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -22,11 +22,14 @@ import {
TextInput,
Title,
} from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconFlag,
IconRefresh,
IconSearch,
@@ -35,6 +38,8 @@ import {
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
@@ -58,7 +63,7 @@ import {
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
@@ -75,9 +80,21 @@ interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
onUseCss?(css: string): void;
modalStack?: {
modalProps: Pick<ModalProps, "size" | "styles">;
details: WorkshopModalStep;
report: WorkshopModalStep;
};
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
interface WorkshopModalStep {
opened: boolean;
stackId?: string;
open(): void;
close(): void;
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
@@ -86,12 +103,25 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpened, reportControls] = useDisclosure(false);
const [internalReportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState<string | null>(null);
const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
const openDetails = (id: string) => {
setSelectedId(id);
modalStack?.details.open();
};
const closeDetails = () => {
modalStack?.details.close();
setSelectedId(null);
};
const openReport = modalStack?.report.open ?? reportControls.open;
const closeReport = modalStack?.report.close ?? reportControls.close;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -123,7 +153,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
})
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
const install = useWorkshopWidgetImportMutation(onInstall);
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
@@ -135,7 +165,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
reportControls.close();
closeReport();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
@@ -161,25 +191,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
</Text>
</Box>
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</Group>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
<Group gap="xs">
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
size="xs"
variant="default"
leftSection={<IconExternalLink size={14} />}
>
{t("openCommunity")}
</Button>
)}
{user ? (
<>
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
@@ -260,9 +303,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === expectedSchema ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Group gap="xs">
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
@@ -277,8 +317,8 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{type === "customCss" ? t("inspectCss") : t("inspect")}
<Button variant="light" onClick={() => openDetails(item.id)}>
{type === "customCss" ? t("inspectCss") : t("install")}
</Button>
</Stack>
</Card>
@@ -294,21 +334,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
<Modal
opened={selectedId !== null}
onClose={() => setSelectedId(null)}
title={detail.data?.title ?? t("details")}
size="xl"
{...modalStack?.modalProps}
opened={detailsOpened}
onClose={closeDetails}
stackId={modalStack?.details.stackId}
title={type === "customCss" ? t("inspectCss") : t("install")}
size={modalStack?.modalProps.size ?? "xl"}
>
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Box>
<Title order={3}>{detail.data.title}</Title>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
</Box>
<Button
component="a"
href={getWorkshopWebUrl(detail.data.id)}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
</Group>
{detail.data.description && <Text>{detail.data.description}</Text>}
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
The author marked this submission as outdated. Review it carefully before importing.
@@ -346,9 +403,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
<WorkshopCodeViewer value={detail.data.content} language={type === "customCss" ? "css" : "json"} />
<Group justify="space-between">
<Group gap="xs">
<Button
@@ -381,7 +436,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={reportControls.open}
onClick={openReport}
>
{t("report")}
</Button>
@@ -390,13 +445,14 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Button
loading={install.isPending}
disabled={!detailCompatible}
rightSection={<IconArrowRight size={16} />}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
{t("install")}
{t("continueInstall")}
</Button>
)}
{onUseCss && (
@@ -424,7 +480,13 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
</Modal>
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
<Modal
{...modalStack?.modalProps}
opened={reportOpened}
onClose={closeReport}
stackId={modalStack?.report.stackId}
title={t("reportTitle")}
>
<Stack>
<Select
label={t("reportReason")}
@@ -474,6 +536,54 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
);
}
function WorkshopCodeViewer({ value, language }: { value: string; language: "json" | "css" }) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height="360px"
/>
);
}
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
@@ -1,5 +1,5 @@
import { WorkshopBackend } from "@homarr/workshop/backend";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
@@ -12,3 +12,12 @@ export function createWorkshopClient() {
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
export function getWorkshopWebUrl(submissionId?: string) {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-web-url"]')?.content;
const workshopUrl = (runtimeUrl || WORKSHOP_WEB_URL).replace(/\/+$/u, "");
return submissionId ? `${workshopUrl}/${encodeURIComponent(submissionId)}` : workshopUrl;
}
@@ -2,7 +2,8 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -16,11 +17,20 @@ interface WorkshopInstallButtonProps {
fullWidth?: boolean;
}
const installFlowModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "review"]);
const stack = useModalsStack(["workshop", "details", "report", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const zIndex = getDefaultZIndex("modal") + 10;
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const closeReview = () => {
stack.close("review");
@@ -35,11 +45,26 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("title")}
{children ?? t("importFromWorkshop")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} title={t("installDialog")} size="90%" zIndex={zIndex}>
<Modal {...stack.register("workshop")} {...installFlowModalProps} title={t("installDialog")}>
<WorkshopBrowser
modalStack={{
modalProps: installFlowModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
@@ -49,7 +74,13 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
zIndex={zIndex + 1}
modalProps={installFlowModalProps}
labels={{
title: t("confirmInstallTitle"),
description: t("confirmInstallDescription"),
cancel: t("backToWidget"),
confirm: t("install"),
}}
onClose={closeReview}
onImported={() => {
stack.closeAll();
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopWebUrl } from "./workshop-client";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
});
it("links to the runtime-configured Workshop entry", () => {
document.head.innerHTML = '<meta name="homarr-workshop-web-url" content="https://preview.example/workshop/">';
expect(getWorkshopWebUrl()).toBe("https://preview.example/workshop");
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
});
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
const installButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
expect(installButton).toContain("<Modal.Stack>");
expect(installButton).toContain('height: "min(85dvh, 900px)"');
expect(installButton).not.toContain("getDefaultZIndex");
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
});
});
@@ -30,6 +30,8 @@ export interface ImportReviewDialogProps {
children?: ReactNode;
stackId?: string;
zIndex?: ModalProps["zIndex"];
size?: ModalProps["size"];
styles?: ModalProps["styles"];
onClose(): void;
onConfirm(): void;
}
@@ -54,6 +56,8 @@ export function ImportReviewDialog({
children,
stackId,
zIndex,
size,
styles,
onClose,
onConfirm,
}: ImportReviewDialogProps) {
@@ -63,9 +67,10 @@ export function ImportReviewDialog({
onClose={onClose}
title={messages.title}
centered
size="lg"
stackId={stackId}
zIndex={zIndex}
size={size ?? "lg"}
styles={styles}
>
{review && (
<Stack gap="md">
+7 -3
View File
@@ -1130,7 +1130,8 @@
"installError": "Could not install widget",
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
"title": "Workshop",
"openCommunity": "Open public Workshop",
"importFromWorkshop": "Import from Workshop",
"openCommunity": "Open Workshop",
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
@@ -1150,9 +1151,8 @@
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
"author": "by {name}",
"inspect": "Inspect widget",
"inspectCss": "Inspect CSS",
"details": "Widget details",
"sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
"sources": "Declared API sources",
"requests": "Declared queries and actions",
@@ -1160,6 +1160,10 @@
"export": "Export",
"report": "Report",
"install": "Install widget",
"continueInstall": "Continue installation",
"confirmInstallTitle": "Confirm widget installation",
"confirmInstallDescription": "Confirm where this widget connects and add any credentials required by your Homarr installation.",
"backToWidget": "Back to widget",
"useCss": "Import CSS",
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
"reportTitle": "Report submission",