580 lines
19 KiB
TypeScript
580 lines
19 KiB
TypeScript
"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 { resolveCustomWidgetOptionsBinding, 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 = (() => {
|
|
if (!request) return null;
|
|
try {
|
|
return resolveCustomWidgetOptionsBinding(request, configuration);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const query = clientApi.customWidget.optionRequest.useQuery(
|
|
{ boardId: board?.id ?? "", definitionId, requestId, params: params ?? {} },
|
|
{ enabled: Boolean(board?.id && definitionId && requestId && request && params) },
|
|
);
|
|
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);
|
|
}
|