feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
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,330 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, 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={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={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
|
||||
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,
|
||||
};
|
||||
|
||||
function JsonOption({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => JSON.stringify(value ?? null, null, 2));
|
||||
const [error, setError] = useState<string>();
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
id={`${label}-json-option`}
|
||||
label={label}
|
||||
description={description}
|
||||
language="json"
|
||||
value={draft}
|
||||
height="160px"
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
onChange(JSON.parse(next) as unknown);
|
||||
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);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
|
||||
import { Avatar, Badge, Combobox, Group, InputBase, Loader, Stack, Text, useCombobox } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -16,6 +18,7 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
options,
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const labels = useScopedI18n("widget.customApi.picker");
|
||||
const form = useFormContext();
|
||||
const board = useOptionalBoard();
|
||||
const currentValue = form.values.options[property] as string;
|
||||
@@ -25,7 +28,10 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]);
|
||||
const definitions = useMemo(
|
||||
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
@@ -38,7 +44,6 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
);
|
||||
|
||||
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
@@ -47,43 +52,74 @@ 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);
|
||||
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}>
|
||||
<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}>
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
|
||||
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
|
||||
@@ -1,228 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Center, Code, Loader, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, 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 { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createCustomWidgetDisplayComponents } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
|
||||
|
||||
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
|
||||
const [lastSuccess, setLastSuccess] = useState(false);
|
||||
|
||||
const buttonLabel = (data.buttonLabel as string) ?? t("execute");
|
||||
const buttonColor = (data.buttonColor as string) ?? "blue";
|
||||
const confirmText = (data.confirmText as string) || "";
|
||||
const requiresConfirmation = data.requiresConfirmation === true;
|
||||
const successMessage = (data.successMessage as string) || t("executeSuccess");
|
||||
const itemId = data.widgetItemId as string | undefined;
|
||||
const isEditMode = data.isEditMode === true;
|
||||
|
||||
const handleExecute = async (confirmed: boolean) => {
|
||||
if (!itemId || isEditMode) return;
|
||||
setLastSuccess(false);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({ itemId, confirmed });
|
||||
if (result.success) {
|
||||
setLastSuccess(true);
|
||||
showSuccessNotification({ title: buttonLabel, message: successMessage });
|
||||
setTimeout(() => setLastSuccess(false), 3000);
|
||||
} else {
|
||||
showErrorNotification({ title: buttonLabel, message: result.error ?? t("executeFailed") });
|
||||
}
|
||||
} catch {
|
||||
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (confirmText || requiresConfirmation) {
|
||||
openConfirmModal({
|
||||
title: buttonLabel,
|
||||
children: confirmText || t("executeConfirm"),
|
||||
onConfirm: () => void handleExecute(true),
|
||||
});
|
||||
} else {
|
||||
void handleExecute(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Button
|
||||
size="lg"
|
||||
color={buttonColor}
|
||||
onClick={handleClick}
|
||||
loading={executeMutation.isPending}
|
||||
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
|
||||
variant={lastSuccess ? "light" : "filled"}
|
||||
disabled={!itemId || isEditMode}
|
||||
>
|
||||
{executeMutation.isPending ? t("executing") : buttonLabel}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export const createDisplayComponents = (openJsonLabel: string) =>
|
||||
createCustomWidgetDisplayComponents({
|
||||
actionButton: ActionButtonDisplay,
|
||||
customJsx: CustomJsxDisplay,
|
||||
openJsonLabel,
|
||||
});
|
||||
|
||||
export default function CustomApiWidget({ options, itemId, isEditMode }: 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomApiWidgetInner
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
isEditMode={isEditMode}
|
||||
refreshInterval={refreshInterval as number}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomApiWidgetInner({
|
||||
definitionId,
|
||||
export default function CustomApiWidget({
|
||||
options,
|
||||
itemId,
|
||||
isEditMode,
|
||||
refreshInterval,
|
||||
}: {
|
||||
definitionId: string;
|
||||
itemId: string | undefined;
|
||||
isEditMode: boolean;
|
||||
refreshInterval: number;
|
||||
}) {
|
||||
removeItem,
|
||||
}: WidgetComponentProps<"customApi">) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const tCustomWidget = useScopedI18n("customWidget");
|
||||
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
|
||||
const displayComponents = useMemo(() => createDisplayComponents(t("openJson")), [t]);
|
||||
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
|
||||
const intervalMs = Math.max(1000, safeInterval * 1000);
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
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(
|
||||
{ itemId: itemId ?? "" },
|
||||
{
|
||||
enabled: Boolean(itemId) && !isEditMode,
|
||||
refetchInterval: (query) => {
|
||||
const result = query.state.data as Record<string, unknown> | undefined;
|
||||
if (
|
||||
result?.type === "actionButton" ||
|
||||
result?.type === "disabled" ||
|
||||
result?.type === "networkAccessNeedsReview"
|
||||
) {
|
||||
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) => (isTerminalDefinitionError(currentQuery.state.error) ? false : intervalMs),
|
||||
retry: (failureCount, error) => !isTerminalDefinitionError(error) && failureCount < 3,
|
||||
},
|
||||
);
|
||||
|
||||
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 (error) {
|
||||
const isNotFound = error.data?.code === "NOT_FOUND";
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isNotFound ? t("definitionNotFound") : tCustomJsx("requestFailed")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("editModePending")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
if (query.error) {
|
||||
const errorCode = query.error.data?.code;
|
||||
if (isEditMode && errorCode === "NOT_FOUND") {
|
||||
return <Unavailable message={t("editModePending")} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const widgetData = data as Record<string, unknown>;
|
||||
const dataType = widgetData.type as string | undefined;
|
||||
|
||||
if (dataType === "disabled") {
|
||||
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCustomWidget("widget.disabled")}
|
||||
</Text>
|
||||
</Center>
|
||||
<Unavailable
|
||||
message={
|
||||
isUnavailable
|
||||
? t("definitionNotFound")
|
||||
: errorCode === "PRECONDITION_FAILED"
|
||||
? t("configurationNeedsRepair")
|
||||
: tCustomJsx("requestFailed")
|
||||
}
|
||||
danger={!isUnavailable}
|
||||
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
|
||||
onRemove={removeItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (dataType === "networkAccessNeedsReview") {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{tCustomJsx("networkAccessNeedsReview")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
const enrichedData =
|
||||
dataType === "actionButton" || dataType === "customJsx"
|
||||
? { ...widgetData, widgetDefinitionId: definitionId, widgetItemId: itemId, isEditMode }
|
||||
: widgetData;
|
||||
return <Component data={enrichedData} />;
|
||||
}
|
||||
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 isTerminalDefinitionError(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)) return false;
|
||||
return data.code === "NOT_FOUND" || data.code === "FORBIDDEN" || data.code === "PRECONDITION_FAILED";
|
||||
}
|
||||
|
||||
function Unavailable({
|
||||
message,
|
||||
danger = false,
|
||||
removeLabel,
|
||||
onRemove,
|
||||
}: {
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
removeLabel?: 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>
|
||||
{removeLabel && onRemove && (
|
||||
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,27 @@ 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, vi } from "vitest";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
||||
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 { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
|
||||
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
|
||||
|
||||
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
|
||||
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
@@ -31,6 +38,10 @@ const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -49,6 +60,14 @@ describe("CustomJsxDisplay", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
writable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -60,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 })),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,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>',
|
||||
@@ -105,26 +190,6 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(container.textContent).not.toContain("GAMMA");
|
||||
});
|
||||
|
||||
it("renders fetched data through an interpreter-created SubFetch child", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const rendered = renderSafeJsx({
|
||||
template:
|
||||
"<SubFetch>{(detail, meta) => <Text>{detail.types.map((entry) => entry.name).join(', ')} · {meta.status}</Text>}</SubFetch>",
|
||||
components: {
|
||||
SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) =>
|
||||
props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never,
|
||||
Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never,
|
||||
},
|
||||
bindings: {},
|
||||
});
|
||||
|
||||
await act(async () => root.render(rendered.node));
|
||||
|
||||
expect(container.textContent).toBe("grass, poison · 200");
|
||||
});
|
||||
|
||||
it("blocks reflective properties assembled with string concatenation", async () => {
|
||||
await renderDisplay({
|
||||
template: '<Text>{data["con" + "structor"]}</Text>',
|
||||
@@ -373,14 +438,14 @@ describe("Custom JSX component registry", () => {
|
||||
it("has a runtime implementation for every enabled registry component", () => {
|
||||
const missing = enabledCustomJsxComponents
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, 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: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
renderSafeJsx({ template, components: CUSTOM_WIDGET_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
|
||||
describe("new collection/string methods", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -543,9 +608,14 @@ describe("shared Custom JSX examples", () => {
|
||||
|
||||
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
|
||||
const rendered = renderSafeJsx({
|
||||
template: example.template,
|
||||
components: WHITELISTED_COMPONENTS,
|
||||
bindings: SAFE_BINDINGS(sampleData),
|
||||
template: example.widget.template,
|
||||
components: CUSTOM_WIDGET_COMPONENTS,
|
||||
bindings: {
|
||||
...SAFE_BINDINGS(sampleData),
|
||||
status: {},
|
||||
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
|
||||
inputs: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered.warnings).toEqual([]);
|
||||
|
||||
@@ -1,47 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
|
||||
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { WidgetDefinitionProvider } from "./widget-definition-context";
|
||||
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 t = useScopedI18n("widget.customApi.customJsx");
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
|
||||
const components = useMemo(() => createWhitelistedComponents({ copy: t("copy"), copied: t("copied") }), [t]);
|
||||
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={data.data}
|
||||
requestCapabilities={capabilities}
|
||||
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"),
|
||||
interactive: t("interactive"),
|
||||
networkCapabilities: t("networkCapabilities"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const hasRuntime = typeof data.widgetItemId === "string" || typeof data.previewSessionId === "string";
|
||||
return hasRuntime ? (
|
||||
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 (
|
||||
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
|
||||
{renderer}
|
||||
</InactiveWidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WidgetDefinitionProvider
|
||||
definitionId={String(data.widgetDefinitionId ?? "")}
|
||||
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
|
||||
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
previewSessionId={previewSessionId}
|
||||
previewLiveActions={data.previewLiveActions === true}
|
||||
queriesDisabled={data.queriesDisabled === true}
|
||||
isEditMode={data.isEditMode === true}
|
||||
requestCapabilities={capabilities}
|
||||
setQueryState={publishQueryState}
|
||||
>
|
||||
{renderer}
|
||||
</WidgetDefinitionProvider>
|
||||
) : (
|
||||
renderer
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
|
||||
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),
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ 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 createWhitelistedComponents = (copyLabels: { copy: string; copied: string }) =>
|
||||
export const createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
|
||||
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
|
||||
|
||||
export const SAFE_BINDINGS = createCustomJsxBindings;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBug,
|
||||
IconCalendar,
|
||||
IconCamera,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
IconMusic,
|
||||
IconNetwork,
|
||||
IconPhoto,
|
||||
IconPokeball,
|
||||
IconPin,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
@@ -133,6 +135,7 @@ const TABLER_ICON_IMPORTS = {
|
||||
IconHeart,
|
||||
IconStar,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconFlag,
|
||||
IconPin,
|
||||
IconPlayerPlay,
|
||||
@@ -183,6 +186,7 @@ const TABLER_ICON_IMPORTS = {
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconPokeball,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconVideo,
|
||||
@@ -223,6 +227,8 @@ const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { fetchApi, clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetPublishedQueryState,
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetRuntimeMessages,
|
||||
CustomWidgetRuntimePort,
|
||||
@@ -12,23 +13,32 @@ 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;
|
||||
}
|
||||
|
||||
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
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");
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const messages: CustomWidgetRuntimeMessages = {
|
||||
migrationRequired: t("migrationRequired"),
|
||||
return {
|
||||
requestIdRequired: t("requestIdRequired"),
|
||||
unsavedPreview: t("unsavedPreview"),
|
||||
invalidParams: t("invalidParams"),
|
||||
loadRequest: t("loadRequest"),
|
||||
@@ -43,6 +53,32 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
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) {
|
||||
@@ -72,18 +108,41 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
}),
|
||||
invalidate: async ({ itemId, previewSessionId, targets }) => {
|
||||
if (previewSessionId) {
|
||||
await utils.customWidget.previewQuery.invalidate();
|
||||
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;
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (targets.includes("parent")) tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
|
||||
if (targets.some((target) => target !== "parent")) tasks.push(utils.widget.customApi.queryRequest.invalidate());
|
||||
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 }) =>
|
||||
confirm: ({ title, message, confirmLabel, destructive }) =>
|
||||
new Promise((resolve) => {
|
||||
openConfirmModal({ title, children: message, onConfirm: () => resolve(true), onCancel: () => resolve(false) });
|
||||
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),
|
||||
@@ -94,10 +153,12 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
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>
|
||||
|
||||
@@ -107,6 +107,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
removeItem?: () => void;
|
||||
};
|
||||
|
||||
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
|
||||
|
||||
@@ -98,6 +98,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,
|
||||
@@ -216,6 +222,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>,
|
||||
) => ({
|
||||
|
||||
Reference in new issue
Block a user