feat: add custom widget flow workbench
This commit is contained in:
1 parent
c32a5317ca
commit
a6bdbbb4ea
259 files changed
+23875
-1293
No files matched your search
@@ -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}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user