feat: add custom widget flow workbench

This commit is contained in:
ajnart committed 2026-09-12 22:51:46 +02:00
1 parent c32a5317ca
commit a6bdbbb4ea
259 files changed
+23875 -1293

No files matched your search

+2 -1
View File
@@ -31,7 +31,8 @@
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts",
"./refetch-intervals": "./src/refetch-intervals.ts",
"./widget-card-shell": "./src/widget-card-shell.tsx"
"./widget-card-shell": "./src/widget-card-shell.tsx",
"./custom-api/integration-option": "./src/custom-api/integration-option.tsx"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -28,6 +28,8 @@ import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbenc
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetIntegrationOption } from "../custom-api/integration-option";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
@@ -128,6 +130,8 @@ function OptionField({
const form = useFormContext();
const input = form.getInputProps(path, { type: option.control === "switch" ? "checkbox" : "input" });
const common = { label: option.label, description: option.description };
if (option.control === "integration")
return <CustomWidgetIntegrationOption {...common} {...input} kinds={option.integrationKinds} />;
if (option.choicesFrom)
return <DynamicOptionField option={option} path={path} configuration={configuration} definitionId={definitionId} />;
if (option.control === "switch") return <Switch {...common} {...input} />;
@@ -1,7 +1,11 @@
"use client";
import type { ComponentProps } from "react";
import { useCallback, useMemo, useState } from "react";
import { useSession } from "@homarr/auth/client";
import { customWidgetExtensionsSchema } from "@homarr/custom-widgets/core";
import { isRecord } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { CustomJsxRendererMessages, CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
@@ -19,10 +23,24 @@ interface ScopedQueryState {
values: Record<string, CustomWidgetPublishedQueryState>;
}
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
export default function CustomJsxDisplay({
data,
inspect,
onInspect,
}: {
data: Record<string, unknown>;
inspect?: boolean;
onInspect?: ComponentProps<typeof CustomJsxRenderer>["onInspect"];
}) {
const session = useSession();
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
const diagnosticsT = useI18n("customWidget.editor.diagnostics");
const extensions = useMemo(() => {
const parsed = customWidgetExtensionsSchema.safeParse(data.extensions);
if (parsed.success) return parsed.data;
return undefined;
}, [data.extensions]);
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const copyLabel = actionT("copy");
const copiedLabel = t("copied");
@@ -69,9 +87,24 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
);
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const queryFixtures = Object.fromEntries(
Object.entries(baseStatus).map(([id, status]) => [
id,
{
data: baseData[id],
status: { ...(isRecord(status) ? status : {}), loading: isRecord(status) && status.loading === true },
},
]),
);
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
extensions={extensions}
inspect={inspect}
onInspect={onInspect}
preferenceStorageKey={
itemId ? `homarr:custom-widget:${itemId}:${session.data?.user.id ?? "anonymous"}:preferences` : undefined
}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
status={{
...baseStatus,
@@ -81,6 +114,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
components={components}
createBindings={SAFE_BINDINGS}
messages={{
close: actionT("close"),
noTemplate: t("noTemplate"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
bindingTypeConflict,
@@ -89,7 +123,12 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
);
if (!itemId && !previewSessionId) {
return (
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
<InactiveWidgetDefinitionProvider
definitionId={definitionId}
isEditMode={data.isEditMode === true}
nativeCapabilities={extensions?.native}
queryFixtures={queryFixtures}
>
{renderer}
</InactiveWidgetDefinitionProvider>
);
@@ -104,6 +143,8 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
queriesDisabled={data.queriesDisabled === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
nativeCapabilities={extensions?.native}
queryFixtures={queryFixtures}
setQueryState={publishQueryState}
>
{renderer}
@@ -0,0 +1,46 @@
"use client";
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
export function CustomWidgetIntegrationOption({
value,
onChange,
label,
description,
kinds,
error,
disabled,
}: {
value?: unknown;
onChange(value: string): void;
label: string;
description?: string;
kinds?: readonly string[];
error?: React.ReactNode;
disabled?: boolean;
}) {
const t = useI18n("customWidget.flow");
const integrations = clientApi.integration.all.useQuery();
const data = (integrations.data ?? [])
.filter(
(integration) => integration.permissions.hasUseAccess && (!kinds?.length || kinds.includes(integration.kind)),
)
.map((integration) => ({ value: integration.id, label: `${integration.name} (${integration.kind})` }));
return (
<Select
searchable
clearable
label={label}
description={description}
data={data}
value={typeof value === "string" ? value : null}
onChange={(next) => onChange(next ?? "")}
placeholder={t("chooseIntegration")}
nothingFoundMessage={t("noIntegrations")}
disabled={disabled || integrations.isLoading}
error={integrations.error?.message ?? error}
/>
);
}
@@ -1,5 +1,7 @@
"use client";
import type { CustomWidgetNativeCapability } from "@homarr/custom-widgets/core";
import { useMemo } from "react";
import type { ReactNode } from "react";
import { fetchApi, clientApi } from "@homarr/api/client";
@@ -9,7 +11,11 @@ import type {
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "@homarr/custom-widgets/runtime";
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import {
CustomWidgetRuntimeProvider,
observeCustomWidgetQueries,
useCustomWidgetQueryObserver,
} from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -23,6 +29,8 @@ interface WidgetDefinitionProviderProps {
previewLiveActions?: boolean;
queriesDisabled?: boolean;
isEditMode?: boolean;
nativeCapabilities?: Record<string, CustomWidgetNativeCapability>;
queryFixtures?: Record<string, CustomWidgetPublishedQueryState>;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
children: ReactNode;
@@ -60,14 +68,22 @@ const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
export function InactiveWidgetDefinitionProvider({
definitionId,
isEditMode,
nativeCapabilities,
queryFixtures,
children,
}: Pick<WidgetDefinitionProviderProps, "definitionId" | "isEditMode" | "children">) {
}: Pick<
WidgetDefinitionProviderProps,
"definitionId" | "isEditMode" | "nativeCapabilities" | "queryFixtures" | "children"
>) {
const messages = useRuntimeMessages();
return (
<CustomWidgetRuntimeProvider
definitionId={definitionId}
isEditMode={isEditMode ?? false}
requestCapabilities={[]}
nativeCapabilities={nativeCapabilities}
queryFixtures={queryFixtures}
queriesDisabled
port={INACTIVE_PORT}
messages={messages}
>
@@ -77,94 +93,129 @@ export function InactiveWidgetDefinitionProvider({
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const queryObserver = useCustomWidgetQueryObserver();
const utils = clientApi.useUtils();
const queryClient = useQueryClient();
const { openConfirmModal } = useConfirmModal();
const messages = useRuntimeMessages();
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
const port = useMemo<CustomWidgetRuntimePort>(
() => ({
readContent: (input, signal) => fetchApi.customWidget.contentRead.query(input, { signal }),
writeContent: (input) => fetchApi.customWidget.contentWrite.mutate(input),
queryNative: (input, signal) => fetchApi.customWidget.nativeQuery.query(input, { signal }),
executeNativeAction: (input) => fetchApi.customWidget.nativeAction.mutate(input),
subscribeNative: (input, onData, onError) => {
let active = true;
let timer: ReturnType<typeof setTimeout> | undefined;
let subscription: { unsubscribe(): void } | undefined;
const connect = () => {
if (!active) return;
subscription = utils.client.customWidget.nativeSubscribe.subscribe(input, {
onData,
onError,
onComplete: () => {
if (active) timer = setTimeout(connect, 1000);
},
});
};
connect();
return () => {
active = false;
clearTimeout(timer);
subscription?.unsubscribe();
};
},
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
const invalidateAll = targets.includes("*");
if (previewSessionId) {
if (invalidateAll) {
await fetchApi.customWidget.previewRefresh.mutate({ sessionId: previewSessionId });
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
const invalidateAll = targets.includes("*");
if (previewSessionId) {
if (invalidateAll) {
await fetchApi.customWidget.previewRefresh.mutate({ sessionId: previewSessionId });
}
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "preview" &&
key[2] === previewSessionId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
});
return;
}
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "preview" &&
key[2] === previewSessionId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
});
return;
}
if (!itemId) return;
if (targets.length === 0) return;
if (invalidateAll) await fetchApi.widget.customApi.refresh.mutate({ itemId });
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "item" &&
key[2] === itemId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
if (!itemId) return;
if (targets.length === 0) return;
if (invalidateAll) await fetchApi.widget.customApi.refresh.mutate({ itemId });
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "item" &&
key[2] === itemId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
}),
];
if (
invalidateAll ||
(props.requestCapabilities ?? []).some(
(request) => request.trigger === "load" && targets.includes(request.id),
)
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
}
await Promise.all(tasks);
},
confirm: ({ title, message, confirmLabel, destructive }) =>
new Promise((resolve) => {
openConfirmModal({
title,
children: message,
labels: { confirm: confirmLabel },
confirmProps: { color: destructive ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}),
];
if (
invalidateAll ||
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
}
await Promise.all(tasks);
},
confirm: ({ title, message, confirmLabel, destructive }) =>
new Promise((resolve) => {
openConfirmModal({
title,
children: message,
labels: { confirm: confirmLabel },
confirmProps: { color: destructive ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
};
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
}),
[utils, queryClient, openConfirmModal, props.requestCapabilities],
);
const observedPort = useMemo(
() => observeCustomWidgetQueries(port, queryObserver, messages.requestFailed),
[port, queryObserver, messages.requestFailed],
);
return (
<CustomWidgetRuntimeProvider
itemId={props.itemId}
@@ -175,7 +226,9 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
queriesDisabled={props.queriesDisabled}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
nativeCapabilities={props.nativeCapabilities}
queryFixtures={props.queryFixtures}
port={observedPort}
messages={messages}
setQueryState={props.setQueryState}
>