merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -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;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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>,
|
||||
) => ({
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
|
||||
path.split("."),
|
||||
{ input, type: "query" as const },
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user