feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
263 files changed
+65503 -17816

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
@@ -18,8 +19,10 @@ import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
import { WidgetInternalInput } from "./widget-internal-input";
const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
location: WidgetLocationInput,
@@ -37,6 +40,7 @@ const mapping = {
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
@@ -0,0 +1,577 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { json } from "@codemirror/lang-json";
import {
Accordion,
Alert,
ColorInput,
Fieldset,
MultiSelect,
NumberInput,
Select,
Slider,
Stack,
Switch,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput, TimeInput } from "@mantine/dates";
import { IconAlertTriangle, IconBraces } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
const CodeMirror = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), { ssr: false });
type Schema = Record<string, unknown>;
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 schema = isRecord(definition?.optionsSchema) ? definition.optionsSchema : null;
const issues = useMemo(
() => (schema ? validateCustomWidgetOptions(schema, configuration) : []),
[configuration, schema],
);
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 || !schema) return null;
const properties = getEffectiveProperties(schema, configuration);
const required = getEffectiveRequired(schema, configuration);
const entries = sortOptionEntries(properties);
const regular = entries.filter(([, child]) => !isAdvancedOption(child));
const advanced = entries.filter(([, child]) => isAdvancedOption(child));
return (
<Stack gap="md">
{issues.length > 0 && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{labels("needsAttention", { message: issues[0]?.message ?? "" })}</Text>
</Alert>
)}
{regular.map(([name, candidate]) =>
isRecord(candidate) ? (
<OptionField
key={name}
name={name}
schema={candidate}
path={`options.${property}.${name}`}
configuration={configuration}
required={required.has(name)}
/>
) : null,
)}
{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, candidate]) =>
isRecord(candidate) ? (
<OptionField
key={name}
name={name}
schema={candidate}
path={`options.${property}.${name}`}
configuration={configuration}
required={required.has(name)}
/>
) : null,
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
<AdvancedJson value={configuration} onChange={(value) => form.setFieldValue(`options.${property}`, value)} />
</Stack>
);
};
function OptionField({
name,
schema,
path,
configuration,
required = false,
}: {
name: string;
schema: Schema;
path: string;
configuration: Record<string, unknown>;
required?: boolean;
}) {
const form = useFormContext();
const presentation = isRecord(schema["x-homarr"]) ? schema["x-homarr"] : {};
const label = typeof schema.title === "string" ? schema.title : humanize(name);
const description = typeof schema.description === "string" ? schema.description : undefined;
const placeholder = typeof presentation.placeholder === "string" ? presentation.placeholder : undefined;
const control = typeof presentation.control === "string" ? presentation.control : inferControl(schema);
const input = form.getInputProps(path, { type: control === "switch" ? "checkbox" : "input" });
if (control === "json") {
return (
<JsonValueInput
label={label}
description={description}
required={required}
value={input.value}
onChange={(value) => form.setFieldValue(path, value)}
/>
);
}
if (schema.type === "object" && isRecord(schema.properties)) {
const value = isRecord(input.value) ? input.value : {};
const properties = getEffectiveProperties(schema, value);
const nestedRequired = getEffectiveRequired(schema, value);
return (
<Fieldset legend={`${label}${required ? " *" : ""}`}>
<Stack gap="sm">
{sortOptionEntries(properties).map(([childName, child]) =>
isRecord(child) ? (
<OptionField
key={childName}
name={childName}
schema={child}
path={`${path}.${childName}`}
configuration={configuration}
required={nestedRequired.has(childName)}
/>
) : null,
)}
</Stack>
</Fieldset>
);
}
const choices = getChoices(control === "multi-select" && isRecord(schema.items) ? schema.items : schema);
if (isRecord(presentation.optionsSource)) {
return (
<DynamicOptionsSelect
label={label}
description={description}
source={presentation.optionsSource}
configuration={configuration}
path={path}
numeric={
control === "multi-select" && isRecord(schema.items)
? schema.items.type === "number" || schema.items.type === "integer"
: schema.type === "number" || schema.type === "integer"
}
multiple={control === "multi-select"}
placeholder={placeholder}
required={required}
/>
);
}
if (control === "switch") return <Switch label={label} description={description} required={required} {...input} />;
if (control === "number")
return <NumberInput label={label} description={description} required={required} {...input} />;
if (control === "duration")
return <NumberInput label={label} description={description} required={required} suffix=" s" min={0} {...input} />;
if (control === "slider") {
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Slider
min={typeof schema.minimum === "number" ? schema.minimum : 0}
max={typeof schema.maximum === "number" ? schema.maximum : 100}
step={typeof schema.multipleOf === "number" ? schema.multipleOf : 1}
{...input}
/>
</Stack>
);
}
if (control === "select") {
const numeric = schema.type === "number" || schema.type === "integer";
return (
<Select
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
if (control === "multi-select") {
const itemSchema = isRecord(schema.items) ? schema.items : {};
const numeric = itemSchema.type === "number" || itemSchema.type === "integer";
return (
<MultiSelect
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
if (control === "date")
return (
<DateInput label={label} description={description} required={required} valueFormat="YYYY-MM-DD" {...input} />
);
if (control === "time") return <TimeInput label={label} description={description} required={required} {...input} />;
if (control === "color") return <ColorInput label={label} description={description} required={required} {...input} />;
if (control === "icon") {
return (
<Stack gap={4}>
<IconPicker label={label} withAsterisk={required} {...input} />
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
</Stack>
);
}
if (control === "timeZone") {
return (
<Select
label={label}
description={description}
data={Intl.supportedValuesOf("timeZone")}
searchable
required={required}
{...input}
/>
);
}
if (control === "textarea")
return (
<Textarea
label={label}
description={description}
placeholder={placeholder}
required={required}
autosize
minRows={3}
{...input}
/>
);
return (
<TextInput
label={label}
description={description}
placeholder={placeholder}
required={required}
type={control === "url" ? "url" : "text"}
{...input}
/>
);
}
function DynamicOptionsSelect({
label,
description,
source,
configuration,
path,
numeric,
multiple,
placeholder,
required,
}: {
label: string;
description?: string;
source: Schema;
configuration: Record<string, unknown>;
path: string;
numeric: boolean;
multiple: boolean;
placeholder?: string;
required: boolean;
}) {
const form = useFormContext();
const board = useOptionalBoard();
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
const input = form.getInputProps(path);
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 requestId = typeof source.requestId === "string" ? source.requestId : "";
const request = definition?.optionRequests.find((candidate) => candidate.id === requestId);
const params = Object.fromEntries(
Object.entries(request?.parameters ?? {}).flatMap(([name, type]) => {
const value = configuration[name];
return typeof value === type ? [[name, value as string | number | boolean]] : [];
}),
);
const query = clientApi.customWidget.optionRequest.useQuery(
{ boardId: board?.id ?? "", definitionId, requestId, params },
{ enabled: Boolean(board?.id && definitionId && requestId && request) },
);
const valuePath = typeof source.valuePath === "string" ? source.valuePath : "$.value";
const labelPath = typeof source.labelPath === "string" ? source.labelPath : "$.label";
const itemsPath = typeof source.itemsPath === "string" ? source.itemsPath : undefined;
const collection = itemsPath ? getByPath(query.data?.data, itemsPath) : query.data?.data;
const rows = Array.isArray(collection) ? collection : [];
const data = rows.flatMap((row) => {
const value = getByPath(row, valuePath);
const optionLabel = getByPath(row, labelPath);
return typeof value === "string" || typeof value === "number"
? [{ value: String(value), label: String(optionLabel ?? value) }]
: [];
});
const commonProps = {
label,
description,
placeholder,
data,
searchable: true,
required,
disabled: !request || query.isError,
rightSection: query.isFetching ? "…" : undefined,
onBlur: input.onBlur,
};
if (multiple) {
return (
<MultiSelect
{...commonProps}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
return (
<Select
{...commonProps}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
function AdvancedJson({
value,
onChange,
}: {
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const labels = useScopedI18n("widget.customApi.configuration");
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value, null, 2)), [value]);
return (
<Accordion variant="contained">
<Accordion.Item value="json">
<Accordion.Control icon={<IconBraces size={16} />}>{labels("advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeMirror
value={draft}
extensions={[json()]}
height="220px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
if (!isRecord(parsed)) throw new Error(labels("jsonObjectRequired"));
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs" mt="xs">
{error}
</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
function JsonValueInput({
label,
description,
required,
value,
onChange,
}: {
label: string;
description?: string;
required: boolean;
value: unknown;
onChange(value: unknown): void;
}) {
const labels = useScopedI18n("widget.customApi.configuration");
const [draft, setDraft] = useState(() => JSON.stringify(value ?? null, null, 2));
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value ?? null, null, 2)), [value]);
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<CodeMirror
value={draft}
extensions={[json()]}
height="160px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs">
{error}
</Text>
)}
</Stack>
);
}
function inferControl(schema: Schema) {
if (Array.isArray(schema.enum)) return "select";
if (schema.type === "boolean") return "switch";
if (schema.type === "number" || schema.type === "integer") return "number";
if (schema.format === "date") return "date";
if (schema.format === "time") return "time";
if (schema.format === "color") return "color";
if (schema.format === "uri") return "url";
return "text";
}
function getChoices(schema: Schema) {
return Array.isArray(schema.enum)
? schema.enum.flatMap((value) =>
typeof value === "string" || typeof value === "number" ? [{ value: String(value), label: String(value) }] : [],
)
: [];
}
function getEffectiveProperties(schema: Schema, configuration: Record<string, unknown>): Record<string, unknown> {
const properties = isRecord(schema.properties) ? schema.properties : {};
if (!isRecord(schema.if)) return properties;
const branch = matchesCondition(schema.if, configuration) ? schema.then : schema.else;
return isRecord(branch) && isRecord(branch.properties) ? { ...properties, ...branch.properties } : properties;
}
function getEffectiveRequired(schema: Schema, configuration: Record<string, unknown>) {
const required = new Set(
Array.isArray(schema.required) ? schema.required.filter((value): value is string => typeof value === "string") : [],
);
if (!isRecord(schema.if)) return required;
const branch = matchesCondition(schema.if, configuration) ? schema.then : schema.else;
if (isRecord(branch) && Array.isArray(branch.required)) {
branch.required
.filter((value): value is string => typeof value === "string")
.forEach((value) => required.add(value));
}
return required;
}
function sortOptionEntries(properties: Record<string, unknown>) {
return Object.entries(properties).toSorted((left, right) => optionOrder(left[1]) - optionOrder(right[1]));
}
function optionOrder(value: unknown) {
if (!isRecord(value) || !isRecord(value["x-homarr"])) return 0;
const order = value["x-homarr"].order;
return typeof order === "number" ? order : 0;
}
function isAdvancedOption(value: unknown) {
return isRecord(value) && isRecord(value["x-homarr"]) && value["x-homarr"].advanced === true;
}
function matchesCondition(schema: Schema, configuration: Record<string, unknown>): boolean {
const required = Array.isArray(schema.required)
? schema.required.filter((value): value is string => typeof value === "string")
: [];
if (required.some((key) => configuration[key] === undefined)) return false;
if (!isRecord(schema.properties)) return true;
return Object.entries(schema.properties).every(([key, candidate]) => {
if (!isRecord(candidate)) return true;
const value = configuration[key];
if (candidate.const !== undefined) return Object.is(value, candidate.const);
return !Array.isArray(candidate.enum) || candidate.enum.some((entry) => Object.is(entry, value));
});
}
function humanize(value: string) {
return value
.replaceAll(/[-_]/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (letter) => letter.toUpperCase());
}
function getByPath(value: unknown, path: string): unknown {
const normalized = path
.replace(/^\$\.?/u, "")
.replaceAll(/\[(\d+)\]/gu, ".$1")
.replaceAll(/\[['"]([^'"]+)['"]\]/gu, ".$1");
return normalized
.split(".")
.filter(Boolean)
.reduce<unknown>((current, segment) => {
if (["__proto__", "prototype", "constructor"].includes(segment.toLowerCase())) return undefined;
if (Array.isArray(current) && /^\d+$/u.test(segment)) return current[Number(segment)];
return 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,21 +1,39 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import {
Avatar,
Badge,
Card,
Combobox,
Group,
InputBase,
Loader,
Paper,
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";
import { useFormContext } from "./form";
const CustomJsxDisplay = dynamic(() => import("../custom-api/custom-jsx-display"), { ssr: false });
export const WidgetCustomWidgetSelectInput = ({
property,
kind,
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 +43,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,6 +59,7 @@ export const WidgetCustomWidgetSelectInput = ({
);
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
const selected = definitions.find((definition) => definition.value === currentValue);
const combobox = useCombobox({
onDropdownClose: () => {
@@ -47,43 +69,106 @@ 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>
{selected && (
<Card withBorder p="sm">
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={600}>
{selected.name}
</Text>
<Badge size="sm" variant="light">
{labels("preview")}
</Badge>
</Group>
<Paper withBorder p="sm" h={180} style={{ overflow: "auto" }}>
<CustomJsxDisplay
data={{
template: selected.template,
data: {},
status: Object.fromEntries(
selected.requestCapabilities.map((request) => [request.id, { loading: true }]),
),
options: selected.defaultOptions,
stateSchema: selected.stateSchema,
defaultState: selected.defaultState,
requestCapabilities: selected.requestCapabilities,
previewSessionId: "widget-picker",
queriesDisabled: true,
isEditMode: true,
}}
/>
</Paper>
</Stack>
</Card>
)}
</Stack>
);
};
@@ -0,0 +1,3 @@
import type { CommonWidgetInputProps } from "./common";
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
+70 -196
View File
@@ -1,228 +1,102 @@
"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: intervalMs,
retry: (failureCount, error) =>
!["NOT_FOUND", "PRECONDITION_FAILED"].includes(error.data?.code ?? "") && 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";
if (query.error) {
const errorCode = query.error.data?.code;
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>
<Unavailable
message={
errorCode === "NOT_FOUND"
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={errorCode !== "NOT_FOUND"}
removeLabel={errorCode === "NOT_FOUND" ? t("removeFromBoard") : undefined}
onRemove={removeItem}
/>
);
}
if (!data) {
if (isEditMode) {
return (
<Center h="100%">
<Text c="dimmed" size="sm" ta="center">
{t("editModePending")}
</Text>
</Center>
);
}
return null;
}
const widgetData = data as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
if (dataType === "disabled") {
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCustomWidget("widget.disabled")}
</Text>
</Center>
);
}
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 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>
);
}
@@ -6,16 +6,21 @@ 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,
customJsxExamples,
customJsxTablerIconNames,
enabledCustomJsxComponents,
} 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 +36,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;
@@ -105,26 +114,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 +362,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,8 +532,8 @@ 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,
template: example.widget.template,
components: CUSTOM_WIDGET_COMPONENTS,
bindings: SAFE_BINDINGS(sampleData),
});
@@ -1,11 +1,12 @@
"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 { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { WidgetDefinitionProvider } from "./widget-definition-context";
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
@@ -13,11 +14,39 @@ 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}
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 : {}}
stateSchema={isStringRecord(data.stateSchema) ? data.stateSchema : {}}
defaultState={isRecord(data.defaultState) ? data.defaultState : {}}
onStateChange={typeof data.onStateChange === "function" ? (data.onStateChange as never) : undefined}
requestCapabilities={capabilities}
components={components}
createBindings={SAFE_BINDINGS}
@@ -36,8 +65,10 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
>
{renderer}
</WidgetDefinitionProvider>
@@ -45,3 +76,21 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
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);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
}
+2
View File
@@ -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),
@@ -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;
@@ -223,6 +223,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,27 @@ 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 t = useScopedI18n("widget.customApi.customJsx");
const utils = clientApi.useUtils();
const queryClient = useQueryClient();
const { openConfirmModal } = useConfirmModal();
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: t("migrationRequired"),
requestIdRequired: t("requestIdRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
@@ -72,18 +77,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 +122,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>
+1
View File
@@ -103,6 +103,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"];
+12
View File
@@ -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>,
) => ({