merge: stack widget modernization on dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-08 20:40:26 +02:00
commit 9edeba5cd2
940 files changed
+104475 -33649

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
@@ -18,8 +19,10 @@ import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
import { WidgetInternalInput } from "./widget-internal-input";
const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
location: WidgetLocationInput,
@@ -37,6 +40,7 @@ const mapping = {
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { JsonOption } from "./widget-custom-widget-configuration-input";
vi.mock("@homarr/custom-widgets/workbench", () => ({
CustomWidgetCodeEditor: (props: {
id: string;
label: string;
value: string;
error?: string;
onChange(value: string): void;
}) => (
<textarea
id={props.id}
aria-label={props.label}
aria-invalid={Boolean(props.error)}
value={props.value}
onChange={(event) => props.onChange(event.currentTarget.value)}
/>
),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function renderJsonOption(identity: string, value: unknown) {
await act(async () => {
root.render(
<JsonOption
identity={identity}
editorId="settings-json-option"
label="Settings"
value={value}
onChange={vi.fn()}
/>,
);
});
}
function enterEditorValue(editor: HTMLTextAreaElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
valueSetter?.call(editor, value);
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("custom widget JSON option", () => {
it("preserves an invalid active draft until its definition or external value changes", async () => {
await renderJsonOption("definition-a:settings", { owner: "Alice" });
const editor = host.querySelector("textarea");
if (!editor) throw new Error("JSON editor was not rendered");
expect(editor?.value).toContain("Alice");
await act(async () => enterEditorValue(editor, '{"owner":'));
expect(editor?.value).toBe('{"owner":');
await renderJsonOption("definition-a:settings", { owner: "Alice" });
expect(editor?.value).toBe('{"owner":');
await renderJsonOption("definition-b:settings", { owner: "Bob" });
expect(editor?.value).toContain("Bob");
expect(editor?.value).not.toContain("Alice");
});
it("synchronizes a valid external configuration update", async () => {
await renderJsonOption("definition-a:settings", { page: 1 });
const editor = host.querySelector("textarea");
await renderJsonOption("definition-a:settings", { page: 2 });
expect(editor?.value).toContain('"page": 2');
});
});
@@ -0,0 +1,348 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
Alert,
ColorInput,
MultiSelect,
NumberInput,
Select,
Slider,
Stack,
Switch,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput, TimeInput } from "@mantine/dates";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetCustomWidgetConfigurationInput = ({
property,
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
const form = useFormContext();
const labels = useScopedI18n("widget.customApi.configuration");
const board = useOptionalBoard();
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
const configurationValue = form.values.options[property];
const configuration = useMemo(() => (isRecord(configurationValue) ? configurationValue : {}), [configurationValue]);
const available = clientApi.customWidget.available.useQuery(
{ boardId: board?.id ?? "", currentId: definitionId || undefined },
{ enabled: board !== null && Boolean(definitionId) },
);
const definition = available.data?.find((candidate) => candidate.id === definitionId);
const options = isRecord(definition?.options) ? (definition.options as Record<string, CustomWidgetOption>) : null;
const issues = useMemo(
() => (options ? validateCustomWidgetOptions(options, configuration) : []),
[configuration, options],
);
useEffect(() => {
const path = `options.${property}`;
if (issues[0]) form.setFieldError(path, `${issues[0].path}: ${issues[0].message}`);
else form.clearFieldError(path);
}, [form, issues, property]);
if (!definitionId || !options) return null;
const entries = Object.entries(options);
const regular = entries.filter(([, option]) => !option.advanced);
const advanced = entries.filter(([, option]) => option.advanced);
return (
<Stack gap="md">
{issues[0] && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
</Alert>
)}
{regular.map(([name, option]) => (
<OptionField
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
definitionId={definitionId}
/>
))}
{advanced.length > 0 && (
<Accordion variant="contained">
<Accordion.Item value="advanced-options">
<Accordion.Control>{labels("advancedOptions")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{advanced.map(([name, option]) => (
<OptionField
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
definitionId={definitionId}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
);
};
function OptionField({
option,
path,
configuration,
definitionId,
}: {
option: CustomWidgetOption;
path: string;
configuration: Record<string, unknown>;
definitionId: string;
}) {
const form = useFormContext();
const input = form.getInputProps(path, { type: option.control === "switch" ? "checkbox" : "input" });
const common = { label: option.label, description: option.description };
if (option.choicesFrom)
return <DynamicOptionField option={option} path={path} configuration={configuration} definitionId={definitionId} />;
if (option.control === "switch") return <Switch {...common} {...input} />;
if (["number", "duration"].includes(option.control))
return <NumberInput {...common} min={option.min} max={option.max} step={option.step} {...input} />;
if (option.control === "slider")
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{option.label}
</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
<Slider min={option.min ?? 0} max={option.max ?? 100} step={option.step ?? 1} {...input} />
</Stack>
);
const choices = option.choices?.map((choice) => ({ label: choice.label, value: String(choice.value) })) ?? [];
if (option.control === "select")
return (
<Select
{...common}
data={choices}
value={input.value == null ? null : String(input.value)}
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
/>
);
if (option.control === "multiSelect")
return (
<MultiSelect
{...common}
data={choices}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onChange={(value) =>
form.setFieldValue(
path,
value.map((entry) => coerceChoice(entry, option)),
)
}
/>
);
if (option.control === "textarea") return <Textarea {...common} autosize minRows={3} {...input} />;
if (option.control === "color") return <ColorInput {...common} {...input} />;
if (option.control === "date") return <DateInput {...common} valueFormat="YYYY-MM-DD" {...input} />;
if (option.control === "time") return <TimeInput {...common} {...input} />;
if (option.control === "icon") return <IconPicker withAsterisk={false} {...input} />;
if (option.control === "timeZone")
return <Select {...common} searchable data={Intl.supportedValuesOf("timeZone")} {...input} />;
if (option.control === "json")
return (
<JsonOption
identity={`${definitionId}:${path}`}
editorId={`${definitionId}-${path}-json-option`}
label={option.label}
description={option.description}
value={input.value}
onChange={(value) => form.setFieldValue(path, value)}
/>
);
return <TextInput {...common} type={option.control === "url" ? "url" : "text"} {...input} />;
}
function DynamicOptionField({
option,
path,
configuration,
definitionId,
}: {
option: CustomWidgetOption;
path: string;
configuration: Record<string, unknown>;
definitionId: string;
}) {
const form = useFormContext();
const board = useOptionalBoard();
const source = option.choicesFrom;
if (!source) return null;
const query = clientApi.customWidget.optionRequest.useQuery(
{
boardId: board?.id ?? "",
definitionId,
requestId: source.request,
params: primitiveConfiguration(configuration),
},
{ enabled: Boolean(board?.id && definitionId && source.request) },
);
const collection = source.itemsPath ? getByPath(query.data?.data, source.itemsPath) : query.data?.data;
const data = (Array.isArray(collection) ? collection : []).flatMap((row) => {
const value = getByPath(row, source.valuePath);
const label = getByPath(row, source.labelPath);
return typeof value === "string" || typeof value === "number"
? [{ value: String(value), label: String(label ?? value) }]
: [];
});
const input = form.getInputProps(path);
const common = {
label: option.label,
description: option.description,
data,
searchable: true,
disabled: query.isError,
rightSection: query.isFetching ? "…" : undefined,
};
if (option.control === "multiSelect")
return (
<MultiSelect
{...common}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onChange={(value) =>
form.setFieldValue(
path,
value.map((entry) => coerceChoice(entry, option)),
)
}
/>
);
return (
<Select
{...common}
value={input.value == null ? null : String(input.value)}
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
/>
);
}
const editorMessages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal",
fullTab: "Full",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Ready",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
export function JsonOption({
identity,
editorId,
label,
description,
value,
onChange,
}: {
identity: string;
editorId: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const serializedValue = JSON.stringify(value ?? null, null, 2);
const [draft, setDraft] = useState(serializedValue);
const [error, setError] = useState<string>();
const synchronizedValueRef = useRef(serializedValue);
const identityRef = useRef(identity);
useEffect(() => {
if (identityRef.current === identity && synchronizedValueRef.current === serializedValue) return;
identityRef.current = identity;
synchronizedValueRef.current = serializedValue;
setDraft(serializedValue);
setError(undefined);
}, [identity, serializedValue]);
return (
<CustomWidgetCodeEditor
id={editorId}
label={label}
description={description}
language="json"
value={draft}
height="160px"
error={error}
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
synchronizedValueRef.current = JSON.stringify(parsed ?? null, null, 2);
onChange(parsed);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid JSON");
}
}}
messages={editorMessages}
/>
);
}
function coerceChoice(value: string | null, option: CustomWidgetOption) {
if (value === null) return null;
return typeof option.default === "number" ||
option.choices?.some((choice) => typeof choice.value === "number" && String(choice.value) === value)
? Number(value)
: value;
}
function primitiveConfiguration(value: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string | number | boolean] =>
["string", "number", "boolean"].includes(typeof entry[1]),
),
);
}
function getByPath(value: unknown, path: string): unknown {
return path
.replace(/^\$\.?/u, "")
.split(".")
.filter(Boolean)
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
const mocks = vi.hoisted(() => ({
available: [] as Array<Record<string, unknown>>,
permissions: ["admin"] as string[],
setFieldValue: vi.fn(),
}));
vi.mock("@mantine/core", () => {
// oxlint-disable-next-line unicorn/consistent-function-scoping -- the mock factory is hoisted by Vitest
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
const Combobox = Object.assign(Container, {
Target: Container,
Dropdown: Container,
Options: Container,
Group: Container,
Option: Container,
Empty: Container,
Chevron: () => null,
});
return {
Alert: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
<div role="alert">
<strong>{title}</strong>
{children}
</div>
),
Anchor: ({ children, href }: { children?: ReactNode; href?: string }) => <a href={href}>{children}</a>,
Avatar: Container,
Badge: Container,
Combobox,
Group: Container,
InputBase: ({ value }: { value?: string }) => <input aria-label="Custom widget" value={value} readOnly />,
Loader: () => null,
Stack: Container,
Text: Container,
useCombobox: () => ({
dropdownOpened: false,
resetSelectedOption: vi.fn(),
closeDropdown: vi.fn(),
openDropdown: vi.fn(),
updateSelectedOptionIndex: vi.fn(),
}),
};
});
vi.mock("@homarr/api/client", () => ({
clientApi: {
customWidget: {
available: {
useQuery: () => ({ data: mocks.available, isLoading: false }),
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
}));
vi.mock("@homarr/boards/context", () => ({
useOptionalBoard: () => ({ id: "board-1" }),
}));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
manageMigration: "Open Manage custom widgets",
contactAdmin: "Contact an administrator.",
})[key] ?? key,
}));
vi.mock("@homarr/ui", () => ({ Link: "a" }));
vi.mock("./form", () => ({
useFormContext: () => ({
values: { options: { definitionId: "legacy-widget" } },
setFieldValue: mocks.setFieldValue,
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.available = [];
mocks.permissions = ["admin"];
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function renderInput() {
await act(async () => {
root.render(
<WidgetCustomWidgetSelectInput
kind="customApi"
property="definitionId"
options={{ withDescription: false }}
initialOptions={{}}
/>,
);
});
}
describe("custom widget picker migration status", () => {
it("keeps an existing legacy selection visible with migration guidance", async () => {
mocks.available = [
{
id: "legacy-widget",
name: "Legacy status",
description: "Old API widget",
iconUrl: null,
sources: [],
requestCapabilities: [],
defaultOptions: {},
updatedAt: new Date(0),
migrationRequired: true,
},
];
await renderInput();
expect(host.querySelector("input")?.value).toBe("Legacy status");
expect(host.textContent).toContain("Migration required");
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
});
it("tells non-admins to contact an administrator without linking management", async () => {
mocks.permissions = ["board-modify-all"];
mocks.available = [
{
id: "legacy-widget",
name: "Legacy status",
description: "Old API widget",
iconUrl: null,
sources: [],
requestCapabilities: [],
defaultOptions: {},
updatedAt: new Date(0),
migrationRequired: true,
},
];
await renderInput();
expect(host.textContent).toContain("Contact an administrator.");
expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
});
});
@@ -1,9 +1,26 @@
"use client";
import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import {
Alert,
Anchor,
Avatar,
Badge,
Combobox,
Group,
InputBase,
Loader,
Stack,
Text,
useCombobox,
} from "@mantine/core";
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -13,24 +30,23 @@ export const WidgetCustomWidgetSelectInput = ({
property,
kind,
options,
boardId,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
const { data, isLoading } = clientApi.customWidget.available.useQuery(
{ boardId: boardId ?? "" },
{ enabled: Boolean(boardId) },
{ boardId: board?.id ?? "", currentId: currentValue || undefined },
{ enabled: board !== null },
);
const [search, setSearch] = useState("");
const currentValue = form.values.options[property] as string;
const definitions = useMemo(
() =>
(data ?? [])
.filter((def) => def.enabled || def.id === currentValue)
.map((def) => ({ value: def.id, label: def.name })),
[data, currentValue],
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
[data],
);
const filteredOptions = useMemo(
@@ -43,8 +59,8 @@ export const WidgetCustomWidgetSelectInput = ({
[definitions, search],
);
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
const selectedDefinition = definitions.find((definition) => definition.value === currentValue);
const selectedLabel = selectedDefinition?.label;
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
@@ -53,43 +69,104 @@ export const WidgetCustomWidgetSelectInput = ({
});
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
form.setFieldValue(`options.${property}`, val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
form.setFieldValue(`options.${property}`, event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Stack gap="sm">
<Combobox
store={combobox}
onOptionSubmit={(val) => {
const definition = definitions.find((candidate) => candidate.value === val);
if (!definition || definition.migrationRequired) return;
form.setFieldValue(`options.${property}`, val);
form.setFieldValue("options.configuration", definition?.defaultOptions ?? {});
form.setFieldValue(
"options.configurationVersion",
definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : Date.now(),
);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
{def.label}
</Combobox.Option>
))}
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Group label={labels("group")}>
{filteredOptions.map((def) => (
<Combobox.Option
key={def.value}
value={def.value}
active={def.value === currentValue}
disabled={def.migrationRequired}
>
<Group wrap="nowrap" gap="sm">
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
<IconApi size={18} />
</Avatar>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{def.label}
</Text>
{def.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{def.description}
</Text>
)}
<Group gap={4} mt={2}>
{def.migrationRequired && (
<Badge size="xs" variant="light" color="yellow">
{labels("migrationRequired")}
</Badge>
)}
<Badge size="xs" variant="light">
{labels("sources", { count: def.sources.length })}
</Badge>
<Badge size="xs" variant="light" color="gray">
{labels("requests", { count: def.requestCapabilities.length })}
</Badge>
</Group>
</Stack>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>{labels("empty")}</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{selectedDefinition?.migrationRequired && (
<Alert
color="yellow"
variant="light"
icon={<IconAlertTriangle size={18} />}
title={labels("migrationRequired")}
>
<Text size="xs">{labels("migrationDescription")}</Text>
{isAdmin ? (
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
{labels("manageMigration")}
</Anchor>
) : (
<Text size="xs" fw={600}>
{labels("contactAdmin")}
</Text>
)}
</Alert>
)}
</Stack>
);
};
@@ -0,0 +1,3 @@
import type { CommonWidgetInputProps } from "./common";
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
});
for (const app of dbApps) {
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
}
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
}
queryClient.setQueryData(
trpc.app.byIds.queryKey(item.options.items),
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
+110
View File
@@ -0,0 +1,110 @@
import {
IconActivityHeartbeat,
IconAd,
IconApi,
IconApps,
IconArchive,
IconBadgeCc,
IconBatteryCharging,
IconBell,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartAreaLine,
IconChartBar,
IconClock,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
IconLayoutGrid,
IconMessage,
IconMovie,
IconNotes,
IconPhoto,
IconReportSearch,
IconRocket,
IconRoute,
IconRss,
IconServer2,
IconShieldCheck,
IconShieldLock,
IconSpeedboat,
IconTable,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
app: IconApps,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
"immich-serverStats": IconGraphFilled,
"immich-albumCarousel": IconPhoto,
paperlessNgx: IconFileText,
patchmon: IconShieldCheck,
bazarr: IconBadgeCc,
tracearr: IconActivityHeartbeat,
speedtestTracker: IconSpeedboat,
uptimeKuma: IconHeartbeat,
audioStats: IconHeadphones,
umami: IconChartBar,
vpn: IconShieldLock,
archiveTeamWarrior: IconArchive,
ups: IconBatteryCharging,
beszelSystemTable: IconTable,
beszelSystemGrid: IconLayoutGrid,
beszelAlerts: IconBell,
beszelSystemStats: IconChartAreaLine,
traefik: IconRoute,
customApi: IconApi,
};
+99 -584
View File
@@ -1,617 +1,132 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Center,
Code,
Flex,
Group,
Loader,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react";
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
const valueSizeMap: Record<string, "sm" | "md" | "lg" | "xl"> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
interface CustomDisplayProps {
data: Record<string, unknown>;
displayMode: "compact" | "advanced";
width: number;
height: number;
}
function SingleValueDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const configuredSize = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
const size = displayMode === "compact" && (width < 180 || height < 96) ? "md" : configuredSize;
const labelAbove = (data.labelPosition as string) === "above";
const label = data.label ? (
<Text c="dimmed" size={height < 96 ? "xs" : "sm"} ta="center" lineClamp={2}>
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4} ta="center" lineClamp={2} maw="100%">
{String(data.value ?? "—")}
{data.unit ? ` ${data.unit}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
function KeyValueDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const layout = (data.layout as string) ?? "list";
const configuredColumns = Math.max(1, (data.columns as number) ?? 2);
const columns =
displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 140)));
const padding = height < 112 ? "xs" : "sm";
if (layout === "grid") {
return (
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p={padding}>
{entries.map((entry, i) => (
<Stack key={i} align="center" gap={2} miw={0}>
<Text size="sm" fw={600} truncate="end" maw="100%">
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" truncate="end" maw="100%">
{entry.label}
</Text>
</Stack>
))}
</SimpleGrid>
</ScrollArea>
);
}
return (
<ScrollArea h="100%">
<Stack justify="center" gap={height < 112 ? 4 : "xs"} p={padding}>
{entries.map((entry, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="xs">
<Text size="sm" c="dimmed" truncate="end">
{entry.label}
</Text>
<Text size="sm" fw={600} truncate="end" maw="55%">
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
))}
</Stack>
</ScrollArea>
);
}
function TableDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
const visibleColumnCount = displayMode === "advanced" ? columns.length : Math.max(1, Math.floor(width / 120));
const visibleRowCount = displayMode === "advanced" ? rows.length : Math.max(1, Math.floor(height / 32) - 1);
const visibleColumns = columns.slice(0, visibleColumnCount);
return (
<ScrollArea h="100%">
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{visibleColumns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.slice(0, visibleRowCount).map((row, i) => (
<Table.Tr key={i}>
{row.slice(0, visibleColumnCount).map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
function StatGridCard({
item,
cardStyle,
}: {
item: { label: string; unit: string; color: string; value: unknown };
cardStyle: string;
}) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const bgMap: Record<string, string> = {
filled: `var(--mantine-color-${item.color}-light)`,
outline: "transparent",
subtle: `var(--mantine-color-${item.color}-light)`,
};
return (
<Card
ref={ref}
p="sm"
radius="md"
bg={bgMap[cardStyle] ?? bgMap.filled}
withBorder={cardStyle === "outline"}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3} lineClamp={2}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
function StatGridDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const configuredColumns = Math.max(1, (data.columns as number) ?? 2);
const columns =
displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120)));
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
<SimpleGrid
cols={columns}
spacing="xs"
p={height < 96 ? 4 : "xs"}
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, i) => (
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
))}
</SimpleGrid>
);
}
function ProgressBarsDisplay({ data, height }: CustomDisplayProps) {
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
const showPercentage = (data.showPercentage as boolean) ?? true;
const barSize = (data.barSize as string) ?? "md";
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<ScrollArea h="100%">
<Stack justify="center" gap={height < 120 ? "xs" : "sm"} p={height < 120 ? "xs" : "sm"}>
{bars.map((bar, i) => {
const max = bar.max ?? 100;
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
return (
<Stack key={i} gap={4}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size="xs" fw={500} truncate="end" style={{ flex: 1, minWidth: 0 }}>
{bar.label}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
{bar.max !== undefined && showPercentage
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
: ""}
</Text>
</Group>
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
</Stack>
);
})}
</Stack>
</ScrollArea>
);
}
function StatusIndicatorDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
const layout = (data.layout as string) ?? "list";
const dotSize = (data.dotSize as string) ?? "md";
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
const size = dotSizeMap[dotSize] ?? 10;
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<div
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500} truncate="end" style={{ flex: 1, minWidth: 0 }}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto" truncate="end" maw="45%">
{item.value}
</Text>
</Group>
);
if (layout === "grid") {
const columns = displayMode === "advanced" || width >= 280 ? 2 : 1;
return (
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p={height < 112 ? "xs" : "sm"}>
{items.map(renderItem)}
</SimpleGrid>
</ScrollArea>
);
}
return (
<ScrollArea h="100%">
<Stack justify="center" gap="xs" p={height < 112 ? "xs" : "sm"}>
{items.map(renderItem)}
</Stack>
</ScrollArea>
);
}
function CountGridDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const configuredColumns = Math.max(1, (data.columns as number) ?? 2);
const columns =
displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120)));
const valueSize = (data.valueSize as string) ?? "md";
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
return (
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p={height < 112 ? "xs" : "sm"}>
{items.map((item, i) => (
<Stack key={i} align="center" justify="center" gap={0} miw={0}>
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2} truncate="end" maw="100%">
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" ta="center" lh={1.3} truncate="end" maw="100%">
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
</ScrollArea>
);
}
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data, displayMode, height }: CustomDisplayProps) {
const t = useScopedI18n("widget.customApi");
const maxHeight = displayMode === "advanced" ? Math.max(160, height - 80) : ((data.maxHeight as number) ?? 300);
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="flex-end">
<Tooltip label={t("actions.openJsonViewer")}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="sm"
aria-label={t("actions.openJsonViewer")}
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={maxHeight} style={{ flex: 1, minHeight: 0 }}>
<Code block style={{ fontSize: 12 }}>
{jsonString}
</Code>
</ScrollArea>
</Stack>
);
}
function ActionButtonDisplay({ data, width, height }: CustomDisplayProps) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.customWidget.execute.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const successMessage = (data.successMessage as string) || t("executeSuccess");
const definitionId = data.widgetDefinitionId as string | undefined;
const boardId = data.boardId as string | undefined;
const itemId = data.itemId as string | undefined;
const canExecute = data.canExecute === true;
const handleExecute = async () => {
if (!definitionId || !boardId || !itemId || !canExecute) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ boardId, itemId, definitionId });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
setTimeout(() => setLastSuccess(false), 3000);
} else {
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
}
} catch {
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
}
};
const handleClick = () => {
if (confirmText) {
openConfirmModal({
title: buttonLabel,
children: confirmText,
onConfirm: () => void handleExecute(),
});
} else {
void handleExecute();
}
};
return (
<Center h="100%">
<Button
size={width < 180 || height < 96 ? "sm" : "lg"}
color={buttonColor}
onClick={handleClick}
loading={executeMutation.isPending}
disabled={!canExecute || !boardId || !itemId || !definitionId}
leftSection={lastSuccess ? <IconCheck size={18} /> : <IconPlayerPlay size={18} />}
variant={lastSuccess ? "light" : "filled"}
maw="calc(100% - var(--mantine-spacing-sm) * 2)"
>
<Text truncate="end">{executeMutation.isPending ? t("executing") : buttonLabel}</Text>
</Button>
</Center>
);
}
const CustomJsxAdapter = ({ data }: CustomDisplayProps) => <CustomJsxDisplay data={data} />;
export const displayComponents: Record<string, ComponentType<CustomDisplayProps>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxAdapter,
};
const displayTypeTranslationKeys = {
singleValue: "displayType.singleValue",
keyValue: "displayType.keyValue",
table: "displayType.table",
statGrid: "displayType.statGrid",
progressBars: "displayType.progressBars",
statusIndicator: "displayType.statusIndicator",
countGrid: "displayType.countGrid",
raw: "displayType.raw",
actionButton: "displayType.actionButton",
customJsx: "displayType.customJsx",
} as const;
export default function CustomApiWidget({
options,
boardId,
itemId,
width,
height,
displayMode = "compact",
widgetRuntimeRef,
isEditMode,
removeItem,
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
if (!definitionId) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm">
{t("noDefinition")}
</Text>
</Stack>
</Center>
);
}
if (!boardId || !itemId) return null;
return (
<CustomApiWidgetInner
boardId={boardId}
itemId={itemId}
definitionId={definitionId}
refreshInterval={refreshInterval as number}
width={width}
height={height}
displayMode={displayMode}
widgetRuntimeRef={widgetRuntimeRef}
/>
);
}
function CustomApiWidgetInner({
boardId,
itemId,
definitionId,
refreshInterval,
width,
height,
displayMode,
widgetRuntimeRef,
}: {
boardId: string;
itemId: string;
definitionId: string;
refreshInterval: number;
width: number;
height: number;
displayMode: "compact" | "advanced";
widgetRuntimeRef?: WidgetComponentProps<"customApi">["widgetRuntimeRef"];
}) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const [pollingPaused, setPollingPaused] = useState(false);
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
const intervalMs = Math.max(1_000, safeInterval * 1_000);
const query = clientApi.widget.customApi.getData.useQuery(
{ boardId, itemId, definitionId },
{ itemId: itemId ?? "" },
{
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (pollingPaused || result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
if (err.data?.code === "NOT_FOUND") return false;
return failureCount < 3;
},
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) =>
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
},
);
const data = getUsableWidgetQueryData(query);
const { isLoading, refetch, isFetching } = query;
const widgetData = (data ?? {}) as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
const canTogglePolling = Boolean(data) && dataType !== "actionButton" && dataType !== "disabled";
const togglePolling = useCallback(() => {
if (pollingPaused) void refetch();
setPollingPaused((value) => !value);
}, [pollingPaused, refetch]);
useWidgetRuntimeActions(widgetRuntimeRef, canTogglePolling ? { togglePolling } : {});
if (isLoading) {
if (!definitionId) {
return <Unavailable message={t("definitionNotFound")} removeLabel={t("removeFromBoard")} onRemove={removeItem} />;
}
if (isEditMode && !itemId) return <Unavailable message={t("editModePending")} />;
if (query.isLoading)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (!data) return null;
if (dataType === "disabled") {
if (query.error) {
const errorCode = query.error.data?.code;
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCustomWidget("widget.disabled")}
</Text>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
const displayTypeKey = displayTypeTranslationKeys[dataType as keyof typeof displayTypeTranslationKeys];
const pollingLabel = pollingPaused ? t("actions.resumePolling") : t("actions.pausePolling");
const enrichedData =
dataType === "actionButton" ? { ...widgetData, boardId, itemId, widgetDefinitionId: definitionId } : widgetData;
const content = <Component data={enrichedData} displayMode={displayMode} width={width} height={height} />;
if (displayMode === "compact") return content;
return (
<Stack h="100%" gap="xs" p="xs">
<Group justify="space-between" wrap="nowrap">
<Badge variant="light">{displayTypeKey ? t(displayTypeKey) : t("displayType.unknown")}</Badge>
{dataType !== "actionButton" && (
<Tooltip label={pollingLabel}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={pollingLabel}
variant="subtle"
loading={isFetching}
onClick={togglePolling}
>
{pollingPaused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
<Box style={{ flex: 1, minHeight: 0 }}>{content}</Box>
</Stack>
<Unavailable
message={
migrationRequired
? t("migrationRequired")
: isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
onRemove={removeItem}
/>
);
}
if (!query.data) return null;
return (
<ScrollArea h="100%" p="xs">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
<CustomJsxDisplay
data={{
...query.data,
widgetDefinitionId: definitionId,
widgetItemId: itemId,
isEditMode,
}}
/>
);
}
function Unavailable({
message,
danger = false,
removeLabel,
actionLabel,
actionHref,
supplementaryMessage,
onRemove,
}: {
message: string;
danger?: boolean;
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
supplementaryMessage?: string;
onRemove?: () => void;
}) {
return (
<Center h="100%" p="sm">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{supplementaryMessage && (
<Text size="sm" fw={600} ta="center">
{supplementaryMessage}
</Text>
)}
{actionLabel && actionHref && (
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
{actionLabel}
</Button>
)}
{removeLabel && onRemove && (
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
{removeLabel}
</Button>
)}
</Stack>
</Center>
);
}
@@ -1,14 +1,47 @@
import { MantineProvider } from "@mantine/core";
import * as MantineCharts from "@mantine/charts";
import * as MantineCore from "@mantine/core";
import * as MantineDates from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { Root } from "react-dom/client";
import { customJsxComponentRegistry, enabledCustomJsxComponents } from "@homarr/custom-widgets/catalog";
import {
BUNDLED_CUSTOM_WIDGETS,
customJsxExamples,
customJsxTablerIconNames,
getCustomWidgetDefaultOptions,
} from "@homarr/custom-widgets/core";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
}));
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
if (typeof value === "function") return true;
if (!value || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
};
describe("CustomJsxDisplay", () => {
it("keeps safe Tabler icon metadata synchronized with the runtime", () => {
expect(SAFE_TABLER_ICON_NAMES).toEqual(customJsxTablerIconNames);
});
let container: HTMLDivElement;
let root: Root;
@@ -27,10 +60,18 @@ describe("CustomJsxDisplay", () => {
dispatchEvent: () => false,
}),
});
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
value: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
});
afterEach(() => {
root?.unmount();
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
@@ -38,8 +79,15 @@ describe("CustomJsxDisplay", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
root.render(
createElement(
MantineProvider,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(CustomJsxDisplay, { data })),
),
);
});
};
@@ -53,6 +101,65 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("3");
});
it("keeps runtime components inside an inert provider before a preview session exists", async () => {
await renderDisplay({ template: '<RefreshButton label="Refresh" />', data: {} });
expect(container.querySelector('button[aria-label="Refresh"]')).not.toBeNull();
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
expect(container.querySelector("button")?.disabled).toBe(true);
});
it("renders the bundled Pokédex list without a widget-wide runtime failure", async () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
expect(pokedex).toBeDefined();
await renderDisplay({
template: pokedex?.template,
data: {
pokemon: {
count: 2,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
],
},
},
status: { pokemon: { loading: false, ok: true, status: 200 } },
options: getCustomWidgetDefaultOptions(pokedex?.options ?? {}),
});
expect(container.textContent).toContain("bulbasaur");
expect(container.textContent).toContain("pikachu");
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/1.png"]'),
).not.toBeNull();
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/25.png"]'),
).not.toBeNull();
expect(container.querySelectorAll("img.mantine-Image-root")).toHaveLength(2);
expect(container.querySelectorAll(".mantine-Card-root")).toHaveLength(2);
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("recovers when preview data arrives after an initial render error", async () => {
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
await renderDisplay({ template, data: {} });
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
),
);
});
expect(container.textContent).toContain("Bulbasaur");
expect(container.textContent).toContain("Pikachu");
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("blocks script tags and event handlers", async () => {
await renderDisplay({
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
@@ -64,14 +171,125 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("Safe");
});
it("does not expose parser failures in rendered or accessible text", async () => {
it("supports bounded collection callbacks and safe string helpers", async () => {
await renderDisplay({
template: "<Text>http://admin:password@nas.internal/private/path?token=secret",
data: {},
template:
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
data: {
items: [
{ name: "alpha", enabled: true },
{ name: "hidden", enabled: false },
{ name: "beta", enabled: true },
{ name: "gamma", enabled: true },
],
},
});
expect(container.textContent).not.toMatch(/admin|password|nas\.internal|private\/path|token|secret/i);
expect(container.innerHTML).not.toMatch(/admin|password|nas\.internal|private\/path|token|secret/i);
expect(container.textContent).toContain("1. ALPHA");
expect(container.textContent).toContain("2. BETA");
expect(container.textContent).not.toContain("GAMMA");
});
it("blocks reflective properties assembled with string concatenation", async () => {
await renderDisplay({
template: '<Text>{data["con" + "structor"]}</Text>',
data: { value: 1 },
});
expect(container.textContent).toContain("reflective property");
});
it.each([
'<Text>{data["constr\\u0075ctor"]}</Text>',
'<Text>{data["__pro" + "to__"]}</Text>',
'<Text>{String["b" + "ind"](null)}</Text>',
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
"<Text>{data.items.map.apply(null, [])}</Text>",
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
expect(() =>
renderSafeJsx({
template,
components: { Text: (() => null) as never },
bindings: SAFE_BINDINGS({ items: [1] }),
}),
).toThrow(/reflective property/);
});
it("never resolves inherited data properties", () => {
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
inherited.visible = "safe";
const bindings = SAFE_BINDINGS(inherited);
const data = bindings.data as Record<string, unknown>;
expect(data.visible).toBe("safe");
expect(data.secret).toBeUndefined();
expect(Object.getPrototypeOf(data)).toBeNull();
});
it("blocks direct global calls but allows benign text containing fetch", async () => {
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
expect(container.textContent).toContain("fetch is available through controlled requests");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
),
);
});
expect(container.textContent).toContain("Unknown binding: fetch");
});
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
const props = sanitizeCustomJsxProps({
component: "iframe",
renderRoot: "iframe",
withinPortal: true,
onClick: () => undefined,
href: "javascript:alert(1)",
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
});
expect(props).toEqual({ style: { flex: 1, color: "red" } });
});
it("enforces collection and template length budgets", () => {
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
expect(() =>
renderSafeJsx({
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
components: {
Text: ((props: { children?: unknown }) => props.children) as never,
Stack: (() => null) as never,
},
bindings,
budgets: { maxCollectionItems: 2 },
}),
).toThrow(/collection limit/);
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
new SafeJsxError("Template exceeds the 50000 character limit"),
);
expect(() =>
renderSafeJsx({
template: '<Text>{"x".padStart(1000, "x")}</Text>',
components: { Text: (() => null) as never },
bindings: {},
budgets: { maxStringLength: 10 },
}),
).toThrow(/string length limit/);
expect(() =>
renderSafeJsx({
template: "<B/>".repeat(10_000),
components: { B: (() => null) as never },
bindings: {},
}),
).toThrow("Template exceeded the rendered node limit (10000)");
});
describe("safe bindings security", () => {
@@ -163,6 +381,17 @@ describe("CustomJsxDisplay", () => {
expect(math.abs(-5)).toBe(5);
});
it("Date helpers only return primitives and getDay returns the weekday", () => {
const bindings = SAFE_BINDINGS({});
const date = bindings.Date as {
create: (value: string) => number;
getDay: (value: string) => number;
};
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
});
it("JSON.stringify works correctly", () => {
const bindings = SAFE_BINDINGS({});
const json = bindings.JSON as { stringify: (v: unknown) => string };
@@ -186,3 +415,209 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
["@mantine/charts", MantineCharts],
["@mantine/dates", MantineDates],
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
const classified = new Set(
customJsxComponentRegistry
.filter((component) => component.package === packageName)
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
);
const unclassified = Object.entries(moduleExports)
.filter(isComponentLikeExport)
.map(([name]) => name)
.filter((name) => !classified.has(name));
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
});
it("has a runtime implementation for every enabled registry component", () => {
const missing = enabledCustomJsxComponents
.map(({ name }) => name)
.filter((name) => !Object.hasOwn(CUSTOM_WIDGET_COMPONENTS, name));
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
});
});
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: CUSTOM_WIDGET_COMPONENTS, bindings: SAFE_BINDINGS(data) });
describe("new collection/string methods", () => {
let container: HTMLDivElement;
let root: Root;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
});
return container.textContent ?? "";
};
it("find returns the first matching item", async () => {
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
items: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
],
});
expect(text).toContain("b");
});
it("findIndex returns correct index", async () => {
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
expect(text).toContain("2");
});
it("some returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("no");
});
it("every returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("all");
});
it("sort with comparator returns stable order on NaN", async () => {
const text = await renderAndGetText(
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
);
expect(text).toContain("a,");
expect(text).toContain("b");
});
it("reduce accumulates values", async () => {
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
nums: [1, 2, 3],
});
expect(text).toContain("6");
});
it("at returns element by negative index", async () => {
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
expect(text).toContain("c");
});
it("flat(2) throws SafeJsxError", () => {
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
});
it("flat(1) flattens one level", async () => {
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
expect(text).toContain("1,2,3");
});
it("reverse returns reversed copy", async () => {
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
expect(text).toContain("3,2,1");
});
it("string replaceAll works", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
expect(text).toContain("f00bar");
});
it("string replaceAll with empty search is a no-op", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
expect(text).toContain("abc");
});
it("string repeat caps at budget", async () => {
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
expect(text).toContain("hihihi");
});
it("string indexOf and lastIndexOf return positions", async () => {
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
s: "hello",
});
expect(text).toContain("2-3");
});
it("string trimStart and trimEnd work", async () => {
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
expect(text).toContain("hi");
});
it("filter(Boolean) removes falsy values", async () => {
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
items: ["a", "", "b", null, "c"],
});
expect(text).toContain("a,b,c");
});
it("pop returns last element", async () => {
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
url: "https://pokeapi.co/api/v2/pokemon/1/",
});
expect(text).toContain("1");
});
it("String(n).padStart works for number formatting", async () => {
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
expect(text).toContain("0006");
});
it("number arithmetic in string concatenation", async () => {
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
index: 3,
});
expect(text).toContain("https://example.com/4.png");
});
});
describe("shared Custom JSX examples", () => {
const sampleData: Record<string, unknown> = {
name: "Server A",
status: "online",
value: 42,
count: 1351,
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
timeline: [
{ date: "2026-07-11", value: 40 },
{ date: "2026-07-12", value: 42.5 },
],
albumArt: "https://example.com/cover.jpg",
album: "Album",
title: "Track",
artist: "Artist",
progress: 55,
playing: true,
shuffle: false,
state: "on",
};
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
const rendered = renderSafeJsx({
template: example.widget.template,
components: CUSTOM_WIDGET_COMPONENTS,
bindings: {
...SAFE_BINDINGS(sampleData),
status: {},
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
inputs: {},
},
});
expect(rendered.warnings).toEqual([]);
expect(rendered.node).not.toBeNull();
});
});
@@ -1,44 +1,92 @@
"use client";
import { useMemo } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./widget-definition-context";
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
if (!template.trim()) {
const t = useScopedI18n("widget.customApi.customJsx");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const components = useMemo(() => createCustomWidgetComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const [queryState, setQueryState] = useState<Record<string, CustomWidgetPublishedQueryState>>({});
useEffect(
() => setQueryState({}),
[data.widgetDefinitionId, data.widgetItemId, data.previewSessionId, data.template],
);
const publishQueryState = useCallback((requestId: string, value: CustomWidgetPublishedQueryState | null) => {
setQueryState((current) => {
if (!value) {
if (!Object.hasOwn(current, requestId)) return current;
const next = { ...current };
delete next[requestId];
return next;
}
const previous = current[requestId];
if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) return current;
return { ...current, [requestId]: value };
});
}, []);
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
status={{
...baseStatus,
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
}}
options={isRecord(data.options) ? data.options : {}}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
noTemplate: t("noTemplate"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
}}
/>
);
const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
if (!itemId && !previewSessionId) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
No JSX template configured
</Text>
</Alert>
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
{renderer}
</InactiveWidgetDefinitionProvider>
);
}
return (
<Stack gap={0} h="100%">
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={bindings}
disableKeyGeneration
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
renderError={() => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">Invalid widget template</Text>
</Alert>
)}
/>
</Stack>
<WidgetDefinitionProvider
definitionId={definitionId}
itemId={itemId}
previewSessionId={previewSessionId}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
>
{renderer}
</WidgetDefinitionProvider>
);
}
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
return (
left.loading === right.loading &&
left.ok === right.ok &&
left.status === right.status &&
left.statusText === right.statusText &&
left.error === right.error
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1 +0,0 @@
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
+14 -34
View File
@@ -1,42 +1,22 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { IconApi } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
refreshInterval: factory.slider({
defaultValue: 30,
validate: z.number().min(1).max(3600),
step: 1,
withDescription: true,
}),
}));
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
boardId: scope.boardId,
itemId: scope.itemId,
definitionId: scope.options.definitionId,
}),
contextActions: ({ options, widgetRuntimeRef }) => {
return [
{
key: "togglePolling",
label: "widget.customApi.actions.togglePolling",
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetRuntimeRef.current.actions.togglePolling !== "function",
onClick: () => {
widgetRuntimeRef.current.actions.togglePolling?.();
},
},
];
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
configuration: factory.customWidgetConfiguration(),
configurationVersion: factory.internal({ defaultValue: 1 }),
refreshInterval: factory.slider({
defaultValue: 30,
validate: z.number().min(1).max(3600),
step: 1,
withDescription: true,
}),
}));
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,12 @@
import type { ComponentType } from "react";
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
import { SafeTablerIcon } from "./jsx-icon-adapter";
// Labels are replaced by the localized runtime adapter in the renderer. These
// fallbacks are used only in isolated component-map tests.
export const createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
export const SAFE_BINDINGS = createCustomJsxBindings;
@@ -0,0 +1,248 @@
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBrandDocker,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPokeball,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconBrandDocker,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconPokeball,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
export const SAFE_TABLER_ICON_NAMES = Object.freeze(Object.keys(TABLER_ICON_MAP).toSorted());
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
export function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
import { createElement } from "react";
import type { ComponentType } from "react";
@@ -1,228 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
const prevCountRef = useRef(items.length);
useEffect(() => {
if (items.length !== prevCountRef.current) {
setPage(0);
prevCountRef.current = items.length;
}
}, [items.length]);
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
return (
<Stack gap="xs">
<div>{visible}</div>
{totalPages > 1 && (
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
</Stack>
);
}
interface TabsContainerProps {
children: ReactNode;
defaultTab?: string;
}
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
}
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
{panel.props.label ?? panel.props.value}
</Tabs.Tab>
))}
</Tabs.List>
{panels.map((panel) => (
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
{panel.props.children}
</Tabs.Panel>
))}
</Tabs>
);
}
interface TabPanelProps {
children: ReactNode;
value: string;
label?: string;
}
export function TabPanel({ children }: TabPanelProps) {
return <>{children}</>;
}
interface CollapsibleProps {
children: ReactNode;
title: string;
defaultOpen?: boolean;
}
const COLLAPSE_CHEVRONS = {
closed: IconChevronDown,
open: IconChevronUp,
} as const;
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
0: "closed",
1: "open",
};
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
const [opened, setOpened] = useState(defaultOpen);
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
return (
<Stack gap={0}>
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
<Group gap="xs" wrap="nowrap">
<Chevron size={14} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
</UnstyledButton>
<Collapse expanded={opened}>{children}</Collapse>
</Stack>
);
}
interface StatBarProps {
value: number;
max?: number;
label?: string;
color?: string;
}
function calcPct(value: number, max: number): number {
if (max <= 0) return 0;
return Math.min((value / max) * 100, 100);
}
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
const pct = calcPct(value, max);
return (
<Group gap="xs" wrap="nowrap">
{label && (
<Text size="xs" w={80} ta="right" c="dimmed">
{label}
</Text>
)}
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: "var(--mantine-color-default-border)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 4,
background: `var(--mantine-color-${color}-6)`,
transition: "width 0.3s ease",
}}
/>
</div>
<Text size="xs" w={30} c="dimmed">
{value}
</Text>
</Group>
);
}
interface TypeBadgeProps {
type: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const TYPE_COLORS: Record<string, string> = {
normal: "gray",
fire: "red",
water: "blue",
electric: "yellow",
grass: "green",
ice: "cyan",
fighting: "orange",
poison: "grape",
ground: "yellow",
flying: "indigo",
psychic: "pink",
bug: "lime",
rock: "orange",
ghost: "violet",
dragon: "indigo",
dark: "dark",
steel: "gray",
fairy: "pink",
};
function normalizeType(type: unknown): string {
if (typeof type === "string") return type;
if (type == null) return "";
return String(type);
}
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
const typeStr = normalizeType(type);
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
return (
<Badge color={color} size={size} variant="filled" tt="capitalize">
{typeStr}
</Badge>
);
}
@@ -1,192 +0,0 @@
import type { ComponentType } from "react";
import { createElement } from "react";
import {
Stack,
Group,
Flex,
Grid,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor,
NumberFormatter,
Badge,
Card,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
List,
Timeline,
Accordion,
Indicator,
Pill,
Spoiler,
Progress,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
} from "@mantine/core";
import {
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
} from "@mantine/charts";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
function SafeAnchor(props: Record<string, unknown>) {
const safeHref = getSafeApplicationUrl(props.href);
const target = safeHref ? props.target : undefined;
return createElement(Anchor as never, {
...props,
component: safeHref ? "a" : "span",
href: safeHref,
target,
rel: target === "_blank" ? SAFE_NEW_TAB_REL : undefined,
});
}
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Stack,
Group,
Flex,
Grid,
"Grid.Col": Grid.Col,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Badge,
Card,
"Card.Section": Card.Section,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
List,
"List.Item": List.Item,
Timeline,
"Timeline.Item": Timeline.Item,
Accordion,
"Accordion.Item": Accordion.Item,
"Accordion.Control": Accordion.Control,
"Accordion.Panel": Accordion.Panel,
Indicator,
Pill,
Spoiler,
Progress,
"Progress.Section": Progress.Section,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
"Avatar.Group": Avatar.Group,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
};
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
safe[key] = sanitizeData(value);
}
return safe;
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = (v: number) => Math.round(v);
safeMath.floor = (v: number) => Math.floor(v);
safeMath.ceil = (v: number) => Math.ceil(v);
safeMath.abs = (v: number) => Math.abs(v);
safeMath.min = (...args: number[]) => Math.min(...args);
safeMath.max = (...args: number[]) => Math.max(...args);
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
safeMath.sqrt = (v: number) => Math.sqrt(v);
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = (v: object) => Object.keys(v);
safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
Object.freeze(safeObject);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
Number: (v: unknown) => Number(v),
Boolean: (v: unknown) => Boolean(v),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
describe("custom widget migration state", () => {
it("recognizes the preserved legacy definition error and stops retries", () => {
const error = {
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("does not label an invalid v2 configuration as a legacy migration", () => {
const error = {
message: "Custom widget configuration needs repair",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("allows transient request failures to retry", () => {
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
});
});
@@ -0,0 +1,13 @@
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
}
export function isTerminalCustomWidgetDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data) || typeof data.code !== "string") return false;
return TERMINAL_DEFINITION_CODES.has(data.code);
}
@@ -0,0 +1,166 @@
"use client";
import type { ReactNode } from "react";
import { fetchApi, clientApi } from "@homarr/api/client";
import type {
CustomWidgetPublishedQueryState,
CustomJsxRequestCapability,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "@homarr/custom-widgets/runtime";
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useQueryClient } from "@tanstack/react-query";
interface WidgetDefinitionProviderProps {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
children: ReactNode;
}
const INACTIVE_PORT: CustomWidgetRuntimePort = {
query: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
executeAction: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
invalidate: async () => undefined,
confirm: async () => false,
notify: () => undefined,
};
const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
const t = useScopedI18n("widget.customApi.customJsx");
return {
requestIdRequired: t("requestIdRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
requestFailed: t("requestFailed"),
loading: t("loading"),
retry: t("retry"),
widgetItemUnavailable: t("widgetItemUnavailable"),
actionsDisabledEditMode: t("actionsDisabledEditMode"),
actionSimulated: t("actionSimulated"),
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
};
};
export function InactiveWidgetDefinitionProvider({
definitionId,
isEditMode,
children,
}: Pick<WidgetDefinitionProviderProps, "definitionId" | "isEditMode" | "children">) {
const messages = useRuntimeMessages();
return (
<CustomWidgetRuntimeProvider
definitionId={definitionId}
isEditMode={isEditMode ?? false}
requestCapabilities={[]}
port={INACTIVE_PORT}
messages={messages}
>
{children}
</CustomWidgetRuntimeProvider>
);
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const utils = clientApi.useUtils();
const queryClient = useQueryClient();
const { openConfirmModal } = useConfirmModal();
const messages = useRuntimeMessages();
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
if (previewSessionId) {
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
},
});
return;
}
if (!itemId) return;
if (targets.length === 0) return;
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === itemId && targets.includes(String(key[2]));
},
}),
];
if (
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
}
await Promise.all(tasks);
},
confirm: ({ title, message, confirmLabel, destructive }) =>
new Promise((resolve) => {
openConfirmModal({
title,
children: message,
labels: { confirm: confirmLabel },
confirmProps: { color: destructive ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
};
return (
<CustomWidgetRuntimeProvider
itemId={props.itemId}
definitionId={props.definitionId}
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
queriesDisabled={props.queriesDisabled}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
messages={messages}
setQueryState={props.setQueryState}
>
{props.children}
</CustomWidgetRuntimeProvider>
);
}
+11 -6
View File
@@ -129,13 +129,16 @@ export interface WidgetDefinition {
export const supportsAdvancedFocus = (definition: object) =>
!("supportsAdvancedFocus" in definition) || definition.supportsAdvancedFocus !== false;
export const getWidgetQueryKeys = (definition: {
kind: string;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
}): readonly QueryKey[] => {
export const getWidgetQueryKeys = (
definition: {
kind?: string;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
},
kind?: WidgetKind,
): readonly QueryKey[] => {
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
return [definition.queryKey ?? [["widget", definition.kind]]];
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
};
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
@@ -178,6 +181,8 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
width: number;
height: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
@@ -1,29 +0,0 @@
import { api, trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "downloadsWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
await Promise.all(
items.map(async (item) => {
if (item.integrationIds.length === 0) return;
const input = {
integrationIds: item.integrationIds,
limitPerIntegration: Number(item.options.limitPerIntegration) || 50,
};
try {
const data = await api.widget.downloads.getJobsAndStatuses(input);
queryClient.setQueryData(trpc.widget.downloads.getJobsAndStatuses.queryKey(input), data);
logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length });
} catch (error) {
logger.warn("Failed to prefetch downloads widget data", { error });
}
}),
);
};
export default prefetchAllAsync;
@@ -17,7 +17,7 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
return (
<Stack h="100%" align="center" justify="center" gap="md">
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
@@ -46,7 +46,6 @@ describe("WidgetError", () => {
MantineProvider,
null,
createElement(WidgetError, {
kind: "app",
error: new Error(secret),
resetErrorBoundary: vi.fn(),
}),
+4 -10
View File
@@ -1,30 +1,24 @@
import { useMemo } from "react";
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
import { TRPCClientError } from "@trpc/client";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetDefinition } from "..";
import { widgetImports } from "..";
import type { WidgetDefinition } from "../definition";
import { ErrorBoundaryError } from "./base";
import type { BaseWidgetErrorProps } from "./base-component";
import { BaseWidgetError } from "./base-component";
interface WidgetErrorProps {
kind: WidgetKind;
definition?: WidgetDefinition;
error: unknown;
resetErrorBoundary: () => void;
}
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErrorProps) => {
if (error instanceof ErrorBoundaryError) {
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
}
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
if (widgetTrpcErrorData) {
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
}
+153
View File
@@ -0,0 +1,153 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import {
createRetryableLoader,
loadAllWidgetDefinitions,
loadWidgetComponent,
loadWidgetDefinition,
loadWidgetModule,
loadWidgetResources,
reduceWidgetOptionsWithDefinition,
widgetKinds,
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
import { getWidgetQueryKeys } from "./definition";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
});
it("returns the same derived definition promise for every render", () => {
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
});
it("returns stable component and combined resource promises", () => {
expect(loadWidgetComponent("clock")).toBe(loadWidgetComponent("clock"));
expect(loadWidgetResources("clock")).toBe(loadWidgetResources("clock"));
});
it("returns the same all-definitions promise for every modal render", () => {
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
});
it("retries a failed loader instead of retaining its rejected promise", async () => {
let attempts = 0;
const loader = createRetryableLoader(
new Map([
[
"example",
() => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error("temporary failure"));
return Promise.resolve("loaded");
},
],
]),
);
await expect(loader("example")).rejects.toThrow("temporary failure");
await expect(loader("example")).resolves.toBe("loaded");
expect(attempts).toBe(2);
});
it("covers every declared widget kind", () => {
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
});
it("loads matching definitions and component loaders for every widget", async () => {
const definitions = await loadAllWidgetDefinitions();
await Promise.all(
widgetKinds.map(async (kind) => {
const module = await loadWidgetModule(kind);
const canonicalComponent = await module.componentLoader();
const component = await loadWidgetComponent(kind);
const resources = await loadWidgetResources(kind);
expect(definitions.get(kind)).toBe(module.definition);
expect(component.default).toBe(canonicalComponent.default);
expect(component.default).toBeDefined();
expect(resources.definition).toBe(module.definition);
expect(resources.Component).toBe(component.default);
}),
);
}, 30_000);
it("preserves every widget option default", async () => {
const definitions = await loadAllWidgetDefinitions();
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
for (const definition of definitions.values()) {
const options = definition.createOptions(settings);
const reduced = reduceWidgetOptionsWithDefinition(definition, settings);
expect(Object.keys(reduced)).toEqual(Object.keys(options));
expect(Object.values(reduced).every((value) => value !== undefined)).toBe(true);
}
});
it("keeps the lightweight integration support map aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
for (const kind of widgetKinds) {
const definition = definitions.get(kind);
const supportedIntegrations =
definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
}
});
it("keeps the lightweight catalog icons aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
for (const kind of widgetKinds) {
expect(widgetCatalogIcons[kind]).toBe(definitions.get(kind)?.icon);
}
});
it("declares the real query prefix for widget kinds that share a router", async () => {
const definitions = await loadAllWidgetDefinitions();
const expectedQueryKeys = new Map([
["anchorNote", [["widget", "anchorNotes"]]],
["beszelAlerts", [["widget", "beszel", "getAlerts"]]],
["clock", [["widget", "weather", "atLocation"]]],
["mediaMissing", [["widget", "mediaOrganizer", "getData"]]],
["mediaRequests-requestList", [["widget", "mediaRequests", "getLatestRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests", "getStats"]]],
["smartHome-entityState", [["widget", "smartHome"]]],
] as const);
for (const [kind, queryKey] of expectedQueryKeys) {
expect(definitions.get(kind)?.queryKey).toEqual(queryKey);
}
});
it("keeps the client polling policy aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
const expectedByQueryKey = new Map<string, PollingPolicy>();
for (const kind of widgetKinds) {
const definition = definitions.get(kind);
if (definition?.refetchInterval === undefined) continue;
for (const queryKey of getWidgetQueryKeys(definition, kind)) {
const path = queryKey[0];
if (!Array.isArray(path) || (path[0] !== "widget" && path[0] !== "docker")) continue;
const entry = { queryKey, intervalSeconds: definition.refetchInterval } as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
}
}
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
);
});
});
+229
View File
@@ -0,0 +1,229 @@
import type { ComponentType } from "react";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetComponentProps, WidgetDefinition } from "./definition";
import type { WidgetOptionDefinition } from "./options";
type WidgetModule = {
definition: WidgetDefinition;
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
};
type WidgetComponentModule = {
default: ComponentType<WidgetComponentProps<WidgetKind>>;
};
export type WidgetResources = {
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
};
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
app: () => import("./app") as Promise<WidgetModule>,
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
notebook: () => import("./notebook") as Promise<WidgetModule>,
iframe: () => import("./iframe") as Promise<WidgetModule>,
video: () => import("./video") as Promise<WidgetModule>,
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
calendar: () => import("./calendar") as Promise<WidgetModule>,
downloads: () => import("./downloads") as Promise<WidgetModule>,
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
releases: () => import("./releases") as Promise<WidgetModule>,
firewall: () => import("./firewall") as Promise<WidgetModule>,
notifications: () => import("./notifications") as Promise<WidgetModule>,
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
coolify: () => import("./coolify") as Promise<WidgetModule>,
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
timetable: () => import("./timetable") as Promise<WidgetModule>,
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
umami: () => import("./umami") as Promise<WidgetModule>,
vpn: () => import("./vpn") as Promise<WidgetModule>,
ups: () => import("./ups") as Promise<WidgetModule>,
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
traefik: () => import("./traefik") as Promise<WidgetModule>,
customApi: () => import("./custom-api") as Promise<WidgetModule>,
};
// Keep these imports explicit so Turbopack can create one discoverable chunk per
// widget implementation. Loading this registry does not load the implementations.
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
app: () => import("./app/component") as Promise<WidgetComponentModule>,
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise<WidgetComponentModule>,
anchorNote: () => import("./anchor-note/component") as Promise<WidgetComponentModule>,
notebook: () => import("./notebook/component") as Promise<WidgetComponentModule>,
iframe: () => import("./iframe/component") as Promise<WidgetComponentModule>,
video: () => import("./video/component") as Promise<WidgetComponentModule>,
dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise<WidgetComponentModule>,
dnsHoleControls: () => import("./dns-hole/controls/component") as Promise<WidgetComponentModule>,
"smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise<WidgetComponentModule>,
"smartHome-executeAutomation": () =>
import("./smart-home/execute-automation/component") as Promise<WidgetComponentModule>,
stockPrice: () => import("./stocks/component") as Promise<WidgetComponentModule>,
mediaServer: () => import("./media-server/component") as Promise<WidgetComponentModule>,
calendar: () => import("./calendar/component") as Promise<WidgetComponentModule>,
downloads: () => import("./downloads/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestList": () => import("./media-requests/list/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise<WidgetComponentModule>,
mediaMissing: () => import("./media-missing/component") as Promise<WidgetComponentModule>,
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
networkControllerStatus: () =>
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
indexerManager: () => import("./indexer-manager/component") as Promise<WidgetComponentModule>,
healthMonitoring: () => import("./health-monitoring/component") as Promise<WidgetComponentModule>,
mediaTranscoding: () => import("./media-transcoding/component") as Promise<WidgetComponentModule>,
minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise<WidgetComponentModule>,
dockerContainers: () => import("./docker/component") as Promise<WidgetComponentModule>,
releases: () => import("./releases/component") as Promise<WidgetComponentModule>,
firewall: () => import("./firewall/component") as Promise<WidgetComponentModule>,
notifications: () => import("./notifications/component") as Promise<WidgetComponentModule>,
mediaReleases: () => import("./media-releases/component") as Promise<WidgetComponentModule>,
systemResources: () => import("./system-resources/component") as Promise<WidgetComponentModule>,
coolify: () => import("./coolify/component") as Promise<WidgetComponentModule>,
systemDisks: () => import("./system-disks/component") as Promise<WidgetComponentModule>,
timetable: () => import("./timetable/component") as Promise<WidgetComponentModule>,
"immich-serverStats": () => import("./immich/server-stats/component") as Promise<WidgetComponentModule>,
"immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise<WidgetComponentModule>,
paperlessNgx: () => import("./paperless-ngx/component") as Promise<WidgetComponentModule>,
patchmon: () => import("./patchmon/component") as Promise<WidgetComponentModule>,
tracearr: () => import("./tracearr/component") as Promise<WidgetComponentModule>,
speedtestTracker: () => import("./speedtest-tracker/component") as Promise<WidgetComponentModule>,
uptimeKuma: () => import("./uptime-kuma/component") as Promise<WidgetComponentModule>,
audioStats: () => import("./audio-stats/component") as Promise<WidgetComponentModule>,
umami: () => import("./umami/component") as Promise<WidgetComponentModule>,
vpn: () => import("./vpn/component") as Promise<WidgetComponentModule>,
ups: () => import("./ups/component") as Promise<WidgetComponentModule>,
beszelSystemTable: () => import("./beszel-system-table/component") as Promise<WidgetComponentModule>,
beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise<WidgetComponentModule>,
beszelAlerts: () => import("./beszel-alerts/component") as Promise<WidgetComponentModule>,
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
};
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
const promises = new Map<TKey, Promise<TValue>>();
return (key: TKey) => {
const existing = promises.get(key);
if (existing) return existing;
const loader = loaders.get(key);
if (!loader) throw new Error(`No loader is registered for ${String(key)}`);
const promise = loader();
promises.set(key, promise);
void promise.catch(() => {
if (promises.get(key) === promise) promises.delete(key);
});
return promise;
};
};
export const loadWidgetModule = createRetryableLoader(
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
);
export const loadWidgetComponent = createRetryableLoader(
new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise<WidgetComponentModule>][]),
);
export const loadWidgetResources = createRetryableLoader(
new Map(
(Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [
kind,
async () => {
const [{ definition }, { default: Component }] = await Promise.all([
loadWidgetModule(kind),
loadWidgetComponent(kind),
]);
return { definition, Component } satisfies WidgetResources;
},
]),
),
);
export const loadWidgetDefinition = (kind: WidgetKind) => {
const existing = definitionPromises.get(kind);
if (existing) return existing;
const promise = loadWidgetModule(kind).then(({ definition }) => definition);
definitionPromises.set(kind, promise);
void promise.catch(() => {
if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind);
});
return promise;
};
let allWidgetDefinitionsPromise: Promise<Map<WidgetKind, WidgetDefinition>> | undefined;
export const loadAllWidgetDefinitions = () => {
if (allWidgetDefinitionsPromise) return allWidgetDefinitionsPromise;
const promise = Promise.all(widgetKinds.map(async (kind) => [kind, await loadWidgetDefinition(kind)] as const)).then(
(entries) => new Map(entries),
);
allWidgetDefinitionsPromise = promise;
void promise.catch(() => {
if (allWidgetDefinitionsPromise === promise) allWidgetDefinitionsPromise = undefined;
});
return promise;
};
export const reduceWidgetOptionsWithDefinition = (
definition: WidgetDefinition,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};
@@ -15,11 +15,11 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { WidgetDefinition } from "../definition";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
@@ -33,8 +33,9 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions;
}
interface ModalProps<TSort extends WidgetKind> {
export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort;
definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
@@ -45,7 +46,7 @@ interface ModalProps<TSort extends WidgetKind> {
appId?: string;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
@@ -56,7 +57,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
z.config({
customError: zodErrorMap(t),
});
const { definition } = widgetImports[innerProps.kind];
const { definition } = innerProps;
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
@@ -95,6 +96,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
@@ -123,44 +125,42 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
const widgetFormContent = (
<Stack>
{innerProps.integrationSupport && (
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
1
}
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
{canConfigureWidget &&
Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
{showAppTab ? (
<Button
variant="subtle"
+12
View File
@@ -101,6 +101,12 @@ export interface OptionLocation {
}
const optionsFactory = {
internal: <T>(input: CommonInput<T> & { defaultValue: T }) => ({
type: "internal" as const,
defaultValue: input.defaultValue,
withDescription: false,
skipContextMenu: true,
}),
switch: (input?: CommonInput<boolean>) => ({
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
@@ -219,6 +225,12 @@ const optionsFactory = {
defaultValue: input?.defaultValue ?? "",
withDescription: input?.withDescription ?? false,
}),
customWidgetConfiguration: (input?: CommonInput<Record<string, unknown>>) => ({
type: "customWidgetConfiguration" as const,
defaultValue: input?.defaultValue ?? {},
withDescription: input?.withDescription ?? false,
skipContextMenu: true,
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
) => ({
+8 -10
View File
@@ -1,26 +1,20 @@
import { cache } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import type { WidgetKind } from "@homarr/definitions";
import { createSettings } from "@homarr/settings/creator";
import { reduceWidgetOptionsWithDefaultValues } from ".";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import prefetchForDownloads from "./downloads/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
const prefetchCallbacks: Partial<{
[TKind in WidgetKind]: Prefetch<TKind>;
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
downloads: prefetchForDownloads,
};
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
@@ -36,11 +30,15 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
return;
}
const serverSettings = await cachedGetServerSettingsAsync(db);
const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]);
const itemsWithDefaultOptions = items.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
options: reduceWidgetOptionsWithDefinition(
definition,
createSettings({ user: null, serverSettings }),
item.options,
),
}));
await callback(queryClient, itemsWithDefaultOptions as never[]);
+35
View File
@@ -0,0 +1,35 @@
/**
* Query policy used by the root client provider.
*
* Keep this module free of widget component/definition imports. Importing the
* full widget registry from a root provider makes every widget part of the
* initial client graph, even before a board has rendered one widget.
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
] as const;
+38 -29
View File
@@ -1,42 +1,51 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from "..";
import { loadAllWidgetDefinitions } from "../manifest";
describe("Widget properties with description should have matching translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
const doesOptionRenderTranslatedCopy = (option: unknown) => {
if (typeof option !== "object" || option === null || !("type" in option)) return false;
return option.type !== "internal" && option.type !== "customWidgetConfiguration";
};
expect("description" in value).toBe(optionValue.withDescription);
const widgetDefinitions = await loadAllWidgetDefinitions();
const enTranslation = await createLanguageMapping().en();
describe("Widget properties with description should have matching translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.entries(definition.createOptions({} as SettingsContextProps))
.filter(([, option]) => doesOptionRenderTranslatedCopy(option))
.forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
expect("description" in value).toBe(optionValue.withDescription);
});
});
});
});
}
});
describe("Widget properties should have matching name translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
describe("Widget properties should have matching name translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.entries(definition.createOptions({} as SettingsContextProps))
.filter(([, option]) => doesOptionRenderTranslatedCopy(option))
.forEach(([optionKey]) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
expect("label" in value).toBe(true);
expect("label" in value).toBe(true);
});
});
});
});
}
});
@@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { createTrpcQueryKey } from "./trpc-query-key";
describe("createTrpcQueryKey", () => {
test("matches the tRPC TanStack query key shape", () => {
expect(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", { integrationIds: ["one"], limit: 50 })).toEqual([
["widget", "downloads", "getJobsAndStatuses"],
{ input: { integrationIds: ["one"], limit: 50 }, type: "query" },
]);
});
});
+4
View File
@@ -0,0 +1,4 @@
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
path.split("."),
{ input, type: "query" as const },
];
+1 -1
View File
@@ -84,7 +84,7 @@ export default function WeatherWidget({
}
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
weather: RouterOutputs["widget"]["weather"]["atLocation"];
weather: NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
}
interface CompactWeatherProps extends WeatherProps {