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
View File
@@ -86,6 +86,7 @@
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"@xyflow/react": "catalog:",
"adm-zip": "catalog:",
"ai": "catalog:",
"assistant-stream": "catalog:",
@@ -96,6 +97,7 @@
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"elkjs": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"isomorphic-dompurify": "catalog:",
@@ -171,7 +171,8 @@ const ScopedTRPCReactProvider = ({
splitLink({
condition: ({ type }) => type === "subscription",
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
condition: ({ path }) =>
path === "widget.beszel.subscribeSystemStats" || path === "customWidget.nativeSubscribe",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
@@ -1,6 +1,6 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { Anchor, Badge, Group } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -11,14 +11,13 @@ export function CustomWidgetBetaBanner() {
const t = useI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Text size="sm">
{t("description")}{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
{t("discussionLink")}
</Anchor>{" "}
{t("closing")}
</Text>
</Alert>
<Group gap="xs">
<Badge variant="light" color="yellow" leftSection={<IconFlask size={12} />}>
{t("title")}
</Badge>
<Anchor size="xs" href={FEEDBACK_URL} target="_blank" rel="noopener noreferrer">
{t("discussionLink")}
</Anchor>
</Group>
);
}
@@ -1 +1,57 @@
export { CodeEditor } from "~/components/custom-widgets/code-editor";
"use client";
import { useSyncExternalStore, useState, useEffect, useCallback, useMemo } from "react";
import type { ComponentProps } from "react";
import { CodeEditor as SharedCodeEditor } from "~/components/custom-widgets/code-editor";
import { useOptionalCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
const subscribeEmpty = () => () => undefined;
const zero = () => 0;
export function CodeEditor({
localHistory = false,
...props
}: ComponentProps<typeof SharedCodeEditor> & { localHistory?: boolean }) {
const [insert, setInsert] = useState<{ text: string; key: number }>();
const [reveal, setReveal] = useState<{ index: number; key: number }>();
useEffect(() => {
const receive = (event: Event) => {
const detail = (event as CustomEvent<{ editorId: string; index: number }>).detail;
if (detail.editorId === props.id) setReveal({ index: detail.index, key: Date.now() });
};
const receiveInsert = (event: Event) => {
const detail = (event as CustomEvent<{ editorId: string; text: string }>).detail;
if (detail.editorId === props.id && !props.readOnly) setInsert({ text: detail.text, key: Date.now() });
};
window.addEventListener("homarr:widget-code-insert", receiveInsert);
window.addEventListener("homarr:widget-code-reveal", receive);
return () => {
window.removeEventListener("homarr:widget-code-reveal", receive);
window.removeEventListener("homarr:widget-code-insert", receiveInsert);
};
}, [props.id, props.readOnly]);
const store = useOptionalCustomWidgetFormDocumentStore();
const getHistoryState = useCallback(() => {
if (!store) return 0;
return Number(store.canUndo()) + 2 * Number(store.canRedo());
}, [store]);
const historyState = useSyncExternalStore(store?.subscribeEditor ?? subscribeEmpty, getHistoryState, zero);
const history = useMemo(() => {
if (!store || localHistory || props.readOnly || props.id.startsWith("preview-")) return undefined;
return {
canUndo: Boolean(historyState & 1),
canRedo: Boolean(historyState & 2),
undo: store.undo,
redo: store.redo,
};
}, [store, localHistory, props.readOnly, props.id, historyState]);
return (
<div data-workbench-local-input={localHistory || undefined}>
<SharedCodeEditor
{...props}
history={history}
insertText={insert?.text ?? props.insertText}
insertKey={insert?.key ?? props.insertKey}
revealIndex={reveal?.index}
revealKey={reveal?.key ?? props.revealKey}
/>
</div>
);
}
@@ -3,7 +3,17 @@
import { useEffect, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Button,
Card,
getContrastColor,
Group,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import {
IconAlertCircle,
IconAlertTriangle,
@@ -24,6 +34,7 @@ import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
import { useWorkbenchSelection } from "./_flow/selection";
import classes from "./_custom-widget-form.module.css";
const sectionLinks = [
@@ -46,10 +57,11 @@ export function EditorSection({
icon: Icon;
children: ReactNode;
}) {
const compact = useWorkbenchSelection() !== undefined;
return (
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
<Card id={id} component="section" withBorder={!compact} p={compact ? 0 : "lg"} className={classes.editorSection}>
<Stack gap="md">
<Group gap="sm">
<Group gap="sm" data-workbench-section-title>
<ThemeIcon variant="light" size="lg">
<SectionIcon size={18} />
</ThemeIcon>
@@ -64,6 +76,7 @@ export function EditorSection({
}
export function SaveActions({
formId,
dirty,
savePending,
previewPending,
@@ -71,6 +84,7 @@ export function SaveActions({
mode,
onPreview,
}: {
formId?: string;
dirty: boolean;
savePending: boolean;
previewPending: boolean;
@@ -80,6 +94,13 @@ export function SaveActions({
}) {
const t = useI18n("customWidget.workbench");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme();
const submitColor = getContrastColor({
color: theme.primaryColor,
colorScheme,
theme: { ...theme, luminanceThreshold: 0.179 },
});
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
@@ -103,7 +124,13 @@ export function SaveActions({
>
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
<Button
form={formId}
type="submit"
loading={savePending}
disabled={invalid || previewPending}
style={{ "--button-color": submitColor }}
>
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
</Button>
</Group>
@@ -143,11 +170,13 @@ export function CustomWidgetSectionNavigation({
}
export function CustomWidgetSaveActions({
formId,
mode,
savePending,
previewPending,
onPreview,
}: {
formId?: string;
mode: "create" | "edit";
savePending: boolean;
previewPending: boolean;
@@ -158,6 +187,7 @@ export function CustomWidgetSaveActions({
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
return (
<SaveActions
formId={formId}
dirty={dirty}
savePending={savePending}
previewPending={previewPending}
@@ -13,60 +13,10 @@ import {
import type { ReactNode } from "react";
import type { UseFormReturnType } from "@mantine/form";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
setValues(values: CustomWidgetFormValues): void;
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(values: CustomWidgetFormValues): void;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
if (Object.keys(left).length !== documentValueKeys.length) return false;
if (Object.keys(right).length !== documentValueKeys.length) return false;
return documentValueKeys.every(
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
);
}
export function createCustomWidgetFormDocumentStore(
initialValues: CustomWidgetFormValues,
): CustomWidgetFormDocumentStore {
let values = initialValues;
let persistedValues = initialValues;
let dirty = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
return {
getValues: () => values,
setValues: (nextValues) => {
if (Object.is(values, nextValues)) return;
values = nextValues;
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
notify();
},
getDirty: () => dirty,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
markSaved: (savedValues) => {
const wasDirty = dirty;
persistedValues = savedValues;
dirty = !areDocumentValuesEqual(values, persistedValues);
if (wasDirty !== dirty) notify();
},
};
}
import type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
export { createCustomWidgetFormDocumentStore } from "./_flow/document-store";
export type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
@@ -80,6 +30,10 @@ export function CustomWidgetFormDocumentProvider({
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
}
export function useOptionalCustomWidgetFormDocumentStore() {
return useContext(CustomWidgetFormDocumentContext);
}
export function useCustomWidgetFormDocumentStore() {
const store = useContext(CustomWidgetFormDocumentContext);
if (!store) throw new Error("Custom widget form document provider is missing");
@@ -104,9 +58,14 @@ export function useDeferredCustomWidgetFormDocumentValues() {
}, 150);
};
const unsubscribe = store.subscribe(scheduleUpdate);
const unsubscribeRenames = store.onNodeRenames(() => {
clearTimeout(timeout);
setValues(store.getValues());
});
return () => {
clearTimeout(timeout);
unsubscribe();
unsubscribeRenames();
};
}, [store]);
return values;
@@ -120,7 +79,7 @@ export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetF
export function useCustomWidgetFormDocumentDirty() {
const store = useCustomWidgetFormDocumentStore();
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
return useSyncExternalStore(store.subscribeEditor, store.getDirty, store.getDirty);
}
export function useCustomWidgetFormDocumentBridge(
@@ -129,6 +88,7 @@ export function useCustomWidgetFormDocumentBridge(
) {
const formRef = useRef(form);
formRef.current = form;
useEffect(() => store.onRestore((values) => formRef.current.setValues(values)), [store]);
return useMemo(
() =>
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
@@ -19,7 +19,8 @@ const PREVIEW_QUERY_CONCURRENCY = 4;
export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
$schema: values.extensions?.trim() ? "homarr-custom-widget-v3" : "homarr-custom-widget-v2",
...(values.extensions?.trim() ? { extensions: parseJson(values.extensions) } : {}),
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
@@ -59,6 +60,7 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
requests: JSON.stringify(widget.requests, null, 2),
options: JSON.stringify(widget.options, null, 2),
template: widget.template,
extensions: widget.$schema === "homarr-custom-widget-v3" ? JSON.stringify(widget.extensions ?? {}, null, 2) : "",
});
}
@@ -104,9 +106,14 @@ export function isRuntimeParams(
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const requests = Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
);
const requests = [
...Object.entries(definition.requests)
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
.map(([id]) => ({ id, native: false })),
...Object.entries(definition.extensions?.native ?? {})
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
.map(([id]) => ({ id, native: true })),
];
const results: Array<{
requestId: string;
data: unknown;
@@ -119,9 +126,11 @@ export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessi
nextIndex += 1;
const request = requests[index];
if (!request) return;
const [requestId] = request;
const requestId = request.id;
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
const result = await (request.native
? fetchApi.customWidget.nativeQuery.query({ previewSessionId: sessionId, nativeId: requestId, params: {} })
: fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }));
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
} catch (error) {
results[index] = {
@@ -1,8 +1,8 @@
"use client";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { customWidgetEditorLayoutSchema, EMPTY_EDITOR_LAYOUT } from "@homarr/custom-widgets/core";
import { memo, useId, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
@@ -14,7 +14,6 @@ import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest
import {
CustomWidgetOptionsSnapshotSync,
CustomWidgetSaveActions,
CustomWidgetSectionNavigation,
CustomWidgetUnsavedChangesGuard,
} from "./_custom-widget-form-layout";
import {
@@ -40,12 +39,15 @@ import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
import { FlowWorkbench } from "./_flow/workbench";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
mode: "create" | "edit";
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
editorLayout?: string | null;
savedRevision?: string;
}
type MobilePane = "configure" | "preview";
@@ -69,7 +71,7 @@ interface CustomWidgetFormViewProps {
savePending: boolean;
previewPending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
onPreview(): void;
onPreview(): Promise<unknown>;
onPasteAiResponse(): void;
}
@@ -81,35 +83,32 @@ function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
sources: values.sources,
requests: values.requests,
options: values.options,
extensions: values.extensions,
template: values.template,
};
}
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
const w = useI18n("customWidget.workbench");
const saveFormId = useId();
const documentStore = props.documentStore;
const form = props.form;
const setMobilePane = props.setMobilePane;
const setPreview = props.setPreview;
const { renameRequest, renameOption } = useMemo(
() =>
createCustomWidgetRenameHandlers({
form,
transaction: documentStore.transaction,
renameRequestBinding: documentStore.renameRequestBinding,
invalidWidgetMessage: w("invalidWidget"),
}),
[form, w],
[documentStore, form, w],
);
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
const readAiDiagnostics = useCallback(
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
[documentStore],
);
const handleSectionSelect = useCallback(
(section: string) => {
setMobilePane(section === "preview" ? "preview" : "configure");
},
[setMobilePane],
);
const handleLiveActionsChange = useCallback(
(enabled: boolean) =>
setPreview((current) => ({
@@ -122,54 +121,39 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
return (
<CustomWidgetFormDocumentProvider store={documentStore}>
<CustomWidgetFormAnalysisProvider>
<form onSubmit={props.onSubmit} className={classes.form}>
<div className={classes.form}>
<form id={saveFormId} onSubmit={props.onSubmit} />
<CustomWidgetUnsavedChangesGuard />
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => {
if (value === "configure" || value === "preview") props.setMobilePane(value);
}}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<FlowWorkbench
feedback={<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />}
form={form}
onPreview={props.onPreview}
execution={props.preview}
definitionId={props.definitionId}
actions={
<CustomWidgetSaveActions
formId={saveFormId}
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
}
general={<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />}
sources={<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />}
requests={<CustomWidgetRequestsSection form={form} onRename={renameRequest} />}
options={<CustomWidgetOptionsSection form={form} onRename={renameOption} />}
template={<CustomWidgetTemplateSection form={form} />}
assistant={
<CustomWidgetAiSection
getDraft={readAiDraft}
getDiagnostics={readAiDiagnostics}
onPaste={props.onPasteAiResponse}
/>
<CustomWidgetAdvancedManifest form={form} />
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
<CustomWidgetTemplateSection form={form} />
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
</Stack>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<CustomWidgetSaveActions
mode={props.mode}
savePending={props.savePending}
previewPending={props.previewPending}
onPreview={props.onPreview}
/>
</Paper>
}
advanced={<CustomWidgetAdvancedManifest form={form} />}
preview={
<CustomWidgetPreviewSection
preview={props.preview}
size={props.previewSize}
@@ -178,20 +162,32 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
onOptionsChange={props.setOptionsSnapshot}
onLiveActionsChange={handleLiveActionsChange}
/>
</Box>
</div>
</form>
}
/>
</div>
</CustomWidgetFormAnalysisProvider>
</CustomWidgetFormDocumentProvider>
);
});
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
export function CustomWidgetForm({
mode,
initialValues,
definitionId,
editorLayout,
savedRevision,
}: CustomWidgetFormProps) {
const flowT = useI18n("customWidget.flow");
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
sources: "{}",
requests: "{}",
template: `<Stack align="center" justify="center" h="100%"><Text size="xl" fw={600}>{${JSON.stringify(flowT("welcomeTemplate"))}}</Text></Stack>`,
...initialValues,
}));
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
const [documentStore] = useState(() =>
createCustomWidgetFormDocumentStore(formInitialValues, parseEditorLayout(editorLayout)),
);
const mantineForm = useZodForm(customWidgetFormSchema, {
initialValues: formInitialValues,
mode: "uncontrolled",
@@ -208,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const actions = useCustomWidgetFormActions({
savedRevision,
mode,
definitionId,
form,
@@ -222,7 +219,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
latestActions.current = actions;
}, [actions]);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
const handlePreview = useCallback(() => latestActions.current.runPreview(), []);
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
return (
@@ -250,3 +247,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
/>
);
}
function parseEditorLayout(value?: string | null) {
try {
return customWidgetEditorLayoutSchema.parse(JSON.parse(value ?? "null"));
} catch {
return EMPTY_EDITOR_LAYOUT;
}
}
@@ -26,6 +26,7 @@ export function CustomWidgetIdentifierInput({
};
return (
<TextInput
data-workbench-local-input
label={label}
value={draft}
error={error}
@@ -6,6 +6,8 @@ import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetIntegrationOption } from "@homarr/widgets/custom-api/integration-option";
import { CodeEditor } from "./_code-editor";
export function DefaultValueEditor({
@@ -22,6 +24,8 @@ export function DefaultValueEditor({
onChange: (value: unknown) => void;
}) {
const t = useI18n("customWidget.workbench.builder");
if (control === "integration")
return <CustomWidgetIntegrationOption label={t("default")} value={value} onChange={onChange} />;
if (control === "switch")
return (
<Switch
@@ -6,7 +6,7 @@ interface PreviewSnapshot {
session: { id: string; liveActions: boolean } | null;
}
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template" | "extensions">;
export function createPreviewDisplayData({
candidate,
@@ -23,15 +23,18 @@ export function createPreviewDisplayData({
}): Record<string, unknown> | null {
if (!candidate) return null;
const requests = Object.entries(candidate.requests);
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
const queryIds = [...requests.map(([id]) => id), ...Object.keys(candidate.extensions?.native ?? {})];
const data =
fixture === "empty" ? Object.fromEntries(queryIds.map((id) => [id, emptyFixture(preview.data[id])])) : preview.data;
const status =
fixture === "loading"
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
? Object.fromEntries(queryIds.map((id) => [id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
? Object.fromEntries(queryIds.map((id) => [id, { loading: false, ok: false, error: fixtureError }]))
: preview.status;
return {
template: candidate.template,
extensions: candidate.extensions,
data,
status,
options,
@@ -51,3 +54,10 @@ export function createPreviewDisplayData({
isEditMode: fixture !== "live",
};
}
function emptyFixture(value: unknown, depth = 0): unknown {
if (depth > 10 || Array.isArray(value) || value === undefined) return [];
if (value && typeof value === "object")
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, emptyFixture(entry, depth + 1)]));
return value;
}
@@ -0,0 +1,124 @@
"use client";
import { useMemo } from "react";
import { Alert, Group, Stack, Switch, Tabs, Text } from "@mantine/core";
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import type { PreviewPanelProps } from "./_flow/preview-panel-types";
import { PreviewRequestData } from "./_flow/preview-request-data";
import { WorkbenchRequestJournal } from "./_flow/request-journal";
import { PreviewStatusDot } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { JsonPreviewEditor } from "./_json-preview-editor";
export function PreviewTabList({
outcome,
widgetLabel,
}: {
outcome: PreviewPanelProps["preview"]["outcome"];
widgetLabel?: string;
}) {
const t = useI18n("customWidget.workbench.preview");
const flowT = useI18n("customWidget.flow");
return (
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
<Group gap={6} wrap="nowrap" justify="center">
{tab === "widget" && widgetLabel ? widgetLabel : t(`tab.${tab}`)}
{tab === "data" && <PreviewStatusDot outcome={outcome} label={t(`status.${outcome}`)} />}
</Group>
</Tabs.Tab>
))}
<Tabs.Tab value="journal">{flowT("requestJournal")}</Tabs.Tab>
</Tabs.List>
);
}
export function PreviewDetailPanels(
props: PreviewPanelProps & {
requestId?: string;
onShowAll(): void;
liveActions: { pending: boolean; toggle(enabled: boolean): void };
},
) {
const t = useI18n("customWidget.workbench.preview");
const candidate = props.candidate;
const candidateTemplate = candidate?.template;
const accessibilityIssues = useMemo(() => {
if (candidateTemplate === undefined) return [];
return analyzeCustomWidgetAccessibility(candidateTemplate);
}, [candidateTemplate]);
return (
<>
<Tabs.Panel value="data" pt="sm">
<PreviewRequestData preview={props.preview} requestId={props.requestId} onShowAll={props.onShowAll} />
</Tabs.Panel>
<Tabs.Panel value="journal" pt="sm">
<WorkbenchRequestJournal />
</Tabs.Panel>
<Tabs.Panel value="options" pt="sm">
<JsonPreviewEditor
id="preview-options"
label={t("instanceOptions")}
value={props.optionsSnapshot}
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
label={t("liveActions")}
description={t("liveActionsDescription")}
checked={props.preview.session?.liveActions ?? false}
disabled={!props.preview.session || props.liveActions.pending}
onChange={(event) => props.liveActions.toggle(event.currentTarget.checked)}
/>
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
<Alert key={issue} color="yellow">
{t(`accessibility.${issue}`)}
</Alert>
))
)}
</Stack>
</Tabs.Panel>
</>
);
}
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
@@ -38,6 +39,9 @@ vi.mock("./_code-editor", () => ({
return <div>{props.label}</div>;
},
}));
vi.mock("./_flow/preview-theme", () => ({
WorkbenchPreviewSurface: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -105,21 +109,21 @@ describe("Custom Widget preview panel", () => {
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.options"),
);
await act(async () => optionsTab?.click());
expect(host.textContent).toContain("instanceOptions");
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.actions"),
);
await act(async () => actionsTab?.click());
expect(host.textContent).toContain("liveActions");
expect(host.textContent).not.toContain("invalid");
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
@@ -1,70 +1,51 @@
"use client";
import { memo, useMemo, useState } from "react";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { memo, useMemo, useState, useRef, useEffect } from "react";
import { Box, Card, Group, Stack, Tabs, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
import { WorkbenchPreviewWidget, workbenchPreviewWidth } from "./_flow/preview-widget";
import { WorkbenchPreviewControls, WorkbenchPreviewTools } from "./_flow/preview-controls";
import { useWorkbenchPreviewPresentation } from "./_flow/preview-presentation";
import { WorkbenchPreviewThemeControl } from "./_flow/preview-theme";
import type { PreviewPanelProps, PreviewFixture } from "./_flow/preview-panel-types";
import { usePreviewExecution } from "./_flow/preview-execution";
import { usePreviewLiveActions } from "./_flow/preview-live-actions";
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { PreviewDetailPanels, PreviewTabList } from "./_custom-widget-preview-details";
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css";
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export interface PreviewState {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
}
interface PreviewPanelProps {
candidate: HomarrCustomWidgetV2 | null;
validationIssues: Array<{ path?: string; message: string }>;
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
export type { PreviewState } from "./_flow/preview-state";
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
const actionT = useI18n("common.action");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
);
const candidate = props.candidate;
const [inspect, setInspect] = useState(false);
const [fixture, setFixture] = useState<PreviewFixture>("live");
const execution = usePreviewExecution();
const liveActions = usePreviewLiveActions(props);
const presentation = useWorkbenchPreviewPresentation();
const canvas = presentation === "canvas";
const lastValid = useRef(props.candidate);
useEffect(() => {
if (props.candidate) lastValid.current = props.candidate;
}, [props.candidate]);
const candidate = props.candidate ?? lastValid.current;
const flowT = useI18n("customWidget.flow");
const candidateExtensions = candidate?.extensions;
const candidateTemplate = candidate?.template;
const candidateRequests = candidate?.requests;
const hasActions =
Object.values(candidateRequests ?? {}).some((request) => request.kind === "action") ||
Object.values(candidateExtensions?.native ?? {}).some((capability) => capability.kind === "action");
const previewCandidate = useMemo(() => {
if (candidateTemplate === undefined || candidateRequests === undefined) return null;
return { template: candidateTemplate, requests: candidateRequests };
}, [candidateRequests, candidateTemplate]);
return { template: candidateTemplate, requests: candidateRequests, extensions: candidateExtensions };
}, [candidateRequests, candidateTemplate, candidateExtensions]);
const previewSnapshot = useMemo(
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
[props.preview.data, props.preview.session, props.preview.status],
);
const fixtureError = t("fixtureError");
const accessibilityIssues = useMemo(() => {
if (candidateTemplate === undefined) return [];
return analyzeCustomWidgetAccessibility(candidateTemplate);
}, [candidateTemplate]);
const displayData = useMemo(
() =>
createPreviewDisplayData({
@@ -82,6 +63,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
JSON.stringify({
template: candidateTemplate,
requests: candidateRequests,
extensions: candidateExtensions,
fixture,
data: props.preview.data,
status: props.preview.status,
@@ -91,6 +73,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
[
candidateRequests,
candidateTemplate,
candidateExtensions,
fixture,
props.optionsSnapshot,
props.preview.data,
@@ -113,161 +96,114 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
? { title: t("result.loading.title"), description: t("result.loading.description") }
: { title: "", description: "" };
const controls = (
<WorkbenchPreviewControls
size={props.size}
onSizeChange={props.onSizeChange}
inspect={inspect}
onInspectChange={setInspect}
fixture={fixture}
onFixtureChange={setFixture}
disabled={!candidate}
/>
);
const result = candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
);
const details = (
<PreviewDetailPanels
{...props}
candidate={candidate}
requestId={execution.requestId}
onShowAll={execution.showAll}
liveActions={liveActions}
/>
);
return (
<Card withBorder p="md">
<Stack gap="md">
<Group gap="xs" justify="space-between">
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
</Group>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
<Card
withBorder={!canvas}
p={canvas ? 0 : "xs"}
w={canvas ? workbenchPreviewWidth(props.size) : undefined}
data-preview-presentation={presentation}
className="nodrag nopan nowheel"
>
<Stack gap="xs">
{(props.preview.stale || !props.candidate) && (
<Text size="xs" c="dimmed" component="output">
{flowT("stalePreview")}
</Text>
)}
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
<Group gap={6} wrap="nowrap" justify="center">
{t(`tab.${tab}`)}
{tab === "data" && (
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
)}
</Group>
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<Paper
withBorder
p="sm"
h={360}
w={previewWidths[props.size] ?? 480}
maw="100%"
style={{ overflow: "auto" }}
>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={actionT("tryAgain")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
<CodeEditor
id="preview-data"
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
<Tabs.Panel value="options" pt="sm">
<JsonPreviewEditor
id="preview-options"
label={t("instanceOptions")}
value={props.optionsSnapshot}
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
label={t("liveActions")}
description={t("liveActionsDescription")}
checked={props.preview.session?.liveActions ?? false}
disabled={!props.preview.session || liveActionsMutation.isPending}
onChange={(event) => {
if (!props.preview.session) return;
const enabled = event.currentTarget.checked;
liveActionsMutation.mutate(
{ sessionId: props.preview.session.id, enabled },
{
onSuccess: () => props.onLiveActionsChange(enabled),
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
},
);
}}
/>
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
{canvas && (
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
{hasActions && (
<Text size="xs" c="dimmed">
{props.preview.session?.liveActions ? t("liveActions") : flowT("previewActionsSimulated")}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
<Alert key={issue} color="yellow">
{t(`accessibility.${issue}`)}
</Alert>
))
)}
{props.preview.session && (
<CodeEditor
id="preview-journal"
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
</Stack>
</Group>
<WorkbenchPreviewTools revealVersion={execution.revealVersion}>
<Tabs
value={execution.activeTab}
onChange={execution.setActiveTab}
keepMounted
keepMountedMode="display-none"
>
<PreviewTabList outcome={props.preview.outcome} widgetLabel={flowT("previewDisplay")} />
<Tabs.Panel value="widget" pt="sm">
<Stack gap="sm">
{controls}
<WorkbenchPreviewThemeControl />
{result}
</Stack>
</Tabs.Panel>
{details}
</Tabs>
</WorkbenchPreviewTools>
</Group>
)}
{!canvas && controls}
{!canvas && result}
<Tabs
value={canvas ? "widget" : execution.activeTab}
onChange={execution.setActiveTab}
keepMounted
keepMountedMode="display-none"
>
{!canvas && <PreviewTabList outcome={props.preview.outcome} />}
<Tabs.Panel
key="widget-renderer"
value="widget"
pt={canvas ? 0 : "sm"}
renderRoot={(attributes) => {
if (canvas) {
const canvasAttributes = { ...attributes };
delete canvasAttributes.role;
delete canvasAttributes["aria-labelledby"];
return <section {...canvasAttributes} aria-label={t("tab.widget")} />;
}
return <section {...attributes} />;
}}
>
<Box
className={classes.previewCanvas}
style={canvas ? { padding: 0, background: "none", overflow: "visible" } : undefined}
>
<WorkbenchPreviewWidget
data={displayData}
size={props.size}
inspect={inspect}
resetKey={rendererResetKey}
/>
</Box>
</Tabs.Panel>
{!canvas && details}
</Tabs>
</Stack>
</Card>
@@ -5,6 +5,8 @@ import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm;
transaction(action: () => void): void;
renameRequestBinding(previousId: string, nextId: string): void;
invalidWidgetMessage: string;
}
@@ -16,7 +18,11 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
input.transaction(() => {
const renamed = renameCustomWidgetRequest(candidate.data, currentId, nextId);
input.renameRequestBinding(currentId, nextId);
applyDefinition(input.form, renamed);
});
input.form.clearFieldError("requests");
} catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -29,7 +35,9 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
input.transaction(() =>
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName)),
);
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
@@ -157,11 +157,16 @@ export function RequestBodyEditor({
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
const [parameterName, setParameterName] = useState("");
const [insertion, setInsertion] = useState({ text: "", key: 0 });
useEffect(() => setDraft(serialized), [serialized]);
useEffect(() => {
setDraft(serialized);
setError(undefined);
}, [serialized]);
return (
<Stack gap="xs">
<CodeEditor
id={`${id}-request-body-editor`}
// Incomplete JSON is a local buffer; its undo must not change another document field.
localHistory
label={t("jsonBody")}
description={t("jsonBodyDescription")}
language="json"
@@ -182,7 +187,7 @@ export function RequestBodyEditor({
}
}}
/>
<Group align="end" wrap="wrap">
<Group align="end" wrap="wrap" data-workbench-local-input>
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
<Button
type="button"
@@ -1,5 +1,10 @@
"use client";
import { useConfirmModal } from "@homarr/modals";
import { projectWidgetGraph } from "./_flow/graph";
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { useWorkbenchSelection } from "./_flow/selection";
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
@@ -20,6 +25,10 @@ export function CustomWidgetRequestsEditor({
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const selection = useWorkbenchSelection();
const store = useCustomWidgetFormDocumentStore();
const flowT = useI18n("customWidget.flow");
const { openConfirmModal } = useConfirmModal();
const t = useI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
@@ -32,7 +41,24 @@ export function CustomWidgetRequestsEditor({
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, request: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const remove = (index: number) => {
const id = entries[index]?.[0];
const graph = projectWidgetGraph(form.values, store.getLayout());
const references = graph.edges
.filter((edge) => edge.source === `request:${id}` || edge.target === `request:${id}`)
.filter((edge) => edge.data?.relationship !== "source")
.map((edge) => (edge.source === `request:${id}` ? edge.target : edge.source));
const apply = () => store.transaction(() => commit(entries.filter((_, i) => i !== index)));
if (!references.length) {
apply();
return;
}
openConfirmModal({
title: flowT("removeReferenced"),
children: flowT("removeReferencedDescription", { references: [...new Set(references)].join(", ") }),
onConfirm: apply,
});
};
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
@@ -49,13 +75,14 @@ export function CustomWidgetRequestsEditor({
return (
<Stack gap="sm">
{entries.map(([id, rawRequest], index) => {
if (selection && selection !== `request:${id}`) return null;
const request = isRecord(rawRequest) ? rawRequest : {};
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
<Fieldset key={id} legend={id || t("request", { count: index + 1 })} data-workbench-request={id}>
<Stack gap="sm">
<Group grow align="start">
<Group grow align="start" data-workbench-request-row>
<CustomWidgetIdentifierInput
label={t("requestId")}
value={id}
@@ -89,7 +116,7 @@ export function CustomWidgetRequestsEditor({
}
/>
</Group>
<Group grow align="start">
<Group grow align="start" data-workbench-request-row>
<Select
label={t("source")}
data={sourceIds}
@@ -121,6 +148,7 @@ export function CustomWidgetRequestsEditor({
</Group>
<TextInput
label={t("path")}
data-workbench-request-path
description={t("pathDescription")}
descriptionProps={{
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
@@ -0,0 +1,39 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
type Secrets = CustomWidgetFormValues["secrets"];
export function getSourceSecretRenames(secrets: Secrets) {
return Object.fromEntries(
secrets.flatMap((secret) => {
if (!secret.hasValue || secret.value.trim() || !secret.savedSourceId || secret.savedSourceId === secret.sourceId)
return [];
return [[secret.savedSourceId, secret.sourceId]];
}),
);
}
/** A save changes where credentials live, including when the user renamed a
* source again while that save was in flight. Preserve newer input values. */
export function markSourceSecretsSaved(
submitted: Secrets,
current: Secrets,
presence?: Array<{ sourceId: string; kind: string }>,
): Secrets {
return current.map((secret) => {
const saved = submitted.find(
(candidate) =>
candidate.kind === secret.kind &&
(candidate.savedSourceId ?? candidate.sourceId) === (secret.savedSourceId ?? secret.sourceId),
);
if (!saved) return secret;
const { savedSourceId: _previousSourceId, ...next } = secret;
let hasValue = Boolean(saved.hasValue || saved.value.trim());
if (presence) hasValue = presence.some((entry) => entry.sourceId === saved.sourceId && entry.kind === saved.kind);
return {
...next,
hasValue,
value: secret.value === saved.value ? "" : secret.value,
...(hasValue && secret.sourceId !== saved.sourceId ? { savedSourceId: saved.sourceId } : {}),
};
});
}
@@ -7,6 +7,8 @@ import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { SourceNetworkHint } from "./_flow/source-network-hint";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -98,6 +100,7 @@ export function CustomWidgetSourceField({
allowDeselect={false}
/>
</Group>
<SourceNetworkHint source={source} onChange={(networkScope) => onUpdate(index, { networkScope })} />
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
@@ -116,7 +119,7 @@ export function CustomWidgetSourceField({
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Group key={kind} align="end" wrap="nowrap" data-workbench-credential>
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
@@ -131,6 +134,7 @@ export function CustomWidgetSourceField({
color="red"
variant="subtle"
loading={clearSecretPending}
disabled={form.submitting}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
@@ -139,7 +143,7 @@ export function CustomWidgetSourceField({
</Group>
);
})}
{index > 0 && (
{
<Button
type="button"
color="red"
@@ -149,7 +153,7 @@ export function CustomWidgetSourceField({
>
{t("remove")}
</Button>
)}
}
</Stack>
</Fieldset>
);
@@ -1,5 +1,8 @@
"use client";
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { useWorkbenchSelection } from "./_flow/selection";
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
@@ -21,6 +24,8 @@ export function CustomWidgetSourcesEditor({
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const selection = useWorkbenchSelection();
const store = useCustomWidgetFormDocumentStore();
const t = useI18n("customWidget.workbench.sources");
const w = useI18n("customWidget.workbench");
const { openConfirmModal } = useConfirmModal();
@@ -34,7 +39,16 @@ export function CustomWidgetSourcesEditor({
form.setFieldError("sources", t("duplicateSourceId"));
return;
}
let parsedRequests: unknown;
if (previousId !== nextId) {
parsedRequests = parseJson(form.values.requests);
if (!isRecord(parsedRequests)) {
form.setFieldError("sources", w("invalidWidget"));
return;
}
}
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
if (previousId && nextId && previousId !== nextId) store.renameSourceBinding(previousId, nextId);
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
form.setFieldValue(
"sources",
@@ -50,12 +64,13 @@ export function CustomWidgetSourcesEditor({
),
);
if (previousId && nextId && previousId !== nextId) {
const parsedRequests = parseJson(form.values.requests);
const requests = isRecord(parsedRequests)
? Object.fromEntries(
Object.entries(parsedRequests).map(([id, request]) => [
id,
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
isRecord(request) && (request.source ?? "default") === previousId
? { ...request, source: nextId }
: request,
]),
)
: {};
@@ -63,7 +78,13 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue(
"secrets",
form.values.secrets.map((secret) =>
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
secret.sourceId === previousId
? {
...secret,
sourceId: nextId,
...(secret.hasValue ? { savedSourceId: secret.savedSourceId ?? previousId } : {}),
}
: secret,
),
);
}
@@ -84,7 +105,10 @@ export function CustomWidgetSourcesEditor({
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
form.setFieldValue("secrets", [
...current,
{ sourceId, kind, value, hasValue: existing?.hasValue, savedSourceId: existing?.savedSourceId },
]);
};
const addSource = () => {
let suffix = sources.length + 1;
@@ -132,7 +156,7 @@ export function CustomWidgetSourcesEditor({
if (!removedId) return;
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
if (dependentRequestIds.length === 0) {
removeSourceAndDependents(index);
store.transaction(() => removeSourceAndDependents(index));
return;
}
openConfirmModal({
@@ -150,12 +174,16 @@ export function CustomWidgetSourcesEditor({
</Stack>
),
confirmProps: { children: t("remove") },
onConfirm: () => removeSourceAndDependents(index),
onConfirm: () => store.transaction(() => removeSourceAndDependents(index)),
});
};
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (form.submitting) return;
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
const savedSourceId = form.values.secrets.find(
(secret) => secret.sourceId === sourceId && secret.kind === kind,
)?.savedSourceId;
await clearSecretMutation.mutateAsync({ definitionId, sourceId: savedSourceId ?? sourceId, kind });
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
await utils.customWidget.get.invalidate({ id: definitionId });
@@ -165,6 +193,7 @@ export function CustomWidgetSourcesEditor({
return (
<Stack gap="sm">
{sources.map((source, index) => {
if (selection && selection !== `source:${source.id}`) return null;
return (
<CustomWidgetSourceField
key={source.id}
@@ -173,8 +202,8 @@ export function CustomWidgetSourcesEditor({
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretMutation.isPending}
onUpdate={update}
onSetAuthentication={setAuth}
onUpdate={(sourceIndex, changes) => store.transaction(() => update(sourceIndex, changes))}
onSetAuthentication={(sourceIndex, type) => store.transaction(() => setAuth(sourceIndex, type))}
onSetSecret={setSecret}
onClearSecret={clearSecret}
onRemove={removeSource}
@@ -0,0 +1,83 @@
# Custom Widget workbench
The flow editor projects the existing Custom Widget definition. It does not execute a graph or replace the dashboard
runtime. The canvas is the default authoring surface; the complete form remains available from the More menu.
The workspace fills the management area by default, beside a collapsible icon rail. Optional immersive full-screen mode
hides management chrome through CSS and background focus isolation; it preserves the React tree, form association, draft,
and execution session. Save errors render inside the workspace.
## Ownership
- `document-store.ts` owns definition transactions and bounded Undo/Redo. Credentials stay in the live form;
`credential-bindings.ts` restores their association across source renames without putting values in history.
- `graph.ts` projects references into nodes and edges. `commands.ts` and the specialized editors change manifest fields.
Template edits use parser source ranges and preserve unrelated formatting and comments. Complex relationships are
inspectable in code rather than converted into a visual component tree.
- Layout metadata lives beside the persisted definition. Moving nodes does not notify definition subscribers, parse JSX,
invalidate preview evidence, or call the executor. ELK is loaded on demand for explicit arrangement.
- Selection, viewport, panes, response bodies, temporary parameters, and Assistant conversation state are session state.
Node data contains compact summaries and stable identifiers, not credentials or response bodies.
- `canvas-preview.tsx` mounts the live preview inside the Widget node. Its header drags the node; preview controls and
authored interactions use `nodrag`, `nopan`, and `nowheel`. CodeMirror and Assistant live in a contextual side pane,
outside canvas zoom, and remain mounted when hidden. Preview tools open in a portaled popup. The preview theme surface
isolates its styles and portal content from management chrome. Runtime-owned dropdown targets escape widget paint
containment while preserving scoped CSS, preview themes, and dialog focus traps. Nested menus and popovers retain
inline dropdowns; interpreter-authored events and portal targets remain blocked.
- Assistant draft commands use the existing conversation and client-tool infrastructure. Draft identity and generation
preconditions reject delayed patches. An accepted patch is one document transaction, not an implicit save or action.
## Compatibility and capabilities
Plain HTTP and static widgets retain the v2 schema. A definition with runtime extensions uses v3; strict v2 definitions
never acquire extension fields. Editor layout is not portable runtime content. Empty sources and requests support static
widgets without a placeholder service URL.
V3 adds scoped styles and keyframes, theme/container bindings, named views, supported detail overlays and embeds,
per-user browser preferences, permissioned shared board content, and curated native capabilities. The initial native
catalog covers Beszel systems/history/alerts/live data, calendars, media requests, Home Assistant, and Docker. It does not
expose arbitrary tRPC methods. Other services use HTTP or embeds; services without these interfaces need an external bridge.
The executable Beszel reference definition and current capability inventory are documented under
`apps/docs/docs/management/custom-widgets/`. The reference includes system selection, historical charts, container statistics,
live statistics, preferences, and partial failures. The catalog is an explicit coverage boundary, not a claim that every
native Homarr widget has a dedicated capability descriptor.
Workshop installation and MCP use the same install procedure. Provenance records endpoint, submission, revision, and
portable fingerprints. Update review retains local connections and secrets, rejects incompatible/local changes, and keeps
one previous package for rollback. Publication updates the existing owned submission or creates an attributed remix.
PocketBase schema validation and SQLite, MySQL, and PostgreSQL migrations ship with these changes.
## Authoring connections
`connection-planner.ts` validates the same operations for ports and the inspector picker. Source associations and action
invalidations update their manifest fields directly. Query, action, and option connections open a binding dialog: choose a
response field, display, or parameter mapping before applying a bounded JSX/manifest edit. Repeated connections can add
another field or display for the same relationship. Query and action creation never runs a remote operation implicitly.
`template-insertion.ts` preserves existing source text around inserted components. Generated bindings carry portable
comment markers for ownership checks. Only unchanged, wholly generated relationships can be removed or replaced visually;
authored or mixed references open in JSX. Replacement applies the removal and new binding in one document transaction.
A stale dialog cannot overwrite changes made after it opened.
## Acceptance evidence and limits
The current canvas presentation has been checked in the isolated SQLite development app with Chromium on Linux. Focused
browser checks cover real query/option/action bindings, repeated mappings, shared Undo, live JSX rendering, invalid-JSX
preview continuity, generated binding disconnect/reconnect and cancellation, compact controls, theme and size changes,
and recovery while the inspector is closed. The preview tools fit a 390px viewport. Real pointer movement and zoom retained the preview shadow tree and CodeMirror
without execution requests. Scoped light/dark accessibility scans found no violations; overlap prevents automated contrast
verification for some canvas elements. The 38 focused existing workbench/runtime/preview tests passed. Tests use local/sample data and simulated actions; these checks do not exercise a real external action
or public Workshop publication.
Earlier implementation passes covered local mock Beszel data and a deterministic Assistant provider, Workshop installation
and board rendering, scoped portals, focused runtime tests, and production builds. Earlier production canvas measurements
covered 16 nodes, 64 requests, and a template near 50,000 characters with zero execution requests or parser entries during
movement; board manifests excluded React Flow, ELK, and workbench chunks. Those measurements predate the preview-in-node
presentation and are not production performance or bundle proof for this final UI.
Physical touch/trackpad testing, a broader browser/device matrix, a fresh production performance pass, real external service
actions, and executing the MySQL/PostgreSQL migrations against live database servers remain separate acceptance work.
Schema snapshots and migration ancestry were checked for all three drivers. An existing demo-gallery test requires the
unavailable llama.cpp demo module. The current development acceptance uses repository routing unchanged; an earlier
production QA instance needed a temporary generated server-routing override.
@@ -0,0 +1,129 @@
"use client";
import { Button, Modal, Stack, Text } from "@mantine/core";
import type { Connection } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { createFlowCommands, isGraphEditable } from "./commands";
import { projectWidgetGraph, readObject, record } from "./graph";
import type { FlowKind } from "./graph";
import { placeAddedNodes } from "./node-placement";
import { useFlowConnection } from "./connection-provider";
import { connectionIssue, planFlowConnection } from "./connection-planner";
export interface PendingConnection {
nodeId: string;
handleType: "source" | "target";
position: { x: number; y: number };
}
export function AddNextStep({
pending,
form,
onClose,
onSelect,
}: {
pending: PendingConnection | null;
form: CustomWidgetWorkbenchForm;
onClose(): void;
onSelect(id: string): void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const connect = useFlowConnection();
if (!pending) return null;
const graph = projectWidgetGraph(store.getValues(), store.getLayout());
const node = graph.nodes.find((entry) => entry.id === pending?.nodeId);
const kinds: FlowKind[] = [];
if (pending?.handleType === "source" && node?.data.kind === "source") kinds.push("query", "action");
if (pending?.handleType === "source" && node?.data.kind === "action") kinds.push("query");
if (pending?.handleType === "target" && node && ["query", "action"].includes(node.data.kind)) kinds.push("source");
if (node?.data.kind === "widget") {
kinds.push("query", "action");
if (pending.handleType === "target") kinds.push("options");
}
if (node?.data.kind === "options" && pending.handleType === "source") kinds.push("query", "action");
const existing = graph.nodes.filter((entry) => {
let source = pending.nodeId;
let target = entry.id;
if (pending.handleType === "target") {
source = entry.id;
target = pending.nodeId;
}
return !connectionIssue({ source, target }, graph.nodes);
});
const add = (kind: FlowKind) => {
if (!pending || !node || !isGraphEditable(store.getValues())) return;
let id = "widget";
let binding: Connection | undefined;
store.transaction(() => {
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
const commands = createFlowCommands(form, store);
let sourceId: string | undefined;
if (node.data.kind === "source") sourceId = node.data.identifier;
if (node.data.kind === "action") {
const source = record(readObject(store.getValues().requests)[node.data.identifier]).source;
if (typeof source === "string") sourceId = source;
}
id = commands.add(kind, sourceId);
const nextGraph = projectWidgetGraph(store.getValues(), store.getLayout());
let source = pending.nodeId;
let target = id;
if (pending.handleType === "target") {
source = id;
target = pending.nodeId;
}
const connection = { source, target, sourceHandle: "out", targetHandle: "in" };
if (planFlowConnection(connection, nextGraph.nodes, store.getValues()) === "direct")
commands.connect(connection, nextGraph.nodes);
else binding = connection;
store.setLayout({
...store.getLayout(),
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, nextGraph.nodes, id, pending.position) },
});
});
onClose();
onSelect(id);
if (binding) connect(binding, projectWidgetGraph(store.getValues(), store.getLayout()).nodes);
};
return (
<Modal
opened={!!pending}
onClose={onClose}
title={t("addNextStep")}
closeButtonProps={{ "aria-label": t("bindingCancel") }}
size="sm"
>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("nextStepFor", { name: node?.data.label ?? "" })}
</Text>
{kinds.map((kind) => (
<Button key={kind} disabled={!isGraphEditable(store.getValues())} variant="light" onClick={() => add(kind)}>
{t(`kind.${kind}`)}
</Button>
))}
{existing.map((entry) => (
<Button
key={entry.id}
disabled={!isGraphEditable(store.getValues())}
variant="subtle"
onClick={() => {
let source = pending.nodeId;
let target = entry.id;
if (pending.handleType === "target") {
source = entry.id;
target = pending.nodeId;
}
const issue = connect({ source, target, sourceHandle: "out", targetHandle: "in" }, graph.nodes);
if (issue) return;
onClose();
onSelect(entry.id);
}}
>
{t("bindingConnectExisting", { name: entry.data.label })}
</Button>
))}
{kinds.length === 0 && existing.length === 0 && <Text size="sm">{t("codeOwned")}</Text>}
</Stack>
</Modal>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Stack, Text } from "@mantine/core";
import { useAuiState } from "@assistant-ui/react";
import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant, useAssistantPreferences } from "~/components/assistant/assistant-context";
import { AssistantConversationSurface } from "~/components/assistant/assistant-panel";
import { AssistantComposerSurfaceBoundary } from "~/components/assistant/assistant-runtime-provider";
import { getPendingAssistantAction } from "~/components/assistant/assistant-pending-action";
import {
applyWorkbenchTemplateEdits,
getWorkbenchDraftId,
registerAssistantWorkbench,
} from "~/components/assistant/assistant-workbench-bridge";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { buildDefinition } from "../_custom-widget-form-utils";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { withoutCredentials } from "./history";
export function WorkbenchAssistant({
form,
selected,
onSelect,
onPreview,
}: {
form: CustomWidgetWorkbenchForm;
selected: string;
onSelect(id: string): void;
onPreview(): Promise<unknown>;
}) {
const store = useCustomWidgetFormDocumentStore();
const draftId = getWorkbenchDraftId(store);
const t = useI18n("customWidget.flow");
const assistant = useOptionalHomarrAssistant();
const [result, setResult] = useState("");
useEffect(
() =>
registerAssistantWorkbench({
read: () => {
const draft = buildDefinition(store.getValues());
if (!draft.success)
return {
success: false,
draftId,
revision: store.getRevision(),
selected,
fields: withoutCredentials(store.getValues()),
diagnostics: draft.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
};
return { success: true, draftId, revision: store.getRevision(), selected, definition: draft.data };
},
apply: (patch) => {
if (patch.draftId !== draftId || patch.revision !== store.getRevision())
return { success: false, error: "Draft changed. Read it again before applying changes." };
const next = { ...withoutCredentials(store.getValues()), ...patch.changes };
if (patch.templateEdits?.length) {
if (patch.changes.template !== undefined)
return { success: false, error: "Choose targeted edits or a complete template, not both." };
try {
next.template = applyWorkbenchTemplateEdits(next.template, patch.templateEdits);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Invalid template edit" };
}
}
const parsed = buildDefinition({ ...next, secrets: [] });
if (!parsed.success)
return {
success: false,
diagnostics: parsed.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
};
store.transaction(() => form.setValues({ ...next, secrets: store.getValues().secrets }));
setResult(patch.summary);
if (patch.focus) onSelect(patch.focus);
return { success: true, draftId, revision: store.getRevision(), summary: patch.summary };
},
preview: async (precondition) => {
if (precondition.draftId !== draftId || precondition.revision !== store.getRevision())
return { success: false, error: "Draft changed. Read it again before running queries." };
return onPreview();
},
}),
[draftId, form, onPreview, onSelect, selected, store],
);
return (
<Stack gap="sm">
{result && (
<Alert color="teal" aria-live="polite">
{result}
</Alert>
)}
{!assistant?.enabled && (
<Text size="xs" c="dimmed">
{assistant?.unavailableDescription ?? t("assistantUnavailable")}
</Text>
)}
{assistant?.enabled && (
<AssistantComposerSurfaceBoundary surfaceId="custom-widget-workbench">
<Conversation />
</AssistantComposerSurfaceBoundary>
)}
</Stack>
);
}
function Conversation() {
const assistant = useOptionalHomarrAssistant();
const preferences = useAssistantPreferences();
const messages = useAuiState((state) => state.thread.messages);
const last = messages.findLast((message) => message.role === "assistant");
if (!assistant) return null;
return (
<div style={{ height: 580, display: "flex", flexDirection: "column", minHeight: 0 }}>
<AssistantConversationSurface
isRunning={assistant.isRunning}
pendingAction={getPendingAssistantAction(last)}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
isRefreshing={assistant.isRefreshing}
onRefresh={assistant.refreshCurrentView}
onModelChange={preferences.setModelId}
onReasoningChange={preferences.setReasoning}
/>
</div>
);
}
@@ -0,0 +1,214 @@
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import {
collectTemplateReferences,
insertCustomWidgetTemplateContent,
markGeneratedBinding,
removeGeneratedBindings,
} from "@homarr/custom-widgets/workbench";
import type { GeneratedBindingDescriptor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { buildDefinition } from "../_custom-widget-form-utils";
import { readObject, record } from "./graph";
import type { FlowBindingIntent } from "./connection-planner";
import { isGeneratedConnectionCurrent } from "./connection-planner";
import { nativeLiteralDefault, nativeParameterFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
import { freshFlowIdentifier } from "./identifiers";
export interface BindingParameter {
mode: "control" | "input" | "option" | "literal";
name: string;
controlName: string;
value: unknown;
}
export interface BindingChoices {
format: "json" | "text" | "list" | "table";
path: string;
columns: string;
label: string;
option: string;
field: string;
location: "query" | "body" | "path" | "input";
params: Record<string, BindingParameter>;
}
export interface BindingLabels {
loading: string;
empty: string;
run: string;
error: string;
}
export function bindingDescriptor(intent: FlowBindingIntent) {
if (intent.native) return record(record(readObject(intent.snapshot.extensions).native)[intent.identifier]);
return record(readObject(intent.snapshot.requests)[intent.identifier]);
}
export function bindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
const descriptor = bindingDescriptor(intent);
if (intent.native) return nativeParameterFields(descriptor.input, schema);
const references = collectCustomWidgetRequestReferences({
path: String(descriptor.path ?? ""),
query: record(descriptor.query),
body: descriptor.body,
});
return [...references.params].map((name) => ({ name, schema: {} as NativeInputSchema }));
}
export function createBindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
const occupied = Object.fromEntries(
collectTemplateReferences(intent.snapshot.template)
.filter((ref) => ref.kind === "bind")
.map((ref) => [ref.name, true]),
);
return Object.fromEntries(
bindingParameters(intent, schema).map(({ name, schema: field }) => {
const binding = freshFlowIdentifier(occupied, `${intent.identifier}-${name}`);
occupied[binding] = true;
return [
name,
{
mode: "control",
name: binding,
controlName: binding,
value: nativeLiteralDefault(field),
} satisfies BindingParameter,
];
}),
);
}
const quote = JSON.stringify;
function propertyPath(root: string, path: string) {
const parts = path.trim().split(".").filter(Boolean);
if (parts.length > 16 || parts.some((part) => ["constructor", "prototype", "__proto__"].includes(part)))
throw new Error("Invalid data path");
return root + parts.map((part) => `?.[${quote(part)}]`).join("");
}
function parameterCode(choices: BindingChoices) {
const controls: string[] = [];
const entries = Object.entries(choices.params).map(([key, parameter]) => {
let expression = quote(parameter.value);
if (!["string", "number", "boolean"].includes(typeof parameter.value))
throw new Error("Parameters must be text, numbers, or booleans");
if (parameter.mode === "input" || parameter.mode === "control") expression = `inputs[${quote(parameter.name)}]`;
if (parameter.mode === "option") expression = `options[${quote(parameter.name)}]`;
if (parameter.mode === "control") {
let component = "TextInput";
let defaultProp = "defaultValue";
if (typeof parameter.value === "number") component = "NumberInput";
if (typeof parameter.value === "boolean") {
component = "Switch";
defaultProp = "defaultChecked";
}
controls.push(
`<${component} bind=${quote(parameter.name)} label={${quote(key)}} ${defaultProp}={${quote(parameter.value)}} />`,
);
}
return `${quote(key)}: ${expression}`;
});
return { controls, params: `params={{ ${entries.join(", ")} }}` };
}
function renderValue(root: string, choices: BindingChoices, labels: BindingLabels) {
const value = propertyPath(root, choices.path);
if (choices.format === "text") return `<Text>{String(${value} ?? ${quote(labels.empty)})}</Text>`;
if (choices.format === "json")
return `{(${value} ?? null) === null ? <Text c="dimmed">{${quote(labels.empty)}}</Text> : <Code block>{JSON.stringify(${value})}</Code>}`;
const rows = `(${value}).slice(0, 50)`;
let body = `${rows}.map((row, index) => <Text key={index} size="sm">{JSON.stringify(row)}</Text>)`;
if (choices.format === "table") {
const columns = choices.columns
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.slice(0, 8);
if (!columns.length) throw new Error("Choose at least one table column");
const header = columns.map((column) => `<Table.Th>{${quote(column)}}</Table.Th>`).join("");
const cells = columns
.map((column) => `<Table.Td>{String(${propertyPath("row", column)} ?? "")}</Table.Td>`)
.join("");
body = `<Table.ScrollContainer minWidth={300}><Table><Table.Thead><Table.Tr>${header}</Table.Tr></Table.Thead><Table.Tbody>{${rows}.map((row, index) => <Table.Tr key={index}>${cells}</Table.Tr>)}</Table.Tbody></Table></Table.ScrollContainer>`;
} else body = `<Stack gap="xs">{${body}}</Stack>`;
return `{Array.isArray(${value}) && ${value}.length > 0 ? (${body}) : <Text c="dimmed">{${quote(labels.empty)}}</Text>}`;
}
export function createBindingSnippet(intent: FlowBindingIntent, choices: BindingChoices, labels: BindingLabels) {
if (intent.kind === "option") {
if (!record(readObject(intent.snapshot.options)[choices.option]).control)
throw new Error("Choose an existing option");
return `<Text>{String(options[${quote(choices.option)}] ?? "")}</Text>`;
}
const descriptor = bindingDescriptor(intent);
const { controls, params } = parameterCode(choices);
let content: string;
if (intent.kind === "action") {
let component = "ActionButton";
let idProp = "requestId";
if (intent.native) {
component = "NativeActionButton";
idProp = "nativeId";
}
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} variant="default">{${quote(choices.label || labels.run)}}</${component}>`;
} else if (descriptor.trigger === "manual" || descriptor.capability === "beszel.live") {
let component = "SubFetch";
let idProp = "requestId";
let live = "";
if (intent.native) {
component = "NativeQuery";
idProp = "nativeId";
}
if (descriptor.capability === "beszel.live") live = " live";
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} trigger="manual"${live}>{(result, meta) => (<Stack gap="xs">${renderValue("result", choices, labels)}</Stack>)}</${component}>`;
} else {
const status = `status[${quote(intent.identifier)}]`;
content = `<Stack gap="xs">\n{${status}?.loading ? <Text c="dimmed">{${quote(labels.loading)}}</Text> : ${status}?.error ? <Alert color="red">{String(${status}.error)}</Alert> : (${`<Stack gap="xs">${renderValue(`data[${quote(intent.identifier)}]`, choices, labels)}</Stack>`})}\n<RefreshButton />\n</Stack>`;
}
if (!controls.length) return content;
return `<Stack gap="xs">\n${controls.join("\n")}\n${content}\n</Stack>`;
}
export function bindingCandidate(
intent: FlowBindingIntent,
values: CustomWidgetFormValues,
choices: BindingChoices,
labels: BindingLabels,
) {
const options = readObject(values.options);
const bindings = collectTemplateReferences(values.template)
.filter((entry) => entry.kind === "bind")
.map((entry) => entry.name);
for (const parameter of Object.values(choices.params)) {
if (
parameter.mode === "option" &&
!["string", "number", "boolean"].includes(typeof record(options[parameter.name]).default)
)
throw new Error("Choose an option with a text, number, or boolean value");
if (parameter.mode === "input" && !bindings.includes(parameter.name))
throw new Error("Choose an existing widget input");
if (parameter.mode === "control" && bindings.includes(parameter.name))
throw new Error("A widget input with this name already exists");
}
const snippet = createBindingSnippet(intent, choices, labels);
let template = values.template;
if (intent.replaceGeneratedIds) {
if (!isGeneratedConnectionCurrent(values, intent.replaceEdgeId, intent.replaceGeneratedIds))
throw new Error("The original connection now includes code-owned references. Edit this relationship in code.");
const fragments = Object.values(record(readObject(values.extensions).fragments)).filter(
(value): value is string => typeof value === "string",
);
const removed = removeGeneratedBindings(template, intent.replaceGeneratedIds, fragments);
if (removed === null) throw new Error("The original binding changed. Edit this relationship in code.");
template = removed;
}
const descriptor: GeneratedBindingDescriptor = { source: intent.source, target: "widget", relationship: "data" };
if (intent.kind === "option") descriptor.relationship = "option";
if (intent.kind === "action" || (!intent.native && bindingDescriptor(intent).trigger === "manual")) {
descriptor.source = "widget";
descriptor.target = intent.source;
descriptor.relationship = "invoke";
}
const candidate = {
...values,
template: insertCustomWidgetTemplateContent(template, markGeneratedBinding(snippet, descriptor)),
};
const parsed = buildDefinition(candidate);
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
return { patch: { template: candidate.template }, preview: snippet };
}
@@ -0,0 +1,252 @@
"use client";
import { useMemo, useState, useSyncExternalStore } from "react";
import {
Accordion,
Alert,
Autocomplete,
Button,
Code,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { isFlowBindingCurrent } from "./connection-planner";
import type { FlowBindingIntent } from "./connection-planner";
import { bindingCandidate, bindingDescriptor, bindingParameters, createBindingParameters } from "./binding-code";
import type { BindingChoices } from "./binding-code";
import { BindingParameterEditor } from "./binding-parameter-editor";
import { BindingOptionEditor } from "./binding-option-editor";
import { requestOptionCandidate } from "./binding-request-option";
import { readObject } from "./graph";
import { useWorkbenchQuery } from "./execution";
import { nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
export function FlowBindingDialog(props: {
intent: FlowBindingIntent;
form: CustomWidgetWorkbenchForm;
onClose(): void;
}) {
const t = useI18n("customWidget.flow");
const catalog = clientApi.customWidget.nativeCapabilities.useQuery(undefined, { enabled: props.intent.native });
const descriptor = bindingDescriptor(props.intent);
const schema = catalog.data?.find((entry) => entry.id === descriptor.capability)?.inputSchema;
let title = t("bindingTitle");
if (props.intent.kind === "requestOption") title = t("bindingConfigureRequest");
return (
<Modal
opened
onClose={props.onClose}
title={title}
closeButtonProps={{ "aria-label": t("bindingCancel") }}
size="lg"
>
{props.intent.native && !schema ? (
<Alert color="yellow">
{catalog.isError ? t("bindingCatalogError") : t("bindingCatalogLoading")}
{catalog.isError && (
<Button type="button" size="xs" onClick={() => void catalog.refetch()}>
{t("bindingRetry")}
</Button>
)}
</Alert>
) : (
<BindingEditor {...props} schema={schema ?? {}} />
)}
</Modal>
);
}
function BindingEditor({
intent,
form,
onClose,
schema,
}: {
intent: FlowBindingIntent;
form: CustomWidgetWorkbenchForm;
onClose(): void;
schema: NativeInputSchema;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
useSyncExternalStore(store.subscribe, store.getRevision, store.getRevision);
const [choices, setChoices] = useState<BindingChoices>(() => ({
format: "json",
path: "",
columns: "name",
label: intent.identifier,
option: Object.keys(readObject(intent.snapshot.options))[0] ?? "",
field: nativeInputFields(schema)[0]?.name ?? "value",
location: intent.native ? "input" : "query",
params: createBindingParameters(intent, schema),
}));
const [applyError, setApplyError] = useState<string | null>(null);
const sample = useWorkbenchQuery(intent.identifier);
const paths = useMemo(() => samplePaths(sample?.data), [sample?.data]);
const options = readObject(intent.snapshot.options);
const inputs = [
...new Set(
collectTemplateReferences(intent.snapshot.template)
.filter((entry) => entry.kind === "bind")
.map((entry) => entry.name),
),
];
const params = bindingParameters(intent, schema);
const current = isFlowBindingCurrent(intent, store.getValues());
const change = (patch: Partial<BindingChoices>) => {
setChoices((value) => ({ ...value, ...patch }));
setApplyError(null);
};
const prepare = () => {
if (intent.kind === "requestOption") return requestOptionCandidate(intent, store.getValues(), choices, schema);
return bindingCandidate(intent, store.getValues(), choices, {
loading: t("bindingLoading"),
empty: t("bindingEmpty"),
run: t("bindingRun"),
error: t("bindingError"),
});
};
let candidate: ReturnType<typeof prepare> | undefined;
let error: string | null = null;
try {
if (current) candidate = prepare();
} catch (reason) {
error = reason instanceof Error ? reason.message : t("bindingError");
}
return (
<Stack gap="md" data-workbench-local-input>
<Text size="sm" c="dimmed">
{t("bindingDescription")}
</Text>
{(intent.kind === "option" || intent.kind === "requestOption") && (
<BindingOptionEditor intent={intent} choices={choices} schema={schema} onChange={change} />
)}
{intent.kind === "query" && (
<>
<Select
label={t("bindingDisplay")}
value={choices.format}
data={[
{ value: "json", label: t("bindingJson") },
{ value: "text", label: t("bindingText") },
{ value: "list", label: t("bindingList") },
{ value: "table", label: t("bindingTable") },
]}
onChange={(format) => format && change({ format: format as BindingChoices["format"] })}
/>
<Autocomplete
label={t("bindingDataPath")}
description={t("bindingDataPathHint")}
data={paths}
value={choices.path}
onChange={(path) => change({ path })}
/>
{choices.format === "table" && (
<TextInput
label={t("bindingColumns")}
description={t("bindingColumnsHint")}
value={choices.columns}
onChange={(event) => change({ columns: event.currentTarget.value })}
/>
)}
</>
)}
{intent.kind === "action" && (
<TextInput
label={t("bindingButtonLabel")}
value={choices.label}
onChange={(event) => change({ label: event.currentTarget.value })}
/>
)}
{(intent.kind === "query" || intent.kind === "action") && params.length > 0 && (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("bindingParameterHint")}
</Text>
{params.map(
({ name, schema: fieldSchema }) =>
choices.params[name] && (
<BindingParameterEditor
key={name}
name={name}
schema={fieldSchema}
parameter={choices.params[name]}
options={options}
inputs={inputs}
onChange={(value) => change({ params: { ...choices.params, [name]: value } })}
/>
),
)}
</Stack>
)}
{!current && (
<Alert color="yellow" aria-live="polite">
{t("bindingStale")}
</Alert>
)}
{(error || applyError) && (
<Alert color="yellow" aria-live="polite">
{applyError ?? error}
</Alert>
)}
{candidate && (
<Accordion variant="contained">
<Accordion.Item value="code">
<Accordion.Control>{t("bindingReview")}</Accordion.Control>
<Accordion.Panel>
<Code block mah={240} style={{ overflow: "auto", whiteSpace: "pre-wrap" }}>
{candidate.preview}
</Code>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
<Group justify="end">
<Button type="button" variant="default" onClick={onClose}>
{t("bindingCancel")}
</Button>
<Button
type="button"
disabled={!current || !candidate}
onClick={() => {
try {
if (!isFlowBindingCurrent(intent, store.getValues())) throw new Error(t("bindingStale"));
const result = prepare();
store.transaction(() => form.setValues({ ...store.getValues(), ...result.patch }));
onClose();
} catch (reason) {
setApplyError(reason instanceof Error ? reason.message : t("bindingError"));
}
}}
>
{t("bindingApply")}
</Button>
</Group>
</Stack>
);
}
function samplePaths(value: unknown) {
const paths: string[] = [];
const visit = (entry: unknown, path: string, depth: number) => {
if (paths.length >= 100 || depth > 5 || !entry || typeof entry !== "object") return;
for (const [key, child] of Object.entries(entry).slice(0, 25)) {
if (key.includes(".")) continue;
let next = key;
if (path) next = `${path}.${key}`;
paths.push(next);
if (!Array.isArray(entry)) visit(child, next, depth + 1);
}
};
visit(value, "", 0);
return paths.slice(0, 100);
}
@@ -0,0 +1,87 @@
"use client";
import { Select, Stack, TextInput } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { FlowBindingIntent } from "./connection-planner";
import { bindingDescriptor } from "./binding-code";
import type { BindingChoices } from "./binding-code";
import { readObject } from "./graph";
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
export function BindingOptionEditor({
intent,
choices,
schema,
onChange,
}: {
intent: FlowBindingIntent;
choices: BindingChoices;
schema: NativeInputSchema;
onChange(value: Partial<BindingChoices>): void;
}) {
const t = useI18n("customWidget.flow");
const fields = nativeInputFields(schema);
const fieldSchema = fields.find((field) => field.name === choices.field)?.schema ?? {};
let options = Object.entries(readObject(intent.snapshot.options));
if (intent.native) options = options.filter(([, option]) => isCompatibleNativeOption(option, fieldSchema));
const descriptor = bindingDescriptor(intent);
const pathFields = [...String(descriptor.path ?? "").matchAll(/\{param:([A-Za-z][A-Za-z0-9_-]*)\}/gu)].map(
(match) => match[1] ?? "",
);
return (
<Stack gap="sm">
<Select
label={t("bindingOption")}
searchable
value={choices.option}
data={options.map(([name]) => name)}
onChange={(value) => value && onChange({ option: value })}
/>
{intent.kind === "requestOption" && (
<>
{!intent.native && (
<Select
label={t("bindingRequestLocation")}
value={choices.location}
data={[
{ value: "query", label: t("bindingQueryParameter") },
{ value: "body", label: t("bindingBodyField") },
{ value: "path", label: t("bindingPathParameter"), disabled: pathFields.length === 0 },
]}
onChange={(value) => {
if (!value) return;
let field = choices.field;
if (value === "path") field = pathFields[0] ?? "";
onChange({ location: value as BindingChoices["location"], field });
}}
/>
)}
{intent.native && (
<Select
label={t("bindingRequestField")}
searchable
value={choices.field}
data={fields.map(({ name }) => name)}
onChange={(value) => value && onChange({ field: value })}
/>
)}
{!intent.native && choices.location === "path" && (
<Select
label={t("bindingRequestField")}
value={choices.field}
data={[...new Set(pathFields)]}
onChange={(value) => value && onChange({ field: value })}
/>
)}
{!intent.native && choices.location !== "path" && (
<TextInput
label={t("bindingRequestField")}
value={choices.field}
onChange={(event) => onChange({ field: event.currentTarget.value })}
/>
)}
</>
)}
</Stack>
);
}
@@ -0,0 +1,91 @@
"use client";
import { Group, Select, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { BindingParameter } from "./binding-code";
import type { NativeInputSchema } from "./native-editor-model";
import { isCompatibleNativeOption } from "./native-editor-model";
import { NativeLiteralInput } from "./native-literal-input";
export function BindingParameterEditor({
name,
parameter,
schema,
options,
inputs,
onChange,
}: {
name: string;
parameter: BindingParameter;
schema: NativeInputSchema;
options: Record<string, unknown>;
inputs: string[];
onChange(value: BindingParameter): void;
}) {
const t = useI18n("customWidget.flow");
const optionNames = Object.keys(options).filter((key) => isCompatibleNativeOption(options[key], schema));
let fieldSchema = schema;
if (!schema.type) fieldSchema = { ...schema, type: typeof parameter.value };
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{name}
</Text>
<Group grow align="start">
<Select
label={t("bindingParameterSource")}
value={parameter.mode}
data={[
{ value: "control", label: t("bindingNewControl") },
{ value: "literal", label: t("nativeLiteral") },
{ value: "option", label: t("nativeOptionBinding"), disabled: optionNames.length === 0 },
{ value: "input", label: t("bindingExistingInput"), disabled: inputs.length === 0 },
]}
onChange={(mode) => {
if (!mode) return;
let selected = parameter.name;
if (mode === "option") selected = optionNames[0] ?? "";
if (mode === "input") selected = inputs[0] ?? "";
if (mode === "control") selected = parameter.controlName;
onChange({ ...parameter, mode: mode as BindingParameter["mode"], name: selected });
}}
/>
{(parameter.mode === "option" || parameter.mode === "input") && (
<Select
label={t("bindingValue")}
value={parameter.name}
searchable
data={parameter.mode === "option" ? optionNames : inputs}
onChange={(value) => value && onChange({ ...parameter, name: value })}
/>
)}
{(parameter.mode === "literal" || parameter.mode === "control") && (
<NativeLiteralInput
label={t("bindingValue")}
value={parameter.value}
schema={fieldSchema}
required
disabled={false}
onChange={(value) => onChange({ ...parameter, value })}
/>
)}
</Group>
{!schema.type && (parameter.mode === "literal" || parameter.mode === "control") && (
<Select
label={t("bindingParameterType")}
value={typeof parameter.value}
data={[
{ value: "string", label: t("bindingText") },
{ value: "number", label: t("bindingNumber") },
{ value: "boolean", label: t("bindingBoolean") },
]}
onChange={(type) => {
let value: unknown = "";
if (type === "number") value = 0;
if (type === "boolean") value = false;
onChange({ ...parameter, value });
}}
/>
)}
</Stack>
);
}
@@ -0,0 +1,84 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import { buildDefinition, isRecord } from "../_custom-widget-form-utils";
import { readObject, record } from "./graph";
import type { FlowBindingIntent } from "./connection-planner";
import type { BindingChoices } from "./binding-code";
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
const parameters = (value: Record<string, unknown>) =>
collectCustomWidgetRequestReferences({
path: String(value.path ?? ""),
query: record(value.query),
body: value.body ?? value.input,
}).params;
export function requestOptionCandidate(
intent: FlowBindingIntent,
values: CustomWidgetFormValues,
choices: BindingChoices,
schema: NativeInputSchema = {},
) {
if (!Object.hasOwn(readObject(values.options), choices.option)) throw new Error("Choose an existing option");
const key = choices.field.trim();
if (intent.native) {
const fields = nativeInputFields(schema);
const target = fields.find((field) => field.name === key);
if (
fields.length &&
(!target || !isCompatibleNativeOption(readObject(values.options)[choices.option], target.schema))
)
throw new Error("Choose an option compatible with this capability field");
}
if (!key || key.length > 256 || ["__proto__", "constructor", "prototype"].includes(key))
throw new Error("Choose a valid request field");
const reference = { $option: choices.option };
if (intent.native && choices.location !== "input") throw new Error("Choose a native capability input");
if (!intent.native && choices.location === "input") throw new Error("Choose an HTTP request field");
const field = intent.native ? "extensions" : "requests";
const document = readObject(values[field]);
let entries = document;
if (intent.native) entries = record(document.native);
const descriptor = record(entries[intent.identifier]);
const updated = { ...descriptor };
if (choices.location === "path") {
const placeholder = `{param:${key}}`;
const path = String(descriptor.path ?? "");
if (!path.includes(placeholder)) throw new Error("Choose an existing path parameter");
updated.path = path.replaceAll(placeholder, `{option:${choices.option}}`);
} else {
const current = descriptor[choices.location];
if (current !== undefined && (!isRecord(current) || "$option" in current || "$param" in current))
throw new Error("This field is code-owned. Edit its existing binding before adding a named field.");
updated[choices.location] = { ...record(current), [key]: reference };
}
const nextEntries = { ...entries, [intent.identifier]: updated };
const remaining = parameters(updated);
if ([...parameters(descriptor)].some((name) => !remaining.has(name))) {
const templates = [
values.template,
...Object.values(record(readObject(values.extensions).fragments)).filter(
(value): value is string => typeof value === "string",
),
];
if (
templates.some((template) =>
collectTemplateReferences(template).some(
(entry) =>
["request", "native"].includes(entry.kind) && (entry.name === intent.identifier || entry.name === "*"),
),
)
)
throw new Error(
"This change removes an invocation parameter used by existing JSX. Update that control in code first.",
);
}
let nextDocument = nextEntries;
if (intent.native) nextDocument = { ...document, native: nextEntries };
const patch = { [field]: JSON.stringify(nextDocument, null, 2) };
const parsed = buildDefinition({ ...values, ...patch });
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
return { patch, preview: JSON.stringify(updated, null, 2) };
}
@@ -0,0 +1,65 @@
"use client";
import { Controls } from "@xyflow/react";
import { Button, Group, Menu } from "@mantine/core";
import { IconArrowBackUp, IconChevronDown, IconFocus2, IconLayoutDistributeHorizontal } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./workbench.module.css";
export function CanvasControls({
arranging,
selection,
branch,
arrange,
focus,
fit,
}: {
arranging: boolean;
selection: string[];
branch: boolean;
arrange(selectionOnly?: boolean): void;
focus(related: boolean): void;
fit(): void;
}) {
const t = useI18n("customWidget.flow");
return (
<Group className={classes.canvasTools} gap={4} p="xs">
<Menu position="bottom-start" withinPortal>
<Menu.Target>
<Button size="compact-sm" variant="default" rightSection={<IconChevronDown size={14} />} loading={arranging}>
{t("canvasOptions")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLayoutDistributeHorizontal size={16} />}
disabled={arranging}
onClick={() => arrange()}
>
{t("arrange")}
</Menu.Item>
<Menu.Item disabled={!selection.length || arranging} onClick={() => arrange(true)}>
{t("arrangeSelection")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconFocus2 size={16} />} onClick={fit}>
{t("fit")}
</Menu.Item>
{selection.length > 0 && <Menu.Item onClick={() => focus(false)}>{t("focus")}</Menu.Item>}
{selection.length > 0 && <Menu.Item onClick={() => focus(true)}>{t("focusBranch")}</Menu.Item>}
</Menu.Dropdown>
</Menu>
{branch && (
<Button size="compact-sm" variant="default" leftSection={<IconArrowBackUp size={14} />} onClick={fit}>
{t("allNodes")}
</Button>
)}
</Group>
);
}
export function CanvasViewportControls() {
const t = useI18n("customWidget.flow");
return (
<fieldset aria-label={t("canvasControls")} style={{ border: 0, margin: 0, padding: 0, minInlineSize: 0 }}>
<Controls showInteractive={false} aria-label="" />
</fieldset>
);
}
@@ -0,0 +1,22 @@
import { useEffect } from "react";
import { useReducedMotion } from "@mantine/hooks";
import { useReactFlow } from "@xyflow/react";
/** Keep the rendered widget beside its JSX editor after the canvas gives the editor room. */
export function useCanvasEditorFocus(opened: boolean, selected: string) {
const { fitView } = useReactFlow();
const reducedMotion = useReducedMotion();
useEffect(() => {
if (!opened || selected !== "widget") return;
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
void fitView({ nodes: [{ id: "widget" }], maxZoom: 1, padding: 0.2, duration: reducedMotion ? 0 : 180 });
});
});
return () => cancelAnimationFrame(frame);
}, [fitView, opened, selected, reducedMotion]);
}
export function isPreviewInteraction(target: EventTarget) {
return target instanceof Element && !!target.closest("[data-flow-preview], [data-mantine-portal]");
}
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, KeyboardEvent, SetStateAction } from "react";
import { applyNodeChanges } from "@xyflow/react";
import type { AriaLabelConfig, EdgeChange, NodeChange, NodeMouseHandler, EdgeMouseHandler } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { createFlowCommands } from "./commands";
import type { WidgetEdge, WidgetNode } from "./graph";
import { captureLayout } from "./layout";
import { isPreviewInteraction } from "./canvas-editor-focus";
interface Props {
edges: WidgetEdge[];
selection: string[];
store: CustomWidgetFormDocumentStore;
commands: ReturnType<typeof createFlowCommands>;
getNodes(): WidgetNode[];
setNodes: Dispatch<SetStateAction<WidgetNode[]>>;
onSelect(id: string): void;
onInspect(id: string): void;
onSelection(ids: string[]): void;
onIssue(issue: string | null): void;
}
/** Owns canvas selection and keyboard commands without putting transient selection into document history. */
export function useCanvasInteractions({
edges,
selection,
store,
commands,
getNodes,
setNodes,
onSelect,
onInspect,
onSelection,
onIssue,
}: Props) {
const t = useI18n("customWidget.flow");
const ariaLabelConfig = useMemo<Partial<AriaLabelConfig>>(
() => ({
"controls.zoomIn.ariaLabel": t("zoomIn"),
"controls.zoomOut.ariaLabel": t("zoomOut"),
"controls.fitView.ariaLabel": t("fit"),
"controls.ariaLabel": t("canvasControls"),
"handle.ariaLabel": t("connect"),
"node.a11yDescription.default": t("nodeKeyboardHelp"),
"node.a11yDescription.keyboardDisabled": t("nodePointerHelp"),
"node.a11yDescription.ariaLiveMessage": ({ x, y }) => t("nodeMoved", { x, y }),
"edge.a11yDescription.default": t("edgeKeyboardHelp"),
}),
[t],
);
const [selectedEdge, setSelectedEdge] = useState<string | null>(null);
const selectionRef = useRef(selection);
selectionRef.current = selection;
useEffect(() => {
setNodes((current) =>
current.map((node) => {
const next = selection.includes(node.id);
if (node.selected === next) return node;
return { ...node, selected: next };
}),
);
}, [selection, setNodes]);
const onNodesChange = useCallback(
(changes: NodeChange<WidgetNode>[]) => {
setNodes((current) =>
applyNodeChanges(
changes.filter((change) => change.type !== "remove"),
current,
),
);
const selectionChanges = changes.filter((change) => change.type === "select");
if (selectionChanges.length === 0) return;
const ids = new Set(selectionRef.current);
for (const change of selectionChanges) {
if (change.selected) ids.add(change.id);
else ids.delete(change.id);
}
onSelection([...ids]);
},
[onSelection, setNodes],
);
const onEdgesChange = useCallback((changes: EdgeChange<WidgetEdge>[]) => {
setSelectedEdge((current) => {
let next = current;
for (const change of changes) {
if (change.type !== "select") continue;
if (change.selected) next = change.id;
else if (next === change.id) next = null;
}
return next;
});
}, []);
const inspectEdge = useCallback(
(edge: WidgetEdge) => {
let id = edge.target;
if (edge.data?.relationship === "invalidates") id = edge.source;
setSelectedEdge(edge.id);
onSelection([]);
onInspect(id);
},
[onInspect, onSelection],
);
const onNodeClick: NodeMouseHandler<WidgetNode> = useCallback(
(event, node) => {
if (isPreviewInteraction(event.target)) return;
setSelectedEdge(null);
onInspect(node.id);
},
[onInspect],
);
const onEdgeClick: EdgeMouseHandler<WidgetEdge> = useCallback((_event, edge) => inspectEdge(edge), [inspectEdge]);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!["Delete", "Backspace"].includes(event.key) || !(event.target instanceof Element)) return;
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
const edge = edges.find((entry) => entry.id === edgeId);
if (!edge) return;
// A focused edge owns Delete; never forward it to the workbench's previous node selection.
event.preventDefault();
event.stopPropagation();
onSelection([]);
const result = commands.disconnect(edge);
onIssue(result);
if (!result) {
setSelectedEdge(null);
// The removed SVG cannot retain focus. Keep Undo and subsequent keyboard commands inside the workbench.
event.currentTarget
.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(edge.source)}"]`)
?.focus({ preventScroll: true });
}
},
[commands, edges, onIssue, onSelection],
);
const onKeyUp = useCallback(
(event: KeyboardEvent) => {
if (isPreviewInteraction(event.target)) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest('input, textarea, button, [contenteditable="true"], [role="combobox"]')) return;
const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
if (nodeId && event.key === "F2") {
event.preventDefault();
onSelect(nodeId);
return;
}
if (["Enter", " "].includes(event.key)) {
if (nodeId) {
setSelectedEdge(null);
onInspect(nodeId);
}
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
const edge = edges.find((entry) => entry.id === edgeId);
if (edge) inspectEdge(edge);
return;
}
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
if (!event.target.closest(".react-flow__node, .react-flow__nodesselection")) return;
requestAnimationFrame(() => store.setLayout(captureLayout(getNodes(), store.getLayout())));
},
[edges, getNodes, inspectEdge, onInspect, onSelect, store],
);
return { selectedEdge, ariaLabelConfig, onNodesChange, onEdgesChange, onNodeClick, onEdgeClick, onKeyDown, onKeyUp };
}
@@ -0,0 +1,80 @@
"use client";
import { createContext, memo, useContext, useMemo } from "react";
import type { ReactNode } from "react";
import { Button, Group, Text } from "@mantine/core";
import { Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import { IconCode } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetNode } from "./graph";
import { FlowNode, FlowPort, useValidConnectionTarget } from "./node";
import { FlowGroup } from "./group";
import { WorkbenchPreviewTheme } from "./preview-theme";
import { WorkbenchPreviewPresentationProvider } from "./preview-presentation";
import classes from "./workbench.module.css";
const CanvasPreviewContext = createContext<{ preview: ReactNode; edit(id: string): void } | null>(null);
export function CanvasPreviewProvider({
preview,
edit,
children,
}: {
preview: ReactNode;
edit(id: string): void;
children: ReactNode;
}) {
const value = useMemo(() => ({ preview, edit }), [preview, edit]);
return <CanvasPreviewContext.Provider value={value}>{children}</CanvasPreviewContext.Provider>;
}
export const CanvasWidgetNode = memo(function CanvasWidgetNode({
id,
data,
selected,
isConnectable,
}: NodeProps<WidgetNode>) {
const context = useContext(CanvasPreviewContext);
const t = useI18n("customWidget.flow");
const validTarget = useValidConnectionTarget(id, isConnectable);
return (
<div className={classes.canvasWidgetNode} data-selected={selected} data-valid-target={validTarget}>
<FlowPort
id="in"
type="target"
position={Position.Left}
connectable={isConnectable}
label={`${t("input")} · ${data.label}`}
title={t("input")}
/>
<Group className="widget-node-drag" justify="space-between" wrap="nowrap" p="sm">
<Text size="sm" fw={600} truncate>
{data.label}
</Text>
<Button
className="nodrag nopan"
type="button"
variant="default"
size="compact-xs"
leftSection={<IconCode size={14} />}
onClick={() => context?.edit(id)}
>
{t("editJsx")}
</Button>
</Group>
<div className="nodrag nopan nowheel" data-flow-preview>
<WorkbenchPreviewTheme controls={false}>
<WorkbenchPreviewPresentationProvider mode="canvas">{context?.preview}</WorkbenchPreviewPresentationProvider>
</WorkbenchPreviewTheme>
</div>
<FlowPort
id="out"
type="source"
position={Position.Right}
connectable={isConnectable}
label={`${t("output")} · ${data.label}`}
title={t("output")}
/>
</div>
);
});
export const canvasNodeTypes = { widget: FlowNode, preview: CanvasWidgetNode, group: FlowGroup };
@@ -0,0 +1,16 @@
import type { ReactFlowInstance } from "@xyflow/react";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import type { WidgetEdge, WidgetNode } from "./graph";
export const defaultEdgeOptions = { type: "smoothstep", selectable: true };
export const initialFitOptions = { maxZoom: 1 };
export interface CanvasProps {
form: CustomWidgetWorkbenchForm;
selected: string;
selection: string[];
inspectorOpened: boolean;
onSelect(id: string): void;
onInspect(id: string): void;
onSelection(ids: string[]): void;
onInit(instance: ReactFlowInstance<WidgetNode, WidgetEdge>): void;
}
@@ -0,0 +1,299 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import {
Background,
MarkerType,
ReactFlow,
ReactFlowProvider,
SelectionMode,
useReactFlow,
useNodesInitialized,
} from "@xyflow/react";
import type { Connection, OnConnectEnd } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import {
useDeferredCustomWidgetFormDocumentValues,
useCustomWidgetFormDocumentStore,
} from "../_custom-widget-form-state";
import { defaultEdgeOptions, initialFitOptions } from "./canvas-types";
import type { CanvasProps as Props } from "./canvas-types";
import { isPreviewInteraction, useCanvasEditorFocus } from "./canvas-editor-focus";
import { createFlowCommands, connectionIssue, isGraphEditable } from "./commands";
import { arrangeWidgetGraph, projectWidgetGraph } from "./graph";
import type { WidgetNode, WidgetEdge } from "./graph";
import { captureLayout, projectLayout, expandGroupSelection } from "./layout";
import { canvasNodeTypes } from "./canvas-preview";
import { useFlowConnection } from "./connection-provider";
import { CanvasControls, CanvasViewportControls } from "./canvas-controls";
import { useCanvasInteractions } from "./canvas-interactions";
import { AddNextStep } from "./add-next-step";
import type { PendingConnection } from "./add-next-step";
// oxlint-disable-next-line import/no-unassigned-import -- Canvas-only React Flow styles.
import "@xyflow/react/dist/style.css";
const Canvas = memo(function Canvas(props: Props) {
return (
<ReactFlowProvider>
<CanvasContent {...props} />
</ReactFlowProvider>
);
});
export default Canvas;
function CanvasContent({
form,
selected,
selection,
onSelect,
onInspect,
onSelection,
onInit,
inspectorOpened,
}: Props) {
useCanvasEditorFocus(inspectorOpened, selected);
const t = useI18n("customWidget.flow");
const actions = useI18n("common.action");
const store = useCustomWidgetFormDocumentStore();
const values = useDeferredCustomWidgetFormDocumentValues();
const graph = useMemo(() => projectWidgetGraph(values, store.getLayout()), [values, store]);
const graphEditable = useMemo(() => isGraphEditable(values), [values]);
const [nodes, setNodes] = useState<WidgetNode[]>(graph.nodes);
const [issue, setIssue] = useState<string | null>(null);
const [arranging, setArranging] = useState(false);
const [branch, setBranch] = useState<string[] | null>(null);
const [pending, setPending] = useState<PendingConnection | null>(null);
const initialized = useRef(false);
const nodesInitialized = useNodesInitialized();
const generation = useRef(0);
const layoutJob = useRef(0);
const reconnecting = useRef(false);
const selectionRef = useRef(selection);
selectionRef.current = selection;
const { fitView, getNodes, screenToFlowPosition } = useReactFlow<WidgetNode, WidgetEdge>();
const reducedMotion = useReducedMotion();
const commands = useMemo(() => createFlowCommands(form, store), [form, store]);
const connect = useFlowConnection();
const { selectedEdge, ...interactions } = useCanvasInteractions({
edges: graph.edges,
selection,
store,
commands,
getNodes,
setNodes,
onSelect,
onInspect,
onSelection,
onIssue: setIssue,
});
useEffect(() => {
generation.current += 1;
setNodes((current) => {
const measured = new Map(current.map((node) => [node.id, node]));
return graph.nodes.map((node) => ({
...measured.get(node.id),
...node,
selected: selectionRef.current.includes(node.id),
}));
});
}, [graph]);
useEffect(
() =>
store.subscribeEditor(() => {
generation.current += 1;
setNodes((current) => projectLayout(current, store.getLayout()));
}),
[store],
);
const arrange = useCallback(
async (selectionOnly = false, initial = false) => {
const token = ++generation.current;
const job = ++layoutJob.current;
setArranging(true);
const revision = store.getRevision();
try {
let targets = getNodes();
if (selectionOnly) targets = expandGroupSelection(targets, selectionRef.current);
if (targets.length === 0) return;
const arranged = await arrangeWidgetGraph(targets, graph.edges, selectionOnly);
if (token !== generation.current || revision !== store.getRevision()) return;
const current = store.getLayout();
const next = {
...current,
nodes: { ...current.nodes, ...arranged.nodes },
groups: { ...current.groups, ...arranged.groups },
};
if (initial) {
store.initializeLayout(next);
requestAnimationFrame(() =>
requestAnimationFrame(() => {
void fitView({ duration: 0, maxZoom: 1 });
}),
);
} else store.setLayout(next);
setIssue(null);
} catch {
if (token === generation.current) {
setIssue("layoutFailed");
}
} finally {
if (job === layoutJob.current) setArranging(false);
}
},
[getNodes, graph.edges, store, fitView],
);
useEffect(() => {
if (initialized.current || !nodesInitialized) return;
initialized.current = true;
if (!Object.keys(store.getLayout().nodes).length) void arrange(false, true);
}, [arrange, store, nodesInitialized]);
const onConnect = useCallback(
(connection: Connection) => setIssue(connect(connection, getNodes())),
[connect, getNodes],
);
const isValidConnection = useCallback(
(connection: Connection | WidgetEdge) => graphEditable && !connectionIssue(connection, getNodes()),
[graphEditable, getNodes],
);
const endConnection: OnConnectEnd = (event, state) => {
if (state.isValid || reconnecting.current || !state.fromNode) return;
if (state.toNode) {
setIssue("codeOwned");
return;
}
const pointer = "changedTouches" in event ? event.changedTouches[0] : event;
if (!pointer) return;
const target = document.elementFromPoint(pointer.clientX, pointer.clientY);
if (!target?.closest(".react-flow__pane")) return;
setPending({
nodeId: state.fromNode.id,
handleType: state.fromHandle?.type ?? "source",
position: screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
});
};
const focus = (related: boolean) => {
const ids = new Set(selectionRef.current);
if (related)
for (const edge of graph.edges) {
if (selectionRef.current.includes(edge.source)) ids.add(edge.target);
if (selectionRef.current.includes(edge.target)) ids.add(edge.source);
}
for (const node of getNodes()) if (ids.has(node.id) && node.parentId) ids.add(node.parentId);
if (related) setBranch([...ids]);
void fitView({ nodes: [...ids].map((id) => ({ id })), maxZoom: 1.1, duration: reducedMotion ? 0 : 200 });
};
const displayedNodes = useMemo(
() =>
nodes.map((node) => ({
...node,
dragHandle: ".widget-node-drag",
ariaLabel: `${t(`kind.${node.data.kind}`)}: ${node.data.label}`,
hidden: !!branch && !branch.includes(node.id),
})),
[nodes, branch, t],
);
const displayedEdges = useMemo(() => {
const labels = new Map(graph.nodes.map((node) => [node.id, node.data.label]));
return graph.edges.map((edge) => {
const related = edge.id === selectedEdge || edge.source === selected || edge.target === selected;
return {
...edge,
selected: edge.id === selectedEdge,
markerEnd: { type: MarkerType.ArrowClosed, color: edge.style?.stroke?.toString(), width: 18, height: 18 },
hidden: !!branch && (!branch.includes(edge.source) || !branch.includes(edge.target)),
ariaLabel: `${t(`relationship.${edge.data?.relationship ?? "data"}`)}: ${labels.get(edge.source)} → ${labels.get(edge.target)}`,
label: related ? t(`relationship.${edge.data?.relationship ?? "data"}`) : undefined,
labelStyle: { fontSize: 10 },
labelBgStyle: { fill: "var(--mantine-color-body)" },
style: { ...edge.style, opacity: related || !selected ? 1 : 0.4 },
};
});
}, [graph, selected, selectedEdge, branch, t]);
return (
<>
<CanvasControls
arranging={arranging}
selection={selection}
branch={!!branch}
arrange={arrange}
focus={focus}
fit={() => {
setBranch(null);
requestAnimationFrame(() => {
void fitView({ duration: reducedMotion ? 0 : 200 });
});
}}
/>
{issue && (
<Alert
color="yellow"
p="xs"
withCloseButton
closeButtonLabel={actions("close")}
onClose={() => setIssue(null)}
aria-live="polite"
>
{t(issue as "codeOwned")}
</Alert>
)}
<ReactFlow<WidgetNode, WidgetEdge>
onInit={onInit}
nodes={displayedNodes}
edges={displayedEdges}
nodeTypes={canvasNodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
aria-label={t("canvas")}
{...interactions}
onConnect={onConnect}
onConnectEnd={endConnection}
onClickConnectEnd={endConnection}
onReconnectStart={() => {
reconnecting.current = true;
}}
onReconnect={(edge, connection) => setIssue(connect(connection, getNodes(), edge))}
onReconnectEnd={() => {
reconnecting.current = false;
}}
isValidConnection={isValidConnection}
nodesConnectable={graphEditable}
onNodeDoubleClick={(event, node) => {
if (!isPreviewInteraction(event.target)) onSelect(node.id);
}}
onNodeDragStart={() => {
generation.current += 1;
setArranging(false);
}}
onNodeDragStop={() => store.setLayout(captureLayout(getNodes(), store.getLayout()))}
onSelectionDragStart={() => {
generation.current += 1;
setArranging(false);
}}
onSelectionDragStop={() => store.setLayout(captureLayout(getNodes(), store.getLayout()))}
panOnScroll
selectionOnDrag
panOnDrag={false}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
zoomOnScroll={false}
zoomActivationKeyCode={["Control", "Meta"]}
multiSelectionKeyCode={["Control", "Meta", "Shift"]}
zoomOnDoubleClick={false}
connectOnClick
connectionRadius={24}
reconnectRadius={18}
nodesFocusable
edgesFocusable
autoPanOnNodeFocus
disableKeyboardA11y={false}
deleteKeyCode={null}
minZoom={0.02}
maxZoom={1.5}
fitView
fitViewOptions={initialFitOptions}
>
<Background gap={24} />
<CanvasViewportControls />
</ReactFlow>
<AddNextStep pending={pending} form={form} onClose={() => setPending(null)} onSelect={onSelect} />
</>
);
}
@@ -0,0 +1,184 @@
import type { Connection } from "@xyflow/react";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { removeGeneratedBindings } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { buildDefinition } from "../_custom-widget-form-utils";
import { readObject, record } from "./graph";
import type { FlowKind, WidgetNode, WidgetEdge } from "./graph";
import { freshFlowIdentifier as fresh } from "./identifiers";
import { createExtensionCommands, isExtensionKind } from "./extension-commands";
import { connectionIssue, isGeneratedConnectionCurrent, planFlowConnection } from "./connection-planner";
export { connectionIssue } from "./connection-planner";
export function isGraphEditable(
values: Pick<CustomWidgetFormValues, "sources" | "requests" | "options" | "extensions">,
) {
return [values.sources, values.requests, values.options, values.extensions || "{}"].every((value) => {
try {
const parsed: unknown = JSON.parse(value);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
});
}
export function createFlowCommands(form: CustomWidgetWorkbenchForm, store: CustomWidgetFormDocumentStore) {
const extensionCommands = createExtensionCommands(form);
const write = (field: "sources" | "requests" | "options", value: Record<string, unknown>) =>
form.setFieldValue(field, JSON.stringify(value, null, 2));
const disconnect = (edge: WidgetEdge) => {
if (!isGraphEditable(form.values)) return "invalidGraphJson";
if (edge.data?.generatedBindingIds?.length) {
if (!isGeneratedConnectionCurrent(store.getValues(), edge.id, edge.data.generatedBindingIds)) return "codeOwned";
const fragments = Object.values(record(readObject(store.getValues().extensions).fragments)).filter(
(value): value is string => typeof value === "string",
);
const template = removeGeneratedBindings(store.getValues().template, edge.data.generatedBindingIds, fragments);
if (template === null || !buildDefinition({ ...store.getValues(), template }).success) return "codeOwned";
form.setFieldValue("template", template);
return null;
}
if (edge.data?.relationship !== "invalidates") return "codeOwned";
const requests = readObject(form.values.requests);
const source = edge.source.replace(/^request:/u, "");
const target = edge.target.replace(/^request:/u, "");
const request = record(requests[source]);
if (!Array.isArray(request.invalidates)) return null;
write("requests", {
...requests,
[source]: { ...request, invalidates: request.invalidates.filter((entry) => entry !== target) },
});
return null;
};
const connect = (connection: Connection, nodes: WidgetNode[]) => {
if (!isGraphEditable(form.values)) return "invalidGraphJson";
const issue = connectionIssue(connection, nodes);
if (issue) return issue;
if (planFlowConnection(connection, nodes, form.values) !== "direct") return "bindingRequired";
const source = nodes.find((node) => node.id === connection.source);
const target = nodes.find((node) => node.id === connection.target);
if (!source || !target) return "missing";
const requests = readObject(form.values.requests);
if (source.data.kind === "source") {
write("requests", {
...requests,
[target.data.identifier]: { ...record(requests[target.data.identifier]), source: source.data.identifier },
});
} else {
const request = record(requests[source.data.identifier]);
const invalidates = Array.isArray(request.invalidates) ? request.invalidates : [];
write("requests", {
...requests,
[source.data.identifier]: { ...request, invalidates: [...new Set([...invalidates, target.data.identifier])] },
});
}
return null;
};
return {
add(kind: FlowKind, preferredSource?: string) {
if (!isGraphEditable(form.values)) return "widget";
let id = "widget";
store.transaction(() => {
if (isExtensionKind(kind)) {
id = extensionCommands.add(kind);
return;
}
if (kind === "source") {
const sources = readObject(form.values.sources);
const key = fresh(sources, "source");
write("sources", {
...sources,
[key]: { baseUrl: "http://service:8080", networkScope: "private", auth: "none" },
});
id = `source:${key}`;
} else if (kind === "query" || kind === "action") {
const sources = readObject(form.values.sources);
let source = Object.keys(sources)[0];
if (preferredSource && sources[preferredSource]) source = preferredSource;
if (!source) {
source = "default";
write("sources", { default: { baseUrl: "http://service:8080", networkScope: "private", auth: "none" } });
}
const requests = readObject(form.values.requests);
const key = fresh({ ...requests, ...record(readObject(form.values.extensions).native) }, kind);
let trigger = "load";
if (kind === "action") trigger = "manual";
write("requests", { ...requests, [key]: { source, path: "/api/data", kind, method: "GET", trigger } });
id = `request:${key}`;
} else if (kind === "options") {
const options = readObject(form.values.options);
const key = fresh(options, "option");
write("options", { ...options, [key]: { control: "text", label: key, default: "" } });
id = "options";
}
});
return id;
},
connect,
disconnect,
reconnect(edge: WidgetEdge, connection: Connection, nodes: WidgetNode[]) {
if (!isGraphEditable(form.values)) return "invalidGraphJson";
const issue = connectionIssue(connection, nodes);
if (issue) return issue;
if (planFlowConnection(connection, nodes, form.values) !== "direct") return "codeOwned";
const source = nodes.find((node) => node.id === connection.source);
if (edge.data?.relationship === "source" && (edge.target !== connection.target || source?.data.kind !== "source"))
return "codeOwned";
if (edge.data?.relationship === "invalidates" && source?.data.kind !== "action") return "codeOwned";
if (!["source", "invalidates"].includes(edge.data?.relationship ?? "")) return "codeOwned";
store.transaction(() => {
if (edge.data?.relationship === "invalidates") disconnect(edge);
connect(connection, nodes);
});
return null;
},
duplicate(node: WidgetNode) {
if (!isGraphEditable(form.values)) return node.id;
if (isExtensionKind(node.data.kind)) return extensionCommands.duplicate(node);
let field: "sources" | "requests" = "requests";
let prefix = "request";
if (node.data.kind === "source") {
field = "sources";
prefix = "source";
}
if (!["source", "query", "action"].includes(node.data.kind)) return node.id;
const entries = readObject(form.values[field]);
let identifiers = entries;
if (field === "requests") identifiers = { ...entries, ...record(readObject(form.values.extensions).native) };
const key = fresh(identifiers, `${node.data.identifier}Copy`);
const copy = structuredClone(record(entries[node.data.identifier]));
if (Array.isArray(copy.invalidates))
copy.invalidates = copy.invalidates.map((id) => (id === node.data.identifier ? key : id));
write(field, { ...entries, [key]: copy });
return `${prefix}:${key}`;
},
remove(nodes: WidgetNode[]) {
if (nodes.every((node) => node.data.kind === "group")) return;
if (!isGraphEditable(form.values)) return;
store.transaction(() => {
const sources = readObject(form.values.sources);
const requests = readObject(form.values.requests);
let options = readObject(form.values.options);
const layout = structuredClone(store.getLayout());
for (const node of nodes) {
if (node.data.kind === "source") delete sources[node.data.identifier];
if (["query", "action"].includes(node.data.kind)) delete requests[node.data.identifier];
if (node.data.kind === "options") options = {};
delete layout.nodes[node.id];
delete layout.groups[node.id];
}
form.setValues({
...form.values,
sources: JSON.stringify(sources, null, 2),
requests: JSON.stringify(requests, null, 2),
options: JSON.stringify(options, null, 2),
extensions: form.values.extensions,
});
extensionCommands.remove(nodes);
store.setLayout(layout);
});
},
};
}
@@ -0,0 +1,83 @@
import type { Connection } from "@xyflow/react";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { projectWidgetGraph, readObject, record } from "./graph";
import type { WidgetNode } from "./graph";
export interface FlowBindingIntent {
kind: "query" | "action" | "option" | "requestOption";
source: string;
target: string;
identifier: string;
native: boolean;
replaceGeneratedIds?: string[];
replaceEdgeId?: string;
snapshot: Pick<CustomWidgetFormValues, "template" | "requests" | "options" | "extensions">;
}
export function connectionIssue(connection: Pick<Connection, "source" | "target">, nodes: WidgetNode[]) {
const source = nodes.find((entry) => entry.id === connection.source);
const target = nodes.find((entry) => entry.id === connection.target);
if (!source || !target) return "missing";
if (source.data.kind === "widget" && ["query", "action", "native"].includes(target.data.kind)) return null;
if (source.data.kind === "source" && ["query", "action"].includes(target.data.kind)) return null;
if (source.data.kind === "action" && target.data.kind === "query") return null;
if (["query", "action", "native", "options"].includes(source.data.kind) && target.data.kind === "widget") return null;
if (source.data.kind === "options" && ["query", "action", "native"].includes(target.data.kind)) return null;
return "codeOwned";
}
export function planFlowConnection(
connection: Pick<Connection, "source" | "target">,
nodes: WidgetNode[],
values: CustomWidgetFormValues,
): FlowBindingIntent | "direct" | "missing" | "codeOwned" {
const issue = connectionIssue(connection, nodes);
if (issue) return issue;
const source = nodes.find((entry) => entry.id === connection.source);
const target = nodes.find((entry) => entry.id === connection.target);
if (!source || !target) return "missing";
if (source.data.kind === "widget") return planFlowConnection({ source: target.id, target: source.id }, nodes, values);
if (source.data.kind === "source" || (source.data.kind === "action" && target.data.kind === "query")) return "direct";
let kind: FlowBindingIntent["kind"] = "query";
let identifier = source.data.identifier;
let native = source.data.kind === "native";
if (source.data.kind === "options") {
kind = "option";
if (target.data.kind !== "widget") {
kind = "requestOption";
identifier = target.data.identifier;
native = target.data.kind === "native";
}
} else if (source.data.kind === "action") kind = "action";
else if (native && record(record(readObject(values.extensions).native)[identifier]).kind === "action")
kind = "action";
return {
kind,
source: source.id,
target: target.id,
identifier,
native,
snapshot: {
template: values.template,
requests: values.requests,
options: values.options,
extensions: values.extensions,
},
};
}
export function isFlowBindingCurrent(intent: FlowBindingIntent, values: CustomWidgetFormValues) {
return Object.entries(intent.snapshot).every(([key, value]) => values[key as keyof typeof intent.snapshot] === value);
}
/** Deferred graph props cannot authorize removal after a newer authored reference was added. */
export function isGeneratedConnectionCurrent(
values: CustomWidgetFormValues,
edgeId: string | undefined,
ids: readonly string[],
) {
const current = projectWidgetGraph(values, { version: 1, nodes: {}, groups: {} }).edges.find(
(edge) => edge.id === edgeId,
)?.data?.generatedBindingIds;
return !!current?.length && current.length === ids.length && current.every((id) => ids.includes(id));
}
@@ -0,0 +1,58 @@
"use client";
import { createContext, useCallback, useContext, useState } from "react";
import type { ReactNode } from "react";
import type { Connection } from "@xyflow/react";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { createFlowCommands, isGraphEditable } from "./commands";
import { isGeneratedConnectionCurrent, planFlowConnection } from "./connection-planner";
import type { FlowBindingIntent } from "./connection-planner";
import type { WidgetNode, WidgetEdge } from "./graph";
import { FlowBindingDialog } from "./binding-dialog";
type Connect = (connection: Connection, nodes: WidgetNode[], replacing?: WidgetEdge) => string | null;
const FlowConnectionContext = createContext<Connect | null>(null);
export function FlowConnectionProvider({ form, children }: { form: CustomWidgetWorkbenchForm; children: ReactNode }) {
const store = useCustomWidgetFormDocumentStore();
const [pending, setPending] = useState<FlowBindingIntent | null>(null);
const connect = useCallback<Connect>(
(connection, nodes, replacing) => {
if (!isGraphEditable(store.getValues())) return "invalidGraphJson";
if (replacing && !replacing.data?.generatedBindingIds?.length)
return createFlowCommands(form, store).reconnect(replacing, connection, nodes);
const plan = planFlowConnection(connection, nodes, store.getValues());
if (plan === "direct") {
if (replacing) return "codeOwned";
let issue: string | null = null;
store.transaction(() => {
issue = createFlowCommands(form, store).connect(connection, nodes);
});
return issue;
}
if (typeof plan === "string") return plan;
if (replacing) {
if (!replacing.data?.generatedBindingIds?.length) return "codeOwned";
if (!isGeneratedConnectionCurrent(store.getValues(), replacing.id, replacing.data.generatedBindingIds))
return "codeOwned";
plan.replaceGeneratedIds = replacing.data.generatedBindingIds;
plan.replaceEdgeId = replacing.id;
}
setPending(plan);
return null;
},
[form, store],
);
return (
<FlowConnectionContext.Provider value={connect}>
{children}
{pending && <FlowBindingDialog intent={pending} form={form} onClose={() => setPending(null)} />}
</FlowConnectionContext.Provider>
);
}
export function useFlowConnection() {
const connect = useContext(FlowConnectionContext);
if (!connect) throw new Error("Flow connections require FlowConnectionProvider");
return connect;
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { ActionIcon, Alert, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconArrowLeft, IconArrowRight, IconUnlink } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { connectionIssue, createFlowCommands, isGraphEditable } from "./commands";
import type { WidgetNode, WidgetEdge } from "./graph";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { useFlowConnection } from "./connection-provider";
interface Props {
node: WidgetNode;
nodes: WidgetNode[];
edges: WidgetEdge[];
form: CustomWidgetWorkbenchForm;
onSelect(id: string): void;
}
export function Connections(props: Props) {
return <ConnectionEditor key={props.node.id} {...props} />;
}
function ConnectionEditor({ node, nodes, edges, form, onSelect }: Props) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const connect = useFlowConnection();
const [target, setTarget] = useState<string | null>(null);
const [source, setSource] = useState<string | null>(null);
const [issue, setIssue] = useState<string | null>(null);
const editable = isGraphEditable(store.getValues());
const destinations = nodes.filter(
(other) =>
!connectionIssue({ source: node.id, target: other.id }, nodes) &&
(other.data.kind === "widget" ||
node.data.kind === "options" ||
!edges.some((edge) => edge.source === node.id && edge.target === other.id)),
);
const sources = nodes.filter(
(other) =>
!connectionIssue({ source: other.id, target: node.id }, nodes) &&
(node.data.kind === "widget" ||
other.data.kind === "options" ||
!edges.some((edge) => edge.source === other.id && edge.target === node.id)),
);
const related = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
const commands = createFlowCommands(form, store);
return (
<Stack gap={6} mb="sm">
{(!editable || issue) && (
<Alert color="yellow" p="xs" aria-live="polite">
{t((issue ?? "invalidGraphJson") as "codeOwned")}
</Alert>
)}
{related.map((edge) => {
let otherId = edge.source;
if (edge.source === node.id) otherId = edge.target;
const other = nodes.find((entry) => entry.id === otherId);
let Direction = IconArrowLeft;
if (edge.source === node.id) Direction = IconArrowRight;
let disconnectLabel = t("disconnect");
if (edge.data?.generatedBindingIds?.length) disconnectLabel = t("bindingDisconnect");
return (
<Group key={edge.id} gap={4} wrap="nowrap">
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<Direction size={14} />}
onClick={() => other && onSelect(other.id)}
>
{t(`relationship.${edge.data?.relationship ?? "data"}`)} · {other?.data.label}
</Button>
{(edge.data?.relationship === "invalidates" || !!edge.data?.generatedBindingIds?.length) && (
<Tooltip label={disconnectLabel}>
<ActionIcon
type="button"
size="sm"
variant="subtle"
aria-label={disconnectLabel}
disabled={!editable}
onClick={() => setIssue(commands.disconnect(edge))}
>
<IconUnlink size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
})}
{sources.length > 0 && (
<Group align="end" wrap="nowrap">
<Select
label={t("connectFrom")}
size="xs"
value={source}
onChange={setSource}
disabled={!editable}
searchable
data={sources.map((entry) => ({
value: entry.id,
label: `${t(`kind.${entry.data.kind}`)} · ${entry.data.label}`,
}))}
/>
<Button
size="xs"
type="button"
disabled={!editable || !sources.some((entry) => entry.id === source)}
onClick={() => {
if (!source) return;
const result = connect({ source, target: node.id, sourceHandle: "out", targetHandle: "in" }, nodes);
setIssue(result);
if (!result) setSource(null);
}}
>
{t("connect")}
</Button>
</Group>
)}
{destinations.length > 0 && (
<Group align="end" wrap="nowrap">
<Select
label={t("connectTo")}
size="xs"
value={target}
onChange={setTarget}
disabled={!editable}
searchable
data={destinations.map((entry) => ({
value: entry.id,
label: `${t(`kind.${entry.data.kind}`)} · ${entry.data.label}`,
}))}
/>
<Button
size="xs"
type="button"
disabled={!editable || !destinations.some((entry) => entry.id === target)}
onClick={() => {
if (!target) return;
const result = connect({ source: node.id, target, sourceHandle: "out", targetHandle: "in" }, nodes);
setIssue(result);
if (!result) setTarget(null);
}}
>
{t("connect")}
</Button>
</Group>
)}
</Stack>
);
}
@@ -0,0 +1,45 @@
import { createId } from "@homarr/common";
import { customWidgetSourcesSchema, getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
export type SourceBindings = Record<string, string>;
/** Only definition identities enter history. Credential values remain in the live form. */
export function reconcileDefinitionBindings(raw: string, current: SourceBindings): SourceBindings {
try {
const sources: unknown = JSON.parse(raw);
if (!sources || typeof sources !== "object" || Array.isArray(sources)) return current;
return Object.fromEntries(Object.keys(sources).map((id) => [id, current[id] ?? createId()]));
} catch {
return current;
}
}
export function restoreCredentialBindings(
secrets: CustomWidgetFormValues["secrets"],
previous: SourceBindings,
next: SourceBindings,
rawSources: string,
) {
let sources;
try {
sources = customWidgetSourcesSchema.safeParse(JSON.parse(rawSources));
} catch {
return secrets;
}
if (!sources.success) return secrets;
const names = new Map(Object.entries(next).map(([name, identity]) => [identity, name]));
return secrets.flatMap((secret) => {
const sourceId = names.get(previous[secret.sourceId] ?? "");
if (!sourceId) return [];
const source = sources.data[sourceId];
if (!source) return [];
let auth: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0];
if (typeof source.auth === "object") auth = source.auth.type;
else auth = source.auth;
if (!new Set<string>(getCustomWidgetRequiredSecretKinds(auth)).has(secret.kind)) return [];
let savedSourceId = secret.savedSourceId;
if (secret.hasValue && sourceId !== secret.sourceId) savedSourceId ??= secret.sourceId;
return [{ ...secret, sourceId, savedSourceId }];
});
}
@@ -0,0 +1,53 @@
"use client";
import { Button, Code, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
function fields(value: unknown) {
const result: Array<{ path: string; value: unknown }> = [];
let visited = 0;
const visit = (entry: unknown, path: string, depth: number) => {
if (++visited > 500 || result.length >= 100) return;
if (depth >= 5 && entry !== null && typeof entry === "object") return;
if (entry === null || typeof entry !== "object") {
result.push({ path, value: entry });
return;
}
for (const [key, child] of Object.entries(entry).slice(0, 30)) {
if (visited >= 500 || result.length >= 100) break;
visit(child, `${path}[${JSON.stringify(key)}]`, depth + 1);
}
};
visit(value, "data", 0);
return result;
}
const insert = (expression: string) =>
window.dispatchEvent(
new CustomEvent("homarr:widget-code-insert", {
detail: { editorId: "jsx-editor", text: `{${expression}}` },
}),
);
export function DataBindings({ data }: { data: Record<string, unknown> }) {
const t = useI18n("customWidget.flow");
return (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t("bindingInspectorHint")}
</Text>
<ScrollArea mah={260}>
<Stack gap={6}>
{fields(data).map((field) => (
<Group key={field.path} justify="space-between" wrap="nowrap">
<Code style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{field.path}</Code>
<Button size="compact-xs" variant="subtle" onClick={() => insert(field.path)}>
{t("insertBinding")}
</Button>
</Group>
))}
</Stack>
</ScrollArea>
</Stack>
);
}
@@ -0,0 +1,103 @@
"use client";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { buildDefinition } from "../_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { createFlowCommands } from "./commands";
import { projectWidgetGraph } from "./graph";
import type { WidgetNode, WidgetEdge } from "./graph";
import { ungroupNodes, captureLayout } from "./layout";
export function DeleteSelection({
opened,
onClose,
selection,
nodes,
edges,
form,
onSelect,
}: {
opened: boolean;
onClose(): void;
selection: string[];
nodes: WidgetNode[];
edges: WidgetEdge[];
form: CustomWidgetWorkbenchForm;
onSelect(id: string): void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const targets = nodes.filter((node) => selection.includes(node.id) && node.data.kind !== "widget");
const validDraft = !opened || buildDefinition(store.getValues()).success;
const targetIds = new Set(targets.map((node) => node.id));
const dependencies = edges.filter((edge) => {
if (targetIds.has(edge.source) && targetIds.has(edge.target)) return false;
if (edge.data?.relationship === "invalidates" || edge.data?.relationship === "invoke")
return targetIds.has(edge.target);
return targetIds.has(edge.source);
});
const dependants = [...new Set(dependencies.map((edge) => (targetIds.has(edge.source) ? edge.target : edge.source)))];
const remove = () => {
if (!buildDefinition(store.getValues()).success) return;
// Recheck current references when confirming; a deferred graph is never deletion authority.
const current = projectWidgetGraph(store.getValues(), store.getLayout());
const latestIds = new Set(current.nodes.filter((node) => targetIds.has(node.id)).map((node) => node.id));
if (
current.edges.some((edge) => {
if (latestIds.has(edge.source) && latestIds.has(edge.target)) return false;
if (["invalidates", "invoke"].includes(edge.data?.relationship ?? "")) return latestIds.has(edge.target);
return latestIds.has(edge.source);
})
)
return;
store.transaction(() => {
const marked = current.nodes.map((node) => ({
...node,
selected: targetIds.has(node.id) && node.data.kind === "group",
}));
store.setLayout(captureLayout(ungroupNodes(marked), store.getLayout()));
createFlowCommands(form, store).remove(targets);
});
onClose();
onSelect("widget");
};
return (
<Modal opened={opened} onClose={onClose} title={t("deleteSelection")}>
<Stack gap="sm">
<Text size="sm">
{t("deleteSelectionDescription", { names: targets.map((node) => node.data.label).join(", ") })}
</Text>
{!validDraft && <Alert color="yellow">{t("invalidDeletion")}</Alert>}
{dependants.length > 0 && (
<Alert color="yellow" title={t("resolveReferences")}>
<Text size="sm">{t("resolveReferencesDescription")}</Text>
<Stack mt="xs" gap={4}>
{dependants.map((id) => (
<Button
key={id}
size="compact-xs"
variant="subtle"
onClick={() => {
onClose();
onSelect(id);
}}
>
{nodes.find((node) => node.id === id)?.data.label ?? id}
</Button>
))}
</Stack>
</Alert>
)}
<Group justify="end">
<Button variant="default" onClick={onClose}>
{t("cancel")}
</Button>
<Button color="red" disabled={!validDraft || targets.length === 0 || dependants.length > 0} onClick={remove}>
{t("delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,218 @@
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { EMPTY_EDITOR_LAYOUT } from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
import { reconcileDefinitionBindings, restoreCredentialBindings } from "./credential-bindings";
import { getNodeRenames, renameDefinitionBinding, renameEditorNode } from "./node-renames";
import { createEditorHistory, withoutCredentials } from "./history";
import { areCustomWidgetValuesEqual } from "../_custom-widget-value-equality";
export interface CustomWidgetFormDocumentStore {
getValues(): CustomWidgetFormValues;
setValues(values: CustomWidgetFormValues): void;
getDirty(): boolean;
subscribe(listener: () => void): () => void;
markSaved(
values: CustomWidgetFormValues,
savedLayout?: CustomWidgetEditorLayout,
savedSourceBindings?: Record<string, string>,
savedRequestBindings?: Record<string, string>,
): void;
initializeLayout(layout: CustomWidgetEditorLayout): void;
getLayout(): CustomWidgetEditorLayout;
getSourceBindings(): Record<string, string>;
getRequestBindings(): Record<string, string>;
setLayout(layout: CustomWidgetEditorLayout): void;
subscribeEditor(listener: () => void): () => void;
getRevision(): number;
renameSourceBinding(previousId: string, nextId: string): void;
renameRequestBinding(previousId: string, nextId: string): void;
onNodeRenames(listener: (renames: Readonly<Record<string, string>>) => void): () => void;
canUndo(): boolean;
canRedo(): boolean;
undo(): void;
redo(): void;
reset(): void;
transaction(action: () => void): void;
onRestore(listener: (values: CustomWidgetFormValues) => void): () => void;
}
const documentValueKeys = customWidgetFormSchema.keyof().options;
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
if (Object.keys(left).length !== documentValueKeys.length) return false;
if (Object.keys(right).length !== documentValueKeys.length) return false;
return documentValueKeys.every(
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
);
}
export function createCustomWidgetFormDocumentStore(
initialValues: CustomWidgetFormValues,
initialLayout: CustomWidgetEditorLayout = EMPTY_EDITOR_LAYOUT,
): CustomWidgetFormDocumentStore {
let values = initialValues;
let persistedValues = initialValues;
let layout = initialLayout;
let persistedLayout = layout;
let sourceBindings = reconcileDefinitionBindings(initialValues.sources, {});
let persistedSourceBindings = sourceBindings;
let requestBindings = reconcileDefinitionBindings(initialValues.requests, {});
let persistedRequestBindings = requestBindings;
let notifiedSources = sourceBindings;
let notifiedRequests = requestBindings;
let revision = 0;
let transactionDepth = 0;
const history = createEditorHistory();
const listeners = new Set<() => void>();
const editorListeners = new Set<() => void>();
const restoreListeners = new Set<(values: CustomWidgetFormValues) => void>();
const renameListeners = new Set<(renames: Readonly<Record<string, string>>) => void>();
const snapshot = () => ({ content: withoutCredentials(values), layout, sourceBindings, requestBindings });
const notifyEditor = () => {
revision += 1;
const renames = {
...getNodeRenames(notifiedSources, sourceBindings, "source"),
...getNodeRenames(notifiedRequests, requestBindings, "request"),
};
notifiedSources = sourceBindings;
notifiedRequests = requestBindings;
if (Object.keys(renames).length) renameListeners.forEach((listener) => listener(renames));
editorListeners.forEach((listener) => listener());
};
const notify = () => {
listeners.forEach((listener) => listener());
notifyEditor();
};
const restore = (next: ReturnType<typeof snapshot> | undefined) => {
if (!next) return;
const changed = !areCustomWidgetValuesEqual(withoutCredentials(values), next.content);
layout = next.layout;
// Secrets intentionally stay in the live form, never in history.
if (changed) {
values = {
...next.content,
secrets: restoreCredentialBindings(values.secrets, sourceBindings, next.sourceBindings, next.content.sources),
};
sourceBindings = next.sourceBindings;
requestBindings = next.requestBindings;
restoreListeners.forEach((listener) => listener(values));
notify();
} else {
sourceBindings = next.sourceBindings;
requestBindings = next.requestBindings;
notifyEditor();
}
};
return {
getValues: () => values,
setValues: (nextValues) => {
if (areDocumentValuesEqual(values, nextValues)) return;
const changed = documentValueKeys.filter((key) => !areCustomWidgetValuesEqual(values[key], nextValues[key]));
if (transactionDepth === 0 && changed.some((key) => key !== "secrets")) {
history.record(snapshot(), changed.join(":"));
}
if (changed.includes("sources")) sourceBindings = reconcileDefinitionBindings(nextValues.sources, sourceBindings);
if (changed.includes("requests"))
requestBindings = reconcileDefinitionBindings(nextValues.requests, requestBindings);
values = nextValues;
if (transactionDepth === 0) notify();
},
getDirty: () =>
!areDocumentValuesEqual(values, persistedValues) || !areCustomWidgetValuesEqual(layout, persistedLayout),
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
markSaved: (
savedValues,
savedLayout = layout,
savedSourceBindings = sourceBindings,
savedRequestBindings = requestBindings,
) => {
persistedValues = savedValues;
persistedLayout = savedLayout;
persistedSourceBindings = savedSourceBindings;
persistedRequestBindings = savedRequestBindings;
notifyEditor();
},
initializeLayout: (next) => {
layout = next;
persistedLayout = next;
notifyEditor();
},
getLayout: () => layout,
getSourceBindings: () => sourceBindings,
getRequestBindings: () => requestBindings,
setLayout: (next) => {
if (areCustomWidgetValuesEqual(layout, next)) return;
if (transactionDepth === 0) history.record(snapshot(), "");
layout = next;
if (transactionDepth === 0) notifyEditor();
},
subscribeEditor: (listener) => {
editorListeners.add(listener);
return () => {
editorListeners.delete(listener);
};
},
getRevision: () => revision,
renameSourceBinding: (previousId, nextId) => {
sourceBindings = renameDefinitionBinding(sourceBindings, previousId, nextId);
layout = renameEditorNode(layout, `source:${previousId}`, `source:${nextId}`);
},
renameRequestBinding: (previousId, nextId) => {
requestBindings = renameDefinitionBinding(requestBindings, previousId, nextId);
layout = renameEditorNode(layout, `request:${previousId}`, `request:${nextId}`);
},
onNodeRenames: (listener) => {
renameListeners.add(listener);
return () => {
renameListeners.delete(listener);
};
},
canUndo: () => history.canUndo,
canRedo: () => history.canRedo,
undo: () => restore(history.undo(snapshot())),
redo: () => restore(history.redo(snapshot())),
reset: () => {
history.record(snapshot(), "");
restore({
content: withoutCredentials(persistedValues),
layout: persistedLayout,
sourceBindings: persistedSourceBindings,
requestBindings: persistedRequestBindings,
});
},
transaction: (action) => {
const previous = snapshot();
const previousValues = values;
transactionDepth += 1;
try {
action();
} catch (error) {
layout = previous.layout;
values = previousValues;
sourceBindings = previous.sourceBindings;
requestBindings = previous.requestBindings;
restoreListeners.forEach((listener) => listener(values));
throw error;
} finally {
transactionDepth -= 1;
if (transactionDepth === 0 && !areCustomWidgetValuesEqual(previous, snapshot())) {
history.record(previous, "");
if (!areCustomWidgetValuesEqual(previous.content, withoutCredentials(values))) notify();
else notifyEditor();
} else if (transactionDepth === 0 && !areDocumentValuesEqual(previousValues, values)) notify();
}
},
onRestore: (listener) => {
restoreListeners.add(listener);
return () => {
restoreListeners.delete(listener);
};
},
};
}
@@ -0,0 +1,214 @@
import type { RouterOutputs } from "@homarr/api";
import type { CustomWidgetQueryExecution } from "@homarr/custom-widgets/runtime";
import type { PreviewState } from "./preview-state";
export type WorkbenchJournalEntry = RouterOutputs["customWidget"]["previewJournal"][number];
export interface NodeExecutionStatus {
state: "idle" | "loading" | "success" | "error";
error?: string;
code?: number;
durationMs?: number;
timestamp?: number;
simulated: boolean;
stale: boolean;
}
export const EMPTY_JOURNAL: WorkbenchJournalEntry[] = [];
export const IDLE_EXECUTION: NodeExecutionStatus = { state: "idle", simulated: false, stale: false };
export interface WorkbenchQuerySnapshot {
data: unknown;
hasData: boolean;
status: unknown;
}
export const EMPTY_EXECUTION_DATA = { data: {}, status: {} };
export function executionRequestIdentifier(requestId: string) {
return requestId.replace(/^native:/u, "");
}
function compactStatus(
value: unknown,
entry: WorkbenchJournalEntry | undefined,
preview: PreviewState,
observed = false,
): NodeExecutionStatus {
let raw: Record<string, unknown> = {};
if (value && typeof value === "object" && !Array.isArray(value)) raw = value as Record<string, unknown>;
let state: NodeExecutionStatus["state"] = "idle";
if (value !== undefined || entry) state = "success";
if (
raw.ok === false ||
raw.error ||
(!observed && entry && !entry.simulated && (entry.status === null || entry.status >= 400))
)
state = "error";
if (raw.loading || preview.outcome === "loading") state = "loading";
let code = entry?.status ?? (typeof raw.status === "number" ? raw.status : undefined);
if (observed) code = typeof raw.status === "number" ? raw.status : undefined;
return {
state,
error: typeof raw.error === "string" ? raw.error.slice(0, 512) : undefined,
code,
durationMs: entry?.durationMs ?? (typeof raw.durationMs === "number" ? raw.durationMs : undefined),
timestamp: entry?.timestamp,
simulated: entry?.simulated ?? false,
stale: preview.stale ?? false,
};
}
export function createWorkbenchExecutionStore(initialPreview?: PreviewState) {
let preview: PreviewState = initialPreview ?? { data: {}, status: {}, session: null, outcome: "idle" };
let statuses = new Map<string, NodeExecutionStatus>();
let journal = EMPTY_JOURNAL;
const observedQueries = new Map<string, WorkbenchQuerySnapshot>();
let queries = new Map<string, WorkbenchQuerySnapshot>();
let dataSnapshot: { data: Record<string, unknown>; status: Record<string, unknown> } = EMPTY_EXECUTION_DATA;
const subscribers = new Map<string, Set<() => void>>();
const journalSubscribers = new Set<() => void>();
const dataSubscribers = new Set<() => void>();
const querySubscribers = new Map<string, Set<() => void>>();
const updateQueries = () => {
const ids = new Set([
...queries.keys(),
...Object.keys(preview.data),
...Object.keys(preview.status),
...observedQueries.keys(),
]);
const next = new Map<string, WorkbenchQuerySnapshot>();
const changed: string[] = [];
for (const id of ids) {
if (!observedQueries.has(id) && !Object.hasOwn(preview.data, id) && !Object.hasOwn(preview.status, id)) {
changed.push(id);
continue;
}
const value = observedQueries.get(id) ?? {
data: preview.data[id],
status: preview.status[id],
hasData: Object.hasOwn(preview.data, id),
};
const previous = queries.get(id);
if (
previous &&
previous.data === value.data &&
previous.status === value.status &&
previous.hasData === value.hasData
)
next.set(id, previous);
else {
next.set(id, value);
changed.push(id);
}
}
queries = next;
if (!changed.length) return;
dataSnapshot = { data: {}, status: {} };
for (const [id, value] of queries) {
if (value.hasData) dataSnapshot.data[id] = value.data;
if (value.status !== undefined) dataSnapshot.status[id] = value.status;
}
for (const id of changed) querySubscribers.get(id)?.forEach((listener) => listener());
dataSubscribers.forEach((listener) => listener());
};
const updateStatuses = () => {
const latest = new Map<string, WorkbenchJournalEntry>();
for (const entry of journal) {
const id = executionRequestIdentifier(entry.requestId);
if (!latest.has(id)) latest.set(id, entry);
}
const ids = new Set([...statuses.keys(), ...queries.keys(), ...latest.keys()]);
const next = new Map<string, NodeExecutionStatus>();
const changed: string[] = [];
for (const id of ids) {
const status = compactStatus(queries.get(id)?.status, latest.get(id), preview, observedQueries.has(id));
const previous = statuses.get(id);
if (previous && JSON.stringify(previous) === JSON.stringify(status)) next.set(id, previous);
else {
next.set(id, status);
changed.push(id);
}
}
statuses = next;
for (const id of changed) subscribers.get(id)?.forEach((listener) => listener());
};
updateQueries();
updateStatuses();
return {
getStatus: (id: string) => statuses.get(id) ?? IDLE_EXECUTION,
getJournal: () => journal,
getQuery: (id: string) => queries.get(id),
getData: () => dataSnapshot,
observeQuery(event: CustomWidgetQueryExecution) {
if (!event.previewSessionId || event.previewSessionId !== preview.session?.id) return;
const previous = queries.get(event.requestId);
let value: WorkbenchQuerySnapshot;
if (event.type === "result") {
value = {
data: event.result.data,
hasData: true,
status: {
loading: false,
ok: event.result.ok,
status: event.result.status,
statusText: event.result.statusText,
error: event.result.error,
},
};
} else {
value = {
data: previous?.data,
hasData: previous?.hasData ?? false,
status: { loading: false, ok: false, error: event.error },
};
}
observedQueries.set(event.requestId, value);
updateQueries();
updateStatuses();
},
subscribeData(listener: () => void) {
dataSubscribers.add(listener);
return () => {
dataSubscribers.delete(listener);
};
},
subscribeQuery(id: string, listener: () => void) {
const listeners = querySubscribers.get(id) ?? new Set<() => void>();
querySubscribers.set(id, listeners);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (!listeners.size) querySubscribers.delete(id);
};
},
subscribe: (id: string, listener: () => void) => {
const listeners = subscribers.get(id) ?? new Set<() => void>();
subscribers.set(id, listeners);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (!listeners.size) subscribers.delete(id);
};
},
subscribeJournal: (listener: () => void) => {
journalSubscribers.add(listener);
return () => {
journalSubscribers.delete(listener);
};
},
update(nextPreview: PreviewState, entries: WorkbenchJournalEntry[]) {
if (
(nextPreview.session && nextPreview.session.id !== preview.session?.id) ||
nextPreview.data !== preview.data ||
nextPreview.status !== preview.status
)
observedQueries.clear();
preview = nextPreview;
const journalChanged = journal !== entries;
journal = entries;
updateQueries();
updateStatuses();
if (journalChanged) {
journalSubscribers.forEach((listener) => listener());
}
},
};
}
export type WorkbenchExecutionStore = ReturnType<typeof createWorkbenchExecutionStore>;
@@ -0,0 +1,73 @@
"use client";
import { createContext, useCallback, useContext, useLayoutEffect, useState, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import { Badge, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { CustomWidgetExecutionObserverProvider } from "@homarr/custom-widgets/runtime";
import { useI18n } from "@homarr/translation/client";
import type { PreviewState } from "./preview-state";
import { createWorkbenchExecutionStore, EMPTY_JOURNAL, IDLE_EXECUTION } from "./execution-store";
import type { WorkbenchExecutionStore } from "./execution-store";
const ExecutionContext = createContext<WorkbenchExecutionStore | null>(null);
const noopSubscribe = () => () => undefined;
export function WorkbenchExecutionProvider({ preview, children }: { preview: PreviewState; children: ReactNode }) {
const [store] = useState(() => createWorkbenchExecutionStore(preview));
const journal = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: preview.session?.id ?? "" },
{ enabled: !!preview.session, refetchInterval: preview.session ? 2_000 : false },
);
useLayoutEffect(() => store.update(preview, journal.data ?? EMPTY_JOURNAL), [store, preview, journal.data]);
return (
<CustomWidgetExecutionObserverProvider observer={store.observeQuery}>
<ExecutionContext.Provider value={store}>{children}</ExecutionContext.Provider>
</CustomWidgetExecutionObserverProvider>
);
}
export function useWorkbenchQuery(id: string) {
const store = useContext(ExecutionContext);
const subscribe = useCallback(
(listener: () => void) => store?.subscribeQuery(id, listener) ?? (() => undefined),
[store, id],
);
const snapshot = useCallback(() => store?.getQuery(id), [store, id]);
return useSyncExternalStore(subscribe, snapshot, snapshot);
}
export function useWorkbenchQueryData() {
const store = useContext(ExecutionContext);
const snapshot = useCallback(() => store?.getData(), [store]);
return useSyncExternalStore(store?.subscribeData ?? noopSubscribe, snapshot, snapshot);
}
export function useWorkbenchRequestStatus(id: string) {
const store = useContext(ExecutionContext);
const subscribe = useCallback(
(listener: () => void) => store?.subscribe(id, listener) ?? (() => undefined),
[store, id],
);
const snapshot = useCallback(() => store?.getStatus(id) ?? IDLE_EXECUTION, [store, id]);
return useSyncExternalStore(subscribe, snapshot, snapshot);
}
export function useWorkbenchJournal() {
const store = useContext(ExecutionContext);
const snapshot = useCallback(() => store?.getJournal() ?? EMPTY_JOURNAL, [store]);
return useSyncExternalStore(store?.subscribeJournal ?? noopSubscribe, snapshot, snapshot);
}
const statusColors = { idle: "gray", loading: "yellow", success: "teal", error: "red" };
export function FlowExecutionStatus({ requestId }: { requestId: string }) {
const t = useI18n("customWidget.flow");
const status = useWorkbenchRequestStatus(requestId);
let label = t(`execution.${status.state}`);
if (status.simulated) label = t("execution.simulated");
if (status.stale) label = t("execution.stale");
let details = label;
if (status.code !== undefined) details += ` · ${status.code}`;
if (status.durationMs !== undefined) details += ` · ${Math.round(status.durationMs)} ms`;
if (status.error) details += ` · ${status.error}`;
return (
<Tooltip label={details} multiline maw={300}>
<Badge component="output" size="xs" variant="dot" color={statusColors[status.state]} aria-label={details}>
{label}
</Badge>
</Tooltip>
);
}
@@ -0,0 +1,67 @@
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { readObject } from "./graph";
import type { WidgetNode } from "./graph";
import { freshFlowIdentifier } from "./identifiers";
const fields = { fragment: "fragments", preference: "preferences", content: "content", native: "native" } as const;
export function isExtensionKind(kind: string): kind is keyof typeof fields {
return Object.hasOwn(fields, kind);
}
function collection(extensions: Record<string, unknown>, field: string): Record<string, unknown> | null {
const value = extensions[field];
if (value === undefined) return {};
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
return null;
}
export function createExtensionCommands(form: CustomWidgetWorkbenchForm) {
const write = (extensions: Record<string, unknown>) =>
form.setFieldValue("extensions", JSON.stringify(extensions, null, 2));
return {
add(kind: keyof typeof fields) {
if (kind === "native") return "native:new";
const extensions = readObject(form.values.extensions);
const field = fields[kind];
const entries = collection(extensions, field);
if (!entries) return `${kind}:new`;
let limit = 32;
if (kind === "preference") limit = 64;
if (Object.keys(entries).length >= limit) return `${kind}:new`;
const key = freshFlowIdentifier(entries, kind);
let value: unknown = { type: "string", defaultValue: "" };
if (kind === "fragment") value = "<Stack />";
if (kind === "content") value = { type: "string", defaultValue: "", permission: "modify" };
write({ ...extensions, [field]: { ...entries, [key]: value } });
return `${kind}:${key}`;
},
duplicate(node: WidgetNode) {
if (!isExtensionKind(node.data.kind)) return node.id;
const extensions = readObject(form.values.extensions);
const field = fields[node.data.kind];
const entries = collection(extensions, field);
if (!entries || !Object.hasOwn(entries, node.data.identifier)) return node.id;
let limit = 32;
if (node.data.kind === "preference" || node.data.kind === "native") limit = 64;
if (Object.keys(entries).length >= limit) return node.id;
let identifiers = entries;
if (node.data.kind === "native") identifiers = { ...entries, ...readObject(form.values.requests) };
const key = freshFlowIdentifier(identifiers, `${node.data.identifier}Copy`);
write({ ...extensions, [field]: { ...entries, [key]: structuredClone(entries[node.data.identifier]) } });
return `${node.data.kind}:${key}`;
},
remove(nodes: WidgetNode[]) {
const selected = nodes.filter((node) => isExtensionKind(node.data.kind));
if (!selected.length) return;
const extensions = readObject(form.values.extensions);
for (const node of selected) {
if (!isExtensionKind(node.data.kind)) continue;
const field = fields[node.data.kind];
const existing = collection(extensions, field);
if (!existing) return;
const entries = { ...existing };
delete entries[node.data.identifier];
extensions[field] = entries;
}
write(extensions);
},
};
}
@@ -0,0 +1,101 @@
"use client";
import { ActionIcon, Button, Group, NumberInput, Stack, Switch, Text, Textarea } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
export function ExtensionDefaultEditor({
type,
value,
onChange,
}: {
type: string;
value: unknown;
onChange(value: unknown): void;
}) {
const t = useI18n("customWidget.flow.extensionEditor");
if (type === "boolean")
return (
<Switch
label={t("defaultValue")}
checked={value === true}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
if (type === "number") return <NumberInput label={t("defaultValue")} value={inputValue(value)} onChange={onChange} />;
if (type === "string")
return (
<Textarea
label={t("defaultValue")}
autosize
minRows={2}
maxRows={6}
maxLength={8192}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.currentTarget.value)}
/>
);
if (type !== "string[]" && type !== "number[]") return null;
const values = Array.isArray(value) ? value : [];
const update = (index: number, next: unknown) =>
onChange(values.map((entry, position) => (position === index ? next : entry)));
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("defaultValue")}
</Text>
<Stack gap="xs" mah={240} style={{ overflow: "auto" }}>
{values.map((entry, index) => (
<Group key={index} gap="xs" wrap="nowrap">
{type === "number[]" ? (
<NumberInput
aria-label={t("listValue", { index: index + 1 })}
value={inputValue(entry)}
onChange={(next) => update(index, next)}
style={{ flex: 1 }}
/>
) : (
<Textarea
aria-label={t("listValue", { index: index + 1 })}
autosize
minRows={1}
maxRows={4}
value={typeof entry === "string" ? entry : ""}
maxLength={512}
onChange={(event) => update(index, event.currentTarget.value)}
style={{ flex: 1 }}
/>
)}
<ActionIcon
variant="subtle"
color="red"
aria-label={t("removeValue", { index: index + 1 })}
onClick={() => onChange(values.filter((_, position) => position !== index))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
</Stack>
<Button
variant="light"
size="compact-sm"
disabled={values.length >= 256}
onClick={() => onChange([...values, defaultForType(type.slice(0, -2))])}
>
{t("addValue")}
</Button>
</Stack>
);
}
function inputValue(value: unknown) {
if (typeof value === "number" || typeof value === "string") return value;
return "";
}
export function defaultForType(type: string): string | number | boolean | unknown[] {
if (type === "number") return 0;
if (type === "boolean") return false;
if (type.endsWith("[]")) return [];
return "";
}
@@ -0,0 +1,257 @@
"use client";
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Alert, Button, Code, CopyButton, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { customWidgetContentSchema, customWidgetPreferenceSchema } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "../_code-editor";
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { isRecord } from "../_custom-widget-form-utils";
import { defaultForType, ExtensionDefaultEditor } from "./extension-default-editor";
import {
extensionCollection,
extensionEntryDependants,
readExtensionCollection,
validExtensionEntryName,
} from "./extension-entry-policy";
import type { ExtensionEntryKind } from "./extension-entry-policy";
const valueTypes = ["string", "number", "boolean", "string[]", "number[]"] as const;
const typeLabels = {
string: "text",
number: "number",
boolean: "toggle",
"string[]": "textList",
"number[]": "numberList",
} as const;
type EntryValues = Record<string, unknown>;
export function ExtensionEntryEditor({
form,
kind,
selected,
onSelect,
}: {
form: CustomWidgetWorkbenchForm;
kind: ExtensionEntryKind;
selected?: string;
onSelect?(id: string): void;
}) {
const t = useI18n("customWidget.flow.extensionEditor");
const actions = useI18n("common.action");
const flow = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const raw = useCustomWidgetFormDocumentField("extensions");
const template = useCustomWidgetFormDocumentField("template");
const [active, setActive] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const collection = readExtensionCollection(raw, kind);
const entries = collection?.entries ?? {};
const ids = Object.keys(entries);
const id = ids.includes(active) ? active : (ids[0] ?? "");
const entry = entries[id];
const deferredTemplate = useDeferredValue(template);
const fragments = useDeferredValue(JSON.stringify(collection?.extensions.fragments));
const dependants = useMemo(() => {
if (!id) return { invalid: false, nodes: [] as string[] };
return extensionEntryDependants(deferredTemplate, fragments ? JSON.parse(fragments) : undefined, kind, id);
}, [deferredTemplate, fragments, kind, id]);
useEffect(() => {
if (selected?.startsWith(`${kind}:`)) setActive(selected.slice(kind.length + 1));
setError("");
}, [selected, kind]);
const select = (next: string) => {
setActive(next);
onSelect?.(`${kind}:${next}`);
};
const write = (change: (current: EntryValues) => EntryValues) => {
const current = readExtensionCollection(store.getValues().extensions, kind);
if (!current) return;
form.setFieldValue(
"extensions",
JSON.stringify(
{
...current.extensions,
[extensionCollection[kind]]: change(current.entries),
},
null,
2,
),
);
};
const update = (value: unknown) => write((current) => ({ ...current, [id]: value }));
const create = () => {
const current = readExtensionCollection(store.getValues().extensions, kind);
if (!current || !validExtensionEntryName(name, current.entries)) return;
const maximum = kind === "preference" ? 64 : 32;
if (Object.keys(current.entries).length >= maximum) return;
let value: unknown = { type: "string", defaultValue: "" };
if (kind === "fragment") value = `<Text>{props.label ?? ${JSON.stringify(t("newViewText"))}}</Text>`;
if (kind === "content") value = { type: "string", defaultValue: "", permission: "modify" };
store.transaction(() => write((values) => ({ ...values, [name]: value })));
select(name);
setName("");
};
const remove = () => {
const current = readExtensionCollection(store.getValues().extensions, kind);
if (!current) return;
const dependencies = extensionEntryDependants(store.getValues().template, current.extensions.fragments, kind, id);
if (dependencies.invalid || dependencies.nodes.length) {
setError(t("deleteBlocked"));
return;
}
store.transaction(() => {
write((values) => Object.fromEntries(Object.entries(values).filter(([key]) => key !== id)));
const layout = store.getLayout();
const nodes = Object.fromEntries(Object.entries(layout.nodes).filter(([key]) => key !== `${kind}:${id}`));
store.setLayout({ ...layout, nodes });
});
setActive("");
onSelect?.("widget");
};
const maximum = kind === "preference" ? 64 : 32;
const createDisabled = !collection || ids.length >= maximum || !validExtensionEntryName(name, entries);
let snippet = `{${kind === "preference" ? "preferences" : "content"}[${JSON.stringify(id)}]}`;
if (kind === "fragment") snippet = `<View name=${JSON.stringify(id)} />`;
return (
<Stack gap="md">
<Text size="xs" c="dimmed">
{t(`${kind}Help`)}
</Text>
{!collection && <Alert color="red">{t("invalidCollection")}</Alert>}
{ids.length > 0 && (
<Select
label={t(`select${kind}`)}
searchable
value={id}
data={ids}
onChange={(next) => {
if (next) select(next);
}}
/>
)}
{id && collection && (
<Stack gap="sm">
{kind === "fragment" && typeof entry === "string" && (
<CodeEditor id={`fragment-${id}`} language="jsx" label={t("viewCode")} value={entry} onChange={update} />
)}
{kind !== "fragment" && isRecord(entry) && <ValueDeclaration kind={kind} entry={entry} onChange={update} />}
{((kind === "fragment" && typeof entry !== "string") || (kind !== "fragment" && !isRecord(entry))) && (
<Alert color="red">{t("invalidEntry")}</Alert>
)}
<Group justify="space-between">
<Text size="xs" fw={600}>
{t("useInJsx")}
</Text>
<CopyButton value={snippet}>
{({ copied, copy }) => (
<Button size="compact-xs" variant="subtle" onClick={copy}>
{copied ? t("copied") : actions("copy")}
</Button>
)}
</CopyButton>
</Group>
<Code block style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{snippet}
</Code>
{(dependants.invalid || dependants.nodes.length > 0) && (
<Alert color="yellow" title={t("deleteBlocked")}>
<Text size="xs">{dependants.invalid ? t("resolveJsxFirst") : t("referencedBy")}</Text>
{dependants.nodes.map((node) => (
<Button key={node} variant="subtle" size="compact-xs" onClick={() => onSelect?.(node)}>
{node === "widget" ? t("widgetJsx") : node.slice(9)}
</Button>
))}
</Alert>
)}
<Button
color="red"
variant="subtle"
disabled={dependants.invalid || dependants.nodes.length > 0}
onClick={remove}
>
{t(`delete${kind}`)}
</Button>
</Stack>
)}
{error && <Alert color="red">{error}</Alert>}
<Stack gap="xs">
<TextInput
label={t(`new${kind}`)}
data-workbench-local-input
value={name}
maxLength={64}
disabled={!collection || ids.length >= maximum}
placeholder={kind}
onChange={(event) => setName(event.currentTarget.value)}
error={name && !validExtensionEntryName(name, entries) ? t("invalidName") : undefined}
/>
<Button onClick={create} disabled={createDisabled}>
{t(`create${kind}`)}
</Button>
{ids.length >= maximum && (
<Text size="xs" c="dimmed">
{t("entryLimit", { maximum })}
</Text>
)}
{!collection && (
<Text size="xs" c="dimmed">
{flow("extensionsJson")}
</Text>
)}
</Stack>
</Stack>
);
}
function ValueDeclaration({
kind,
entry,
onChange,
}: {
kind: "preference" | "content";
entry: Record<string, unknown>;
onChange(entry: Record<string, unknown>): void;
}) {
const t = useI18n("customWidget.flow.extensionEditor");
const parsed = (kind === "content" ? customWidgetContentSchema : customWidgetPreferenceSchema).safeParse(entry);
const type = String(entry.type ?? "");
return (
<Stack gap="sm">
<Select
label={t("valueType")}
description={t("typeChangeHelp")}
value={type}
data={valueTypes.map((value) => ({ value, label: t(typeLabels[value]) }))}
onChange={(next) => {
if (next) onChange({ ...entry, type: next, defaultValue: defaultForType(next) });
}}
/>
<ExtensionDefaultEditor
type={type}
value={entry.defaultValue}
onChange={(defaultValue) => onChange({ ...entry, defaultValue })}
/>
{kind === "content" && (
<Select
label={t("writePermission")}
value={String(entry.permission ?? "")}
data={[
{ value: "modify", label: t("permissionModify") },
{ value: "full", label: t("permissionFull") },
]}
onChange={(permission) => {
if (permission) onChange({ ...entry, permission });
}}
/>
)}
{!parsed.success && (
<Alert color="yellow">
<Text size="xs">{parsed.error.issues.map((issue) => issue.message).join(" · ")}</Text>
</Alert>
)}
</Stack>
);
}
@@ -0,0 +1,45 @@
import { customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
import { validateCustomJsxTemplate } from "@homarr/custom-widgets/jsx/analyzer";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import { isRecord, parseJson } from "../_custom-widget-form-utils";
export type ExtensionEntryKind = "fragment" | "preference" | "content";
export const extensionCollection = { fragment: "fragments", preference: "preferences", content: "content" } as const;
export function readExtensionCollection(raw: string, kind: ExtensionEntryKind) {
const extensions = parseJson(raw || "{}");
if (!isRecord(extensions)) return null;
const value = extensions[extensionCollection[kind]];
if (value !== undefined && !isRecord(value)) return null;
return { extensions, entries: value ?? {} };
}
export function validExtensionEntryName(name: string, entries: Record<string, unknown>) {
return (
customWidgetIdentifierSchema.safeParse(name).success &&
!["constructor", "prototype"].includes(name) &&
!Object.hasOwn(entries, name)
);
}
/** An incomplete template cannot prove an entry is unused, so deletion stays conservative. */
export function extensionEntryDependants(template: string, fragments: unknown, kind: ExtensionEntryKind, name: string) {
const templates: [string, unknown][] = [["widget", template]];
if (isRecord(fragments)) {
for (const [id, jsx] of Object.entries(fragments)) {
if (kind !== "fragment" || id !== name) templates.push([`fragment:${id}`, jsx]);
}
} else if (fragments !== undefined) return { invalid: true, nodes: [] as string[] };
const nodes = new Set<string>();
for (const [id, jsx] of templates) {
if (typeof jsx !== "string" || validateCustomJsxTemplate(jsx).some((issue) => issue.severity === "error"))
return { invalid: true, nodes: [...nodes] };
if (
collectTemplateReferences(jsx).some(
(reference) => reference.kind === kind && (reference.name === name || reference.name === "*"),
)
)
nodes.add(id);
}
return { invalid: false, nodes: [...nodes] };
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect } from "react";
import { Alert, Stack, Tabs, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { NativeCapabilityPicker } from "./native-editor";
import { ExtensionEntryEditor } from "./extension-entry-editor";
import { CodeEditor } from "../_code-editor";
import { isRecord, parseJson } from "../_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import type { PreviewState } from "../_custom-widget-preview-panel";
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
/** Extension editing writes the same draft as code and Assistant transactions. */
export function WorkbenchExtensions({
form,
selected,
preview,
onSelect,
}: {
form: CustomWidgetWorkbenchForm;
selected?: string;
preview?: PreviewState;
onSelect?(id: string): void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const [tab, setTab] = useState<string | null>("native");
useEffect(() => {
const kind = selected?.split(":")[0];
if (kind && ["fragment", "preference", "content", "native"].includes(kind)) setTab(kind);
}, [selected]);
const raw = useCustomWidgetFormDocumentField("extensions");
const parsed = parseJson(raw || "{}");
const valid = isRecord(parsed);
const extensions = valid ? parsed : {};
const writeStylesheet = (stylesheet: string) => {
const current = parseJson(store.getValues().extensions || "{}");
if (isRecord(current)) form.setFieldValue("extensions", JSON.stringify({ ...current, stylesheet }, null, 2));
};
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{t("extensionDescription")}
</Text>
{!valid && <Alert color="red">{t("invalidExtensions")}</Alert>}
<Tabs value={tab} onChange={setTab} keepMounted keepMountedMode="display-none">
<Tabs.List>
<Tabs.Tab value="native">{t("native")}</Tabs.Tab>
<Tabs.Tab value="styles">{t("stylesheet")}</Tabs.Tab>
<Tabs.Tab value="fragment">{t("extensionEditor.views")}</Tabs.Tab>
<Tabs.Tab value="preference">{t("extensionEditor.preferences")}</Tabs.Tab>
<Tabs.Tab value="content">{t("extensionEditor.sharedContent")}</Tabs.Tab>
<Tabs.Tab value="advanced">{t("extensionsJson")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="native" pt="sm">
<NativeCapabilityPicker
form={form}
selected={selected}
disabled={!valid}
previewSessionId={preview?.session?.id}
previewStale={preview?.stale}
previewLiveActions={preview?.session?.liveActions}
onSelect={onSelect}
/>
</Tabs.Panel>
<Tabs.Panel value="styles" pt="sm">
<CodeEditor
id="scoped-stylesheet"
language="css"
label={t("stylesheet")}
value={typeof extensions.stylesheet === "string" ? extensions.stylesheet : ""}
readOnly={!valid}
onChange={writeStylesheet}
/>
</Tabs.Panel>
{(["fragment", "preference", "content"] as const).map((kind) => (
<Tabs.Panel key={kind} value={kind} pt="sm">
<ExtensionEntryEditor form={form} kind={kind} selected={selected} onSelect={onSelect} />
</Tabs.Panel>
))}
<Tabs.Panel value="advanced" pt="sm">
<Text size="xs" c="dimmed" mb="xs">
{t("extensionsJsonDescription")}
</Text>
<CodeEditor
id="extensions-editor"
language="json"
label={t("extensionsJson")}
value={raw}
onChange={(value) => form.setFieldValue("extensions", value)}
/>
</Tabs.Panel>
</Tabs>
</Stack>
);
}
@@ -0,0 +1,35 @@
"use client";
import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { Link } from "@homarr/ui";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentField } from "../_custom-widget-form-state";
export function WorkbenchFrameControls({ onGeneral }: { onGeneral(): void }) {
const t = useI18n("customWidget.flow");
const name = useCustomWidgetFormDocumentField("name");
return (
<Group gap="sm" wrap="nowrap" style={{ flex: "1 1 180px", minWidth: 0 }}>
<Tooltip label={t("backToWidgets")}>
<ActionIcon component={Link} href="/manage/custom-widgets" variant="subtle" aria-label={t("backToWidgets")}>
<IconArrowLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("general")}>
<Text component="h1" m={0} size="sm" fw={600} style={{ minWidth: 0 }}>
<UnstyledButton
type="button"
onClick={onGeneral}
aria-label={`${name || t("workbench")} · ${t("general")}`}
style={{ display: "flex", alignItems: "center", gap: 6, maxWidth: "100%" }}
>
<Text component="span" inherit truncate>
{name || t("workbench")}
</Text>
<IconPencil size={13} style={{ flexShrink: 0 }} aria-hidden />
</UnstyledButton>
</Text>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from "react";
/** Fill the management area without remounting the editor; immersive mode also isolates the shell. */
export function useWorkbenchFullscreen() {
const [fullscreen, setFullscreen] = useState(false);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const workspace = ref.current;
if (!workspace) return;
const managementMain = workspace.closest("main");
const previousFocus = document.activeElement;
const overflow = document.body.style.getPropertyValue("overflow");
const overflowPriority = document.body.style.getPropertyPriority("overflow");
const isolated: { element: HTMLElement; inert: boolean; hidden: string | null }[] = [];
const formId = workspace.querySelector("button[form]")?.getAttribute("form");
if (!workspace.contains(previousFocus)) workspace.focus({ preventScroll: true });
document.body.style.setProperty("overflow", "hidden");
let current: HTMLElement | null = workspace;
while (current?.parentElement) {
for (const sibling of current.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === current) continue;
if (sibling.matches("script, style, link, [data-portal], [data-mantine-portal]")) continue;
if (sibling instanceof HTMLFormElement && sibling.id === formId) continue;
isolated.push({ element: sibling, inert: sibling.inert, hidden: sibling.getAttribute("aria-hidden") });
sibling.inert = true;
sibling.setAttribute("aria-hidden", "true");
}
current = current.parentElement;
if (!fullscreen && current === managementMain) break;
if (current === document.body) break;
}
return () => {
document.body.style.setProperty("overflow", overflow, overflowPriority);
for (const { element, inert, hidden } of isolated) {
element.inert = inert;
if (hidden === null) element.removeAttribute("aria-hidden");
else element.setAttribute("aria-hidden", hidden);
}
if (!workspace.isConnected && previousFocus instanceof HTMLElement && previousFocus.isConnected)
previousFocus.focus({ preventScroll: true });
};
}, [fullscreen]);
return { fullscreen, ref, toggleFullscreen: () => setFullscreen((current) => !current) };
}
@@ -0,0 +1,240 @@
import type { Edge, Node } from "@xyflow/react";
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorLayout, CustomJsxRequest } from "@homarr/custom-widgets/core";
import { collectGeneratedBindings, collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
export type FlowKind =
| "source"
| "query"
| "action"
| "options"
| "widget"
| "group"
| "native"
| "fragment"
| "preference"
| "content";
export interface WidgetNodeData extends Record<string, unknown> {
kind: FlowKind;
identifier: string;
label: string;
summary: string;
}
export type WidgetNode = Node<WidgetNodeData, "widget" | "preview" | "group">;
export type WidgetEdge = Edge<{
relationship: "source" | "data" | "invoke" | "invalidates" | "option" | "composition" | "preference" | "content";
generatedBindingIds?: string[];
}>;
type Relationship = NonNullable<WidgetEdge["data"]>["relationship"];
export function readObject(value: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
} catch {
/* Preserve invalid JSON in the inspector. */
}
return {};
}
export function record(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
return {};
}
const colors = {
source: "var(--mantine-color-blue-6)",
data: "var(--mantine-color-teal-6)",
invoke: "var(--mantine-color-orange-6)",
invalidates: "var(--mantine-color-grape-6)",
option: "var(--mantine-color-gray-6)",
composition: "var(--mantine-color-pink-6)",
preference: "var(--mantine-color-yellow-6)",
content: "var(--mantine-color-cyan-6)",
};
export function projectWidgetGraph(values: CustomWidgetFormValues, layout: CustomWidgetEditorLayout) {
const nodes: WidgetNode[] = [];
const edges: WidgetEdge[] = [];
const generatedEdges = new Map<string, Set<string> | null>();
for (const [id, group] of Object.entries(layout.groups)) {
const position = layout.nodes[id] ?? { x: 0, y: 0 };
nodes.push({
id,
type: "group",
position: { x: position.x, y: position.y },
style: { width: group.width, height: group.height },
data: { kind: "group", identifier: id, label: group.title, summary: "" },
});
}
const addNode = (
id: string,
kind: FlowKind,
identifier: string,
label: string,
summary: string,
x: number,
y: number,
) => {
const position = layout.nodes[id] ?? { x, y };
nodes.push({
id,
type: kind === "widget" ? "preview" : "widget",
ariaLabel: label,
position: { x: position.x, y: position.y },
parentId: position.parentId && layout.groups[position.parentId] ? position.parentId : undefined,
dragHandle: ".widget-node-drag",
data: { kind, identifier, label, summary },
});
};
const addEdge = (source: string, target: string, relationship: Relationship, bindingId?: string) => {
const id = `${source}:${relationship}:${target}`;
if (!bindingId) generatedEdges.set(id, null);
else if (!generatedEdges.has(id)) generatedEdges.set(id, new Set([bindingId]));
else generatedEdges.get(id)?.add(bindingId);
if (edges.some((edge) => edge.id === id)) return;
edges.push({
id,
source,
target,
sourceHandle: "out",
targetHandle: "in",
data: { relationship },
reconnectable: relationship === "source" ? "source" : relationship === "invalidates",
deletable: relationship === "invalidates",
style: {
stroke: colors[relationship],
strokeWidth: 1.5,
strokeDasharray: relationship === "invalidates" ? "5 5" : undefined,
},
});
};
Object.entries(readObject(values.sources)).forEach(([id, source], index) => {
const value = record(source);
addNode(`source:${id}`, "source", id, String(value.name ?? id), String(value.baseUrl ?? ""), 0, index * 145);
});
const requests = readObject(values.requests);
const extensions = readObject(values.extensions ?? "{}");
const native = record(extensions.native);
const requestNode = (id: string) => (Object.hasOwn(native, id) ? `native:${id}` : `request:${id}`);
Object.entries(requests).forEach(([id, raw], index) => {
const request = record(raw);
const kind = request.kind === "action" ? "action" : "query";
addNode(
`request:${id}`,
kind,
id,
id,
`${String(request.method ?? "GET")} ${String(request.path ?? "")}`,
340,
index * 130,
);
addEdge(`source:${String(request.source ?? "default")}`, `request:${id}`, "source");
if (Array.isArray(request.invalidates))
for (const target of request.invalidates) {
if (typeof target === "string") addEdge(`request:${id}`, `request:${target}`, "invalidates");
}
if (typeof request.path === "string") {
const references = collectCustomWidgetRequestReferences(
request as Pick<CustomJsxRequest, "path" | "query" | "body">,
);
if (references.options.size) addEdge("options", `request:${id}`, "option");
}
});
Object.entries(native).forEach(([id, raw], index) => {
const capability = record(raw);
addNode(
`native:${id}`,
"native",
id,
id,
String(capability.capability ?? ""),
340,
(Object.keys(requests).length + index) * 130,
);
const references = collectCustomWidgetRequestReferences({ path: "", query: record(capability.input) });
if (references.options.size || capability.integrationOption) addEdge("options", `native:${id}`, "option");
});
const parsedOptions = readObject(values.options);
const optionNames = Object.keys(parsedOptions);
for (const option of Object.values(parsedOptions)) {
const requestId = record(record(option).choicesFrom).request;
if (typeof requestId === "string") addEdge(requestNode(requestId), "options", "data");
}
if (optionNames.length)
addNode("options", "options", "options", "options", optionNames.join(", "), 0, nodes.length * 80 + 100);
for (const [id, value] of Object.entries(record(extensions.preferences)))
addNode(`preference:${id}`, "preference", id, id, String(record(value).type ?? ""), 0, nodes.length * 130);
for (const [id, value] of Object.entries(record(extensions.content)))
addNode(
`content:${id}`,
"content",
id,
id,
`${String(record(value).type ?? "")} · ${String(record(value).permission ?? "")}`,
0,
nodes.length * 130,
);
const fragments = Object.entries(record(extensions.fragments)).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
fragments.forEach(([id], index) => addNode(`fragment:${id}`, "fragment", id, id, "JSX", 700, 260 + index * 130));
addNode("widget", "widget", "widget", values.name, "JSX", 700, 100);
const templates = [
["widget", values.template],
...fragments.map(([id, source]) => [`fragment:${id}`, source]),
] as const;
for (const [consumer, template] of templates) {
if (!consumer || template === undefined) continue;
const bindings =
consumer === "widget"
? collectGeneratedBindings(
template,
fragments.map(([, source]) => source),
)
: [];
for (const ref of collectTemplateReferences(template)) {
const addReference = (source: string, target: string, relationship: Relationship) => {
const binding = bindings.find(
(entry) =>
entry.source === source &&
entry.target === target &&
entry.relationship === relationship &&
ref.from >= entry.contentFrom &&
ref.to <= entry.contentTo,
);
addEdge(source, target, relationship, binding?.id);
};
if (ref.kind === "data" || ref.kind === "status") addReference(requestNode(ref.name), consumer, "data");
if (ref.kind === "request") addReference(consumer, requestNode(ref.name), "invoke");
if (ref.kind === "native") {
const descriptor = record(native[ref.name]);
if (descriptor.kind === "action") addReference(consumer, `native:${ref.name}`, "invoke");
else addReference(`native:${ref.name}`, consumer, "data");
}
if (ref.kind === "options") addReference("options", consumer, "option");
if (["fragment", "preference", "content"].includes(ref.kind)) {
const referenced = nodes.filter(
(node) => node.data.kind === ref.kind && (ref.name === "*" || node.data.identifier === ref.name),
);
for (const target of referenced) {
if (ref.kind === "fragment") addReference(target.id, consumer, "composition");
if (ref.kind === "preference") addReference(target.id, consumer, "preference");
if (ref.kind === "content") addReference(target.id, consumer, "content");
}
}
}
}
for (const edge of edges) {
const bindings = generatedEdges.get(edge.id);
if (!edge.data || !bindings?.size) continue;
edge.data.generatedBindingIds = [...bindings];
edge.deletable = true;
edge.reconnectable = edge.data.relationship === "invoke" ? "target" : "source";
}
const ids = new Set(nodes.map((n) => n.id));
return { nodes, edges: edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) };
}
export async function arrangeWidgetGraph(nodes: WidgetNode[], edges: WidgetEdge[], preserveAnchor = false) {
const engine = await import("./layout-engine");
return engine.arrangeWidgetGraph(nodes, edges, preserveAnchor);
}
@@ -0,0 +1,16 @@
"use client";
import { memo } from "react";
import { Text } from "@mantine/core";
import type { NodeProps } from "@xyflow/react";
import type { WidgetNode } from "./graph";
import classes from "./workbench.module.css";
export const FlowGroup = memo(function FlowGroup({ data, selected }: NodeProps<WidgetNode>) {
return (
<div className={classes.group} data-selected={selected}>
<Text className="widget-node-drag" fw={600} size="sm">
{data.label}
</Text>
</div>
);
});
@@ -0,0 +1,51 @@
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
export type DocumentContent = Omit<CustomWidgetFormValues, "secrets">;
export interface EditorSnapshot {
content: DocumentContent;
layout: CustomWidgetEditorLayout;
sourceBindings: Record<string, string>;
requestBindings: Record<string, string>;
}
export function withoutCredentials({ secrets: _secrets, ...content }: CustomWidgetFormValues): DocumentContent {
return content;
}
/** Semantic history never captures credentials, network results, or viewport state. */
export function createEditorHistory() {
const past: EditorSnapshot[] = [];
const future: EditorSnapshot[] = [];
let lastGroup = "";
let lastTime = 0;
return {
record(previous: EditorSnapshot, group: string) {
const now = Date.now();
if (!group || group !== lastGroup || now - lastTime > 750) {
past.push(previous);
if (past.length > 80) past.shift();
}
lastGroup = group;
lastTime = now;
future.length = 0;
},
undo(current: EditorSnapshot) {
const previous = past.pop();
if (previous) future.push(current);
lastGroup = "";
return previous;
},
redo(current: EditorSnapshot) {
const next = future.pop();
if (next) past.push(current);
lastGroup = "";
return next;
},
get canUndo() {
return past.length > 0;
},
get canRedo() {
return future.length > 0;
},
};
}
@@ -0,0 +1,5 @@
export function freshFlowIdentifier(entries: Record<string, unknown>, prefix: string) {
let count = 1;
while (Object.hasOwn(entries, `${prefix.slice(0, 55)}${count}`)) count += 1;
return `${prefix.slice(0, 55)}${count}`;
}
@@ -0,0 +1,213 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import type { RefObject } from "react";
import { ActionIcon, Button, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconCopy, IconPalette, IconSparkles, IconTrash, IconX } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { createFlowCommands } from "./commands";
import { isExtensionKind } from "./extension-commands";
import { RequestExecutionInspector } from "./request-execution";
import { projectWidgetGraph } from "./graph";
import type { WidgetNode, WidgetEdge } from "./graph";
import type { FlowWorkbenchProps } from "./workbench-types";
import { placeAddedNodes } from "./node-placement";
import { Connections } from "./connections";
import { DraftRecoverySettings } from "./recovery";
import classes from "./workbench.module.css";
const WorkbenchExtensions = dynamic(() => import("./extensions").then((module) => module.WorkbenchExtensions), {
ssr: false,
});
const WorkbenchAssistant = dynamic(() => import("./assistant").then((module) => module.WorkbenchAssistant), {
ssr: false,
});
export function WorkbenchInspector({
sections,
node,
graph,
selected,
selection,
pane,
setPane,
mode,
editable,
extensionsOpened,
setExtensionsOpened,
select,
onDelete,
onClose,
onAssistant,
inspectorRef,
}: {
sections: FlowWorkbenchProps;
node?: WidgetNode;
graph: { nodes: WidgetNode[]; edges: WidgetEdge[] };
selected: string;
selection: string[];
pane: string;
setPane(value: string): void;
mode: string;
editable: boolean;
extensionsOpened: boolean;
setExtensionsOpened(value: boolean): void;
select(id: string): void;
onDelete(): void;
onClose(): void;
onAssistant(): void;
inspectorRef: RefObject<HTMLDivElement | null>;
}) {
const t = useI18n("customWidget.flow");
const closeLabel = useI18n("common.action")("close");
const store = useCustomWidgetFormDocumentStore();
const commands = useMemo(() => createFlowCommands(sections.form, store), [sections.form, store]);
const category = node?.data.kind ?? "general";
const formMode = mode !== "flow";
const canDuplicate = node && (["source", "query", "action"].includes(category) || isExtensionKind(category));
let title = node?.data.label ?? t("general");
if (formMode) title = t("form");
else if (pane === "assistant") title = t("assistant");
else if (pane === "manifest") title = t("manifest");
else if (pane === "extensions" && !isExtensionKind(category)) title = t("stylesAndCapabilities");
const assistantPane = useMemo(
() => (
<WorkbenchAssistant form={sections.form} selected={selected} onSelect={select} onPreview={sections.onPreview} />
),
[sections.form, sections.onPreview, selected, select],
);
const openExtensions = () => {
setExtensionsOpened(true);
setPane("extensions");
};
const duplicate = () => {
if (!node) return;
store.transaction(() => {
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
const id = commands.duplicate(node);
if (id === node.id) return;
const next = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
store.setLayout({
...store.getLayout(),
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, next, id, node.id) },
});
select(id);
});
};
return (
<aside ref={inspectorRef} className={classes.inspector} aria-label={title}>
<Group className={classes.inspectorHeader} justify="space-between" wrap="nowrap" mb="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text component="h2" m={0} size="sm" fw={600} truncate>
{title}
</Text>
{node && !formMode && pane !== "assistant" && (
<Text size="xs" c="dimmed">
{t(`kind.${node.data.kind}`)}
</Text>
)}
</Stack>
<Group gap={4} wrap="nowrap">
{pane !== "assistant" && (
<Tooltip label={t("assistant")}>
<ActionIcon variant="subtle" aria-label={t("assistant")} onClick={onAssistant}>
<IconSparkles size={17} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={closeLabel}>
<ActionIcon variant="subtle" aria-label={closeLabel} onClick={onClose}>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{(formMode || (pane === "inspector" && category === "general")) && <DraftRecoverySettings />}
<div hidden={!formMode && pane !== "inspector"}>
<div hidden={!formMode && category !== "general"}>{sections.general}</div>
<div hidden={!formMode && category !== "widget"}>{sections.template}</div>
<div hidden={!formMode && category !== "source"}>{sections.sources}</div>
<div hidden={!formMode && !["query", "action"].includes(category)}>{sections.requests}</div>
<div hidden={!formMode && category !== "options"}>{sections.options}</div>
{node?.data.kind === "group" && (
<TextInput
label={t("groupName")}
value={node.data.label}
maxLength={128}
onChange={(event) => {
const current = store.getLayout();
const group = current.groups[node.id];
if (!group) return;
store.setLayout({
...current,
groups: { ...current.groups, [node.id]: { ...group, title: event.currentTarget.value } },
});
}}
/>
)}
{!formMode && category === "widget" && (
<Button
size="compact-xs"
mt="sm"
variant="subtle"
leftSection={<IconPalette size={15} />}
onClick={openExtensions}
>
{t("stylesAndCapabilities")}
</Button>
)}
{node && ["query", "action"].includes(category) && (
<RequestExecutionInspector requestId={node.data.identifier} preview={sections.execution} />
)}
{formMode && sections.assistant}
</div>
<div className={classes.assistantPane} hidden={formMode || pane !== "assistant"} data-flow-assistant>
{assistantPane}
</div>
<div hidden={formMode || pane !== "extensions"}>
{extensionsOpened && (
<WorkbenchExtensions
form={sections.form}
selected={selected}
preview={sections.execution}
onSelect={select}
/>
)}
</div>
{node && !formMode && ["inspector", "extensions"].includes(pane) && (
<Connections node={node} nodes={graph.nodes} edges={graph.edges} form={sections.form} onSelect={select} />
)}
<div hidden={!formMode && pane !== "manifest"}>{sections.advanced}</div>
{!formMode && pane !== "assistant" && (
<Group gap={4} mt="sm">
{pane !== "inspector" && !isExtensionKind(category) && (
<Button size="compact-xs" variant="subtle" onClick={() => setPane("inspector")}>
{t("backToNode")}
</Button>
)}
{canDuplicate && (
<Button
size="compact-xs"
variant="subtle"
disabled={!editable}
leftSection={<IconCopy size={14} />}
onClick={duplicate}
>
{t("duplicate")}
</Button>
)}
{selection.some((id) => id !== "widget") && (
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={14} />}
onClick={onDelete}
>
{t("deleteSelection")}
</Button>
)}
</Group>
)}
</aside>
);
}
@@ -0,0 +1,103 @@
import { useEffect } from "react";
import type { RefObject } from "react";
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { readObject, record } from "./graph";
export function useWorkbenchKeybindings({
store,
selection,
onDelete,
returnFocus,
onSelect,
onClear,
onCloseLibrary,
onShowExecution,
}: {
store: CustomWidgetFormDocumentStore;
selection: string[];
onDelete(): void;
onSelect(id: string): void;
onClear(): void;
onCloseLibrary(): void;
onShowExecution(): void;
returnFocus: RefObject<HTMLElement | null>;
}) {
useEffect(() => {
const undo = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
const target = event.target;
if (!(target instanceof Element) || !target.closest("[data-widget-workbench]")) return;
if (
target.closest(
'input[type="password"], [data-workbench-credential], [data-workbench-local-input], [data-flow-preview], [data-flow-assistant]',
)
)
return;
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) store.redo();
else store.undo();
};
const graph = (event: KeyboardEvent) => {
const target = event.target;
if (event.defaultPrevented || !(target instanceof Element) || !target.closest("[data-widget-workbench]")) return;
if (event.key === "Escape" && target.closest("[data-workbench-library]")) {
event.preventDefault();
onCloseLibrary();
return;
}
if (
target.closest(
'input, textarea, [contenteditable="true"], [role="combobox"], [data-flow-preview], [data-flow-assistant]',
)
)
return;
if ((event.key === "Delete" || event.key === "Backspace") && selection.some((id) => id !== "widget")) {
event.preventDefault();
onDelete();
}
if (event.key === "Escape") {
onClear();
returnFocus.current?.focus();
}
};
const inspect = (event: Event) => {
const detail = (event as CustomEvent<{ fragment?: string }>).detail;
let id = "widget";
if (detail?.fragment) id = `fragment:${detail.fragment}`;
onSelect(id);
};
const insert = () => onSelect("widget");
const inspectRequest = (event: Event) => {
const detail = (event as CustomEvent<{ requestId?: unknown; field?: string }>).detail;
if (typeof detail?.requestId !== "string") return;
const requestId = detail.requestId;
let id = `request:${requestId}`;
if (Object.hasOwn(record(readObject(store.getValues().extensions).native), requestId)) id = `native:${requestId}`;
else if (!Object.hasOwn(readObject(store.getValues().requests), requestId)) return;
onSelect(id);
if (detail.field === "path")
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(
`[data-workbench-request="${CSS.escape(requestId)}"] [data-workbench-request-path]`,
)
?.focus();
});
};
window.addEventListener("homarr:widget-inspect", inspect);
window.addEventListener("homarr:widget-code-insert", insert);
window.addEventListener("homarr:widget-request-inspect", inspectRequest);
window.addEventListener("homarr:widget-execution-panel", onShowExecution);
document.addEventListener("keydown", undo, true);
document.addEventListener("keydown", graph);
return () => {
window.removeEventListener("homarr:widget-inspect", inspect);
window.removeEventListener("homarr:widget-code-insert", insert);
window.removeEventListener("homarr:widget-request-inspect", inspectRequest);
window.removeEventListener("homarr:widget-execution-panel", onShowExecution);
document.removeEventListener("keydown", undo, true);
document.removeEventListener("keydown", graph);
};
}, [store, selection, onDelete, returnFocus, onSelect, onClear, onCloseLibrary, onShowExecution]);
}
@@ -0,0 +1,107 @@
import type { ElkNode } from "elkjs/lib/elk-api";
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
import type { WidgetNode, WidgetEdge } from "./graph";
/** Only editor code loads this module; ELK runs in a real worker. */
export async function arrangeWidgetGraph(nodes: WidgetNode[], edges: WidgetEdge[], preserveAnchor = false) {
const { default: ELK } = await import("elkjs/lib/elk-api");
const worker = new Worker(new URL("elkjs/lib/elk-worker.min.js", import.meta.url));
const elk = new ELK({ workerFactory: () => worker });
let timeout: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("Layout timed out")), 15_000);
});
try {
const ids = new Set(nodes.map((node) => node.id));
const rootMembers = nodes.filter((node) => !node.parentId || !ids.has(node.parentId));
const options = {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.spacing.nodeNode": "40",
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
};
const buildNode = (node: WidgetNode): ElkNode => {
if (node.data.kind === "group")
return {
id: node.id,
layoutOptions: {
...options,
"elk.padding": "[top=60,left=30,bottom=30,right=30]",
},
children: nodes.filter((member) => member.parentId === node.id).map(buildNode),
};
return {
id: node.id,
width: 240,
height: 120,
layoutOptions: { "elk.portConstraints": "FIXED_SIDE" },
ports: [
{ id: `${node.id}:in`, layoutOptions: { "elk.port.side": "WEST" } },
{ id: `${node.id}:out`, layoutOptions: { "elk.port.side": "EAST" } },
],
};
};
const result = await Promise.race([
deadline,
elk.layout({
id: "root",
layoutOptions: options,
children: rootMembers.map(buildNode),
edges: edges
.filter(
(edge) =>
ids.has(edge.source) &&
ids.has(edge.target) &&
edge.data?.relationship !== "invalidates" &&
edge.data?.relationship !== "invoke",
)
.map((edge) => ({ id: edge.id, sources: [`${edge.source}:out`], targets: [`${edge.target}:in`] })),
}),
]);
const next: Pick<CustomWidgetEditorLayout, "nodes" | "groups"> = { nodes: {}, groups: {} };
const original = new Map(nodes.map((node) => [node.id, node]));
// Arranging a selection preserves its location and every unrelated position.
const x = Math.min(0, ...rootMembers.map((node) => node.position.x));
const y = Math.min(0, ...rootMembers.map((node) => node.position.y));
let offsetX = 0;
let offsetY = 0;
if (preserveAnchor && rootMembers.length) {
offsetX =
Math.min(...rootMembers.map((node) => node.position.x)) -
Math.min(...(result.children ?? []).map((node) => node.x ?? 0));
offsetY =
Math.min(...rootMembers.map((node) => node.position.y)) -
Math.min(...(result.children ?? []).map((node) => node.y ?? 0));
} else {
offsetX = x;
offsetY = y;
}
const collect = (members: ElkNode[], topLevel: boolean) => {
for (const member of members) {
const previous = original.get(member.id);
if (!previous) continue;
let memberX = member.x ?? 0;
let memberY = member.y ?? 0;
if (topLevel) {
memberX += offsetX;
memberY += offsetY;
}
next.nodes[member.id] = { x: memberX, y: memberY, parentId: previous.parentId };
if (previous.data.kind === "group") {
next.groups[member.id] = {
title: previous.data.label,
width: Math.max(240, member.width ?? 240),
height: Math.max(160, member.height ?? 160),
};
}
collect(member.children ?? [], false);
}
};
collect(result.children ?? [], true);
return next;
} finally {
clearTimeout(timeout);
worker.terminate();
}
}
@@ -0,0 +1,109 @@
import { createId } from "@homarr/common";
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
import type { WidgetNode } from "./graph";
import { absoluteNodePosition, nodeDimensions } from "./node-geometry";
export function captureLayout(nodes: WidgetNode[], previous: CustomWidgetEditorLayout): CustomWidgetEditorLayout {
return {
...previous,
nodes: Object.fromEntries(nodes.map((node) => [node.id, { ...node.position, parentId: node.parentId }])),
groups: Object.fromEntries(
nodes
.filter((node) => node.data.kind === "group")
.map((node) => [
node.id,
{
title: node.data.label,
width: Number(node.style?.width ?? 600),
height: Number(node.style?.height ?? 400),
},
]),
),
};
}
export function projectLayout(nodes: WidgetNode[], layout: CustomWidgetEditorLayout): WidgetNode[] {
const previous = new Map(nodes.map((node) => [node.id, node]));
const groups: WidgetNode[] = Object.entries(layout.groups).map(([id, group]) => {
const stored = layout.nodes[id] ?? { x: 0, y: 0 };
return {
...previous.get(id),
id,
type: "group",
dragHandle: ".widget-node-drag",
position: { x: stored.x, y: stored.y },
style: { width: group.width, height: group.height },
data: { kind: "group", identifier: id, label: group.title, summary: "" },
};
});
return [
...groups,
...nodes
.filter((node) => node.data.kind !== "group")
.map((node) => {
const stored = layout.nodes[node.id];
if (!stored) return { ...node, parentId: undefined };
let parentId: string | undefined;
if (stored.parentId && layout.groups[stored.parentId]) parentId = stored.parentId;
return { ...node, parentId, position: { x: stored.x, y: stored.y } };
}),
];
}
export function absolutePosition(node: WidgetNode, nodes: WidgetNode[]) {
return absoluteNodePosition(node, new Map(nodes.map((entry) => [entry.id, entry])));
}
export function groupNodes(nodes: WidgetNode[], title: string): WidgetNode[] {
const selected = nodes.filter((node) => node.selected && node.data.kind !== "group");
if (selected.length < 2) return nodes;
const positions = selected.map((node) => absolutePosition(node, nodes));
const dimensions = selected.map(nodeDimensions);
const id = `group:${createId()}`;
const x = Math.min(...positions.map((position) => position.x)) - 30;
const y = Math.min(...positions.map((position) => position.y)) - 60;
const width =
Math.max(...positions.map((position, index) => position.x + (dimensions[index]?.width ?? 240))) - x + 30;
const height =
Math.max(...positions.map((position, index) => position.y + (dimensions[index]?.height ?? 140))) - y + 30;
const ids = new Set(selected.map((node) => node.id));
const result: WidgetNode[] = [
{
id,
type: "group",
dragHandle: ".widget-node-drag",
position: { x, y },
style: { width, height },
data: {
kind: "group",
identifier: id,
label: title,
summary: "",
},
selected: true,
},
...nodes.map((node) => {
if (!ids.has(node.id)) return { ...node, selected: false };
const position = absolutePosition(node, nodes);
return { ...node, parentId: id, position: { x: position.x - x, y: position.y - y }, selected: false };
}),
];
return result;
}
export function ungroupNodes(nodes: WidgetNode[]): WidgetNode[] {
const selected = nodes.filter((node) => node.selected);
const ids = new Set(selected.filter((node) => node.data.kind === "group").map((node) => node.id));
const members = new Set(selected.map((node) => node.id));
return nodes
.filter((node) => !ids.has(node.id))
.map((node) => {
if (!node.parentId || (!ids.has(node.parentId) && !members.has(node.id))) return node;
return { ...node, parentId: undefined, position: absolutePosition(node, nodes), selected: true };
});
}
export function expandGroupSelection(nodes: WidgetNode[], selection: string[]) {
const ids = new Set(selection);
return nodes.filter((node) => ids.has(node.id) || (node.parentId && ids.has(node.parentId)));
}
@@ -0,0 +1,52 @@
"use client";
import { Accordion, Button, Group, Text, Tooltip } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { FlowKind } from "./graph";
const primaryKinds = ["source", "query", "action", "options", "native"] as const;
const additionalKinds = ["fragment", "preference", "content"] as const;
export function WorkbenchNodeLibrary({ editable, onAdd }: { editable: boolean; onAdd(kind: FlowKind): void }) {
const t = useI18n("customWidget.flow");
const buttons = (kinds: readonly ((typeof primaryKinds)[number] | (typeof additionalKinds)[number])[]) => (
<Group gap={4}>
{kinds.map((kind) => (
<Tooltip
key={kind}
label={t(`nodeHelp.${kind}`)}
multiline
w={240}
events={{ hover: true, focus: true, touch: true }}
>
<Button disabled={!editable} type="button" size="compact-xs" variant="light" onClick={() => onAdd(kind)}>
{t(`kind.${kind}`)}
</Button>
</Tooltip>
))}
</Group>
);
return (
<>
<Text size="xs" fw={600} c="dimmed" mt="md" mb={4}>
{t("add")}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("libraryHint")}
</Text>
{buttons(primaryKinds)}
<Accordion mt="xs" variant="default" styles={{ control: { padding: 0 }, content: { padding: "0 0 8px" } }}>
<Accordion.Item value="composition">
<Accordion.Control>
<Text size="xs">{t("moreBlocks")}</Text>
</Accordion.Control>
<Accordion.Panel>{buttons(additionalKinds)}</Accordion.Panel>
</Accordion.Item>
</Accordion>
{!editable && (
<Text size="xs" c="orange" mt="xs">
{t("invalidGraphJson")}
</Text>
)}
</>
);
}
@@ -0,0 +1,234 @@
"use client";
import { useState } from "react";
import { Alert, Button, Checkbox, Fieldset, Group, Select, Stack, Text, Textarea, TextInput } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetIntegrationOption } from "@homarr/widgets/custom-api/integration-option";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { buildDefinition, isRecord, parseJson } from "../_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { nativeInputFields, nativeParameterFields, renameNativeCapability } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
import { NativeInputField } from "./native-input-field";
import { NativeLiteralInput } from "./native-literal-input";
import { NativeRequestTest } from "./native-request-test";
import type { NativePreviewProps } from "./native-request-test";
export function NativeDescriptorEditor({
form,
id,
entry,
options,
schema,
disabled,
onSelect,
...preview
}: NativePreviewProps & {
form: CustomWidgetWorkbenchForm;
id: string;
entry: Record<string, unknown>;
options: Record<string, unknown>;
schema?: NativeInputSchema;
disabled: boolean;
onSelect?: (id: string) => void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const [nextName, setName] = useState(id);
const [error, setError] = useState<string>();
const input = isRecord(entry.input) ? entry.input : {};
const fields = nativeInputFields(schema ?? {});
const hasParams = nativeParameterFields(input, schema ?? {}).length > 0;
const action = entry.kind === "action";
const confirmation = isRecord(entry.confirmation) ? entry.confirmation : {};
const optionId = String(entry.integrationOption ?? "");
const option = options[optionId];
const patch = (changes: Record<string, unknown>) => {
if (disabled) return;
const extensions = parseJson(form.values.extensions);
if (!isRecord(extensions) || !isRecord(extensions.native)) return;
const current = extensions.native[id];
if (!isRecord(current)) return;
form.setFieldValue(
"extensions",
JSON.stringify(
{
...extensions,
native: { ...extensions.native, [id]: { ...current, ...changes } },
},
null,
2,
),
);
};
const updateInput = (name: string, value: unknown) => {
const next = { ...input, [name]: value };
if (value === undefined) delete next[name];
const changes: Record<string, unknown> = { input: next };
if (nativeParameterFields(next, schema ?? {}).length) changes.trigger = "manual";
patch(changes);
};
const rename = () => {
if (disabled || id === nextName) return;
try {
const current = buildDefinition(form.values);
if (!current.success) throw new Error(t("nativeRenameInvalidDraft"));
const next = renameNativeCapability(current.data, id, nextName);
store.transaction(() => {
form.setFieldValue("extensions", JSON.stringify(next.extensions, null, 2));
form.setFieldValue("template", next.template);
const layout = store.getLayout();
const position = layout.nodes[`native:${id}`];
if (position) {
const nodes = { ...layout.nodes, [`native:${nextName}`]: position };
delete nodes[`native:${id}`];
store.setLayout({ ...layout, nodes });
}
});
setError(undefined);
onSelect?.(`native:${nextName}`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
};
const extras = Object.fromEntries(
Object.entries(input).filter(([name]) => !fields.some((field) => field.name === name)),
);
return (
<Stack gap="sm">
<Text fw={600} size="sm">
{String(entry.capability)} · {action ? t("kind.action") : t("kind.query")}
</Text>
<Group align="end" wrap="nowrap">
<TextInput
data-workbench-local-input
label={t("nativeId")}
value={nextName}
onChange={(event) => setName(event.currentTarget.value)}
disabled={disabled}
style={{ flex: 1 }}
error={error}
/>
<Button variant="light" onClick={rename} disabled={disabled || nextName === id || !nextName}>
{t("nativeRename")}
</Button>
</Group>
{isRecord(option) && (
<CustomWidgetIntegrationOption
disabled={disabled}
label={String(option.label ?? optionId)}
value={option.default}
kinds={Array.isArray(option.integrationKinds) ? (option.integrationKinds as string[]) : undefined}
onChange={(value) => {
if (disabled) return;
const current = parseJson(form.values.options);
if (!isRecord(current) || !isRecord(current[optionId])) return;
form.setFieldValue(
"options",
JSON.stringify({ ...current, [optionId]: { ...current[optionId], default: value } }, null, 2),
);
}}
/>
)}
<Select
label={t("nativeTrigger")}
value={String(entry.trigger ?? "load")}
disabled={disabled || action}
description={hasParams ? t("nativeManualRequired") : undefined}
data={[
{ value: "load", label: t("nativeTriggerLoad"), disabled: hasParams },
{ value: "manual", label: t("nativeTriggerManual") },
]}
onChange={(trigger) => {
if (trigger) patch({ trigger });
}}
/>
<Select
label={t("nativePermission")}
value={String(entry.permission ?? "view")}
disabled={disabled}
data={[
{ value: "view", label: t("nativePermissionView"), disabled: action },
{ value: "modify", label: t("nativePermissionModify") },
{ value: "full", label: t("nativePermissionFull") },
]}
onChange={(permission) => {
if (permission) patch({ permission });
}}
/>
{action && (
<Fieldset legend={t("nativeConfirmation")} disabled={disabled}>
<Stack gap="xs">
<TextInput
label={t("nativeConfirmationTitleLabel")}
value={String(confirmation.title ?? "")}
required
maxLength={128}
onChange={(event) => patch({ confirmation: { ...confirmation, title: event.currentTarget.value } })}
/>
<Textarea
label={t("nativeConfirmationMessageLabel")}
value={String(confirmation.message ?? "")}
required
maxLength={512}
onChange={(event) => patch({ confirmation: { ...confirmation, message: event.currentTarget.value } })}
/>
<TextInput
label={t("nativeConfirmationButton")}
value={String(confirmation.confirmLabel ?? "")}
maxLength={64}
onChange={(event) =>
patch({ confirmation: { ...confirmation, confirmLabel: event.currentTarget.value } })
}
/>
<Checkbox
label={t("nativeConfirmationDestructive")}
checked={confirmation.destructive === true}
onChange={(event) =>
patch({ confirmation: { ...confirmation, destructive: event.currentTarget.checked } })
}
/>
</Stack>
</Fieldset>
)}
{!schema && <Alert color="yellow">{t("nativeSchemaUnavailable")}</Alert>}
{fields.map((field) => (
<NativeInputField
key={field.name}
{...field}
value={input[field.name]}
options={options}
disabled={disabled}
onChange={(value) => updateInput(field.name, value)}
/>
))}
{(!schema || Object.keys(extras).length > 0) && (
<NativeLiteralInput
label={t("nativeAdvancedInputs")}
schema={{ type: "object" }}
value={schema ? extras : input}
disabled={disabled}
onChange={(value) => {
if (!isRecord(value)) return;
const declared = Object.fromEntries(
Object.entries(input).filter(([name]) => fields.some((field) => field.name === name)),
);
const next = { ...declared, ...value };
patch({ input: next, ...(nativeParameterFields(next, schema ?? {}).length ? { trigger: "manual" } : {}) });
}}
/>
)}
<Text size="xs" c="dimmed">
{t("nativeBindingHelp", { id })}
</Text>
<NativeRequestTest
key={`${id}:${entry.capability}`}
id={id}
input={input}
schema={schema ?? {}}
action={action}
{...preview}
/>
</Stack>
);
}
@@ -0,0 +1,106 @@
import { customWidgetDefinitionSchema, customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
import { isRecord } from "@homarr/common";
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
export type NativeInputSchema = Record<string, unknown>;
export function nativeInputFields(schema: NativeInputSchema) {
if (!isRecord(schema.properties)) return [];
const required = Array.isArray(schema.required) ? schema.required : [];
return Object.entries(schema.properties).map(([name, value]) => ({
name,
schema: isRecord(value) ? value : {},
required: required.includes(name),
}));
}
export function nativeLiteralDefault(schema: NativeInputSchema): unknown {
if (schema.default !== undefined) return schema.default;
if (Array.isArray(schema.enum)) return schema.enum[0];
if (schema.type === "boolean") return false;
if (schema.type === "number" || schema.type === "integer") return schema.minimum ?? 0;
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return "";
}
export function isCompatibleNativeOption(option: unknown, schema: NativeInputSchema): boolean {
if (!isRecord(option) || option.control === "integration") return false;
const value = option.default;
if (schema.type === "integer") return typeof value === "number" && Number.isInteger(value);
if (["string", "number", "boolean"].includes(String(schema.type))) return typeof value === schema.type;
if (schema.type === "object") return isRecord(value);
if (schema.type !== "array") return true;
if (!Array.isArray(value)) return false;
if (!isRecord(schema.items)) return true;
const type = schema.items.type;
if (type === "integer") return value.every((entry) => typeof entry === "number" && Number.isInteger(entry));
if (["string", "number", "boolean"].includes(String(type))) return value.every((entry) => typeof entry === type);
return true;
}
/** Match parameter references to their field schema without storing execution values in the draft. */
export function nativeParameterFields(input: unknown, schema: NativeInputSchema) {
const fields = new Map<string, NativeInputSchema>();
const visit = (value: unknown, current: NativeInputSchema) => {
if (Array.isArray(value)) {
const item = isRecord(current.items) ? current.items : {};
value.forEach((entry) => visit(entry, item));
return;
}
if (!isRecord(value)) return;
if (typeof value.$param === "string") {
fields.set(value.$param, current);
return;
}
const properties = isRecord(current.properties) ? current.properties : {};
for (const [key, child] of Object.entries(value)) {
visit(child, isRecord(properties[key]) ? properties[key] : {});
}
};
visit(input, schema);
return [...fields].map(([name, fieldSchema]) => ({ name, schema: fieldSchema }));
}
export function renameNativeCapability(definition: HomarrCustomWidgetV2, currentId: string, nextId: string) {
customWidgetIdentifierSchema.parse(nextId);
const native = definition.extensions?.native;
if (!native?.[currentId]) throw new Error(`Unknown capability '${currentId}'`);
if (nextId !== currentId && (native[nextId] || definition.requests[nextId])) {
throw new Error(`Request or capability '${nextId}' already exists`);
}
const rewrite = (template: string) => {
const references = collectTemplateReferences(template)
.filter((entry) => entry.name === currentId && ["native", "data", "status"].includes(entry.kind))
.toSorted((left, right) => right.from - left.from);
let result = template;
for (const reference of references) {
const original = template.slice(reference.from, reference.to);
let from = reference.from;
let replacement = nextId;
if (original.startsWith('"')) replacement = JSON.stringify(nextId);
else if (original.startsWith("'")) replacement = `'${nextId}'`;
else if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(nextId)) {
if (template[from - 1] !== ".") throw new Error("Use bracket notation in code before renaming this reference.");
if (template[from - 2] !== "?") from -= 1;
replacement = `[${JSON.stringify(nextId)}]`;
}
result = result.slice(0, from) + replacement + result.slice(reference.to);
}
return result;
};
return customWidgetDefinitionSchema.parse({
...definition,
template: rewrite(definition.template),
extensions: {
...definition.extensions,
native: Object.fromEntries(Object.entries(native).map(([id, entry]) => [id === currentId ? nextId : id, entry])),
fragments:
definition.extensions?.fragments &&
Object.fromEntries(
Object.entries(definition.extensions.fragments).map(([id, template]) => [id, rewrite(template)]),
),
},
});
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { Button, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "../_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { NativeDescriptorEditor } from "./native-descriptor-editor";
import type { NativePreviewProps } from "./native-request-test";
export function NativeCapabilityPicker({
form,
selected,
disabled,
onSelect,
...preview
}: NativePreviewProps & {
form: CustomWidgetWorkbenchForm;
selected?: string;
disabled: boolean;
onSelect?: (id: string) => void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const raw = useCustomWidgetFormDocumentField("extensions");
const rawOptions = useCustomWidgetFormDocumentField("options");
const [capability, setCapability] = useState<string | null>("beszel.systems");
const [editingId, setEditingId] = useState<string>();
const catalog = clientApi.customWidget.nativeCapabilities.useQuery();
const parsed = parseJson(raw || "{}");
const parsedOptions = parseJson(rawOptions);
const extensions = isRecord(parsed) ? parsed : {};
const native = isRecord(extensions.native) ? extensions.native : {};
const options = isRecord(parsedOptions) ? parsedOptions : {};
const add = () => {
const contract = catalog.data?.find((entry) => entry.id === capability);
if (!contract || !isRecord(parsedOptions)) return;
const base = contract.id.replaceAll(".", "-");
let id = base;
let counter = 2;
const requests = parseJson(form.values.requests);
if (!isRecord(requests)) return;
while (Object.hasOwn(native, id) || Object.hasOwn(requests, id) || Object.hasOwn(options, `${id}Integration`))
id = `${base}-${counter++}`;
const integrationOption = `${id}Integration`;
const values: Record<string, unknown> = {};
const inputSchema = contract.inputSchema;
for (const key of inputSchema.required ?? []) values[key] = { $param: key };
let trigger = "load";
if (contract.kind === "action" || Object.keys(values).length) trigger = "manual";
const descriptor = {
capability: contract.id,
kind: contract.kind,
trigger,
input: values,
permission: contract.kind === "action" ? "modify" : "view",
...(contract.integration ? { integrationOption } : {}),
...(contract.kind === "action"
? {
confirmation: {
title: t("nativeConfirmationTitle"),
message: t("nativeConfirmationMessage", { capability: contract.id }),
},
}
: {}),
};
store.transaction(() => {
form.setFieldValue(
"extensions",
JSON.stringify({ ...extensions, native: { ...native, [id]: descriptor } }, null, 2),
);
if (contract.integration)
form.setFieldValue(
"options",
JSON.stringify(
{
...options,
[integrationOption]: {
label: t("nativeIntegrationLabel", { capability: contract.id }),
control: "integration",
default: "",
integrationKinds: contract.integrationKinds,
},
},
null,
2,
),
);
});
setEditingId(id);
onSelect?.(`native:${id}`);
};
let selectedId = editingId;
if (selected?.startsWith("native:")) selectedId = selected.slice(7);
if (!selectedId || !Object.hasOwn(native, selectedId)) selectedId = Object.keys(native)[0];
const entries = Object.entries(native).filter(([id]) => id === selectedId);
return (
<Stack gap="sm">
<Select
searchable
label={t("chooseCapability")}
value={capability}
onChange={setCapability}
data={(catalog.data ?? []).map((entry) => ({ value: entry.id, label: entry.id }))}
error={catalog.error?.message}
/>
<Button onClick={add} disabled={disabled || !capability || !isRecord(parsedOptions)}>
{t("addCapability")}
</Button>
{!selected?.startsWith("native:") && Object.keys(native).length > 0 && (
<Select
label={t("nativeId")}
searchable
data={Object.keys(native)}
value={selectedId ?? null}
disabled={disabled}
onChange={(id) => {
if (id) {
setEditingId(id);
onSelect?.(`native:${id}`);
}
}}
/>
)}
{entries.map(([id, entry]) => {
if (!isRecord(entry)) return null;
const contract = catalog.data?.find((candidate) => candidate.id === entry.capability);
return (
<NativeDescriptorEditor
key={id}
form={form}
id={id}
entry={entry}
options={options}
schema={contract?.inputSchema}
disabled={disabled}
onSelect={(nextId) => {
setEditingId(nextId.slice(7));
onSelect?.(nextId);
}}
{...preview}
/>
);
})}
</Stack>
);
}
@@ -0,0 +1,97 @@
"use client";
import { Select, Stack, TextInput } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
import { isRecord } from "../_custom-widget-form-utils";
import { isCompatibleNativeOption, nativeLiteralDefault } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
import { NativeLiteralInput } from "./native-literal-input";
export function NativeInputField({
name,
schema,
required,
value,
options,
disabled,
onChange,
}: {
name: string;
schema: NativeInputSchema;
required: boolean;
value: unknown;
options: Record<string, unknown>;
disabled: boolean;
onChange: (value: unknown) => void;
}) {
const t = useI18n("customWidget.flow");
let mode = "literal";
if (value === undefined && !required) mode = "default";
if (isRecord(value) && typeof value.$option === "string") mode = "option";
if (isRecord(value) && typeof value.$param === "string") mode = "param";
const choices = [
{ value: "literal", label: t("nativeLiteral") },
{ value: "option", label: t("nativeOptionBinding") },
];
if (schema.type !== "array" && schema.type !== "object")
choices.push({ value: "param", label: t("nativeParamBinding") });
if (!required) choices.unshift({ value: "default", label: t("nativeUseDefault") });
const optionChoices = Object.entries(options)
.filter(([, option]) => isCompatibleNativeOption(option, schema))
.map(([id, option]) => ({
value: id,
label: isRecord(option) && typeof option.label === "string" ? `${option.label} (${id})` : id,
}));
const reference = isRecord(value) ? value : {};
return (
<Stack gap="xs">
<Select
label={name}
required={required}
value={mode}
data={choices}
disabled={disabled}
onChange={(next) => {
if (next === "default") onChange(undefined);
if (next === "literal") onChange(nativeLiteralDefault(schema));
if (next === "option") onChange({ $option: optionChoices[0]?.value ?? "" });
if (next === "param") onChange({ $param: name });
}}
/>
{mode === "literal" && (
<NativeLiteralInput
label={t("nativeInputValue", { name })}
schema={schema}
value={value}
onChange={onChange}
required={required}
disabled={disabled}
/>
)}
{mode === "option" && (
<Select
label={t("nativeOptionBinding")}
searchable
data={optionChoices}
disabled={disabled}
value={typeof reference.$option === "string" ? reference.$option : null}
error={!optionChoices.some((option) => option.value === reference.$option) && t("nativeMissingOption")}
onChange={(id) => {
if (id) onChange({ $option: id });
}}
/>
)}
{mode === "param" && (
<TextInput
label={t("nativeParameterName")}
disabled={disabled}
value={String(reference.$param ?? "")}
description={t("nativeParameterHelp")}
error={!customWidgetIdentifierSchema.safeParse(reference.$param).success && t("nativeInvalidId")}
onChange={(event) => onChange({ $param: event.currentTarget.value })}
/>
)}
</Stack>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { Checkbox, MultiSelect, NumberInput, Select, TagsInput, Textarea, TextInput } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { isRecord } from "../_custom-widget-form-utils";
import type { NativeInputSchema } from "./native-editor-model";
export interface NativeLiteralInputProps {
label: string;
schema: NativeInputSchema;
value: unknown;
onChange: (value: unknown) => void;
disabled?: boolean;
required?: boolean;
}
/** Scalar and list inputs stay visual; only complex nested values need JSON. */
export function NativeLiteralInput({ schema, value, onChange, ...props }: NativeLiteralInputProps) {
const common = { ...props, description: typeof schema.description === "string" ? schema.description : undefined };
if (schema.type === "boolean") {
return (
<Checkbox {...common} checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} />
);
}
if (Array.isArray(schema.enum) && schema.enum.every((entry) => typeof entry === "string")) {
return (
<Select
{...common}
data={schema.enum as string[]}
value={typeof value === "string" ? value : null}
onChange={(next) => {
if (next !== null) onChange(next);
}}
/>
);
}
if (schema.type === "number" || schema.type === "integer") {
return (
<NumberInput
{...common}
value={typeof value === "number" || typeof value === "string" ? value : ""}
min={typeof schema.minimum === "number" ? schema.minimum : undefined}
max={typeof schema.maximum === "number" ? schema.maximum : undefined}
allowDecimal={schema.type !== "integer"}
clampBehavior="blur"
onChange={onChange}
/>
);
}
if (schema.type === "string") {
return (
<TextInput
{...common}
value={typeof value === "string" ? value : ""}
maxLength={typeof schema.maxLength === "number" ? schema.maxLength : undefined}
onChange={(event) => onChange(event.currentTarget.value)}
/>
);
}
const items = isRecord(schema.items) ? schema.items : {};
if (schema.type === "array" && items.type === "string") {
const values = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
if (Array.isArray(items.enum) && items.enum.every((entry) => typeof entry === "string")) {
return <MultiSelect {...common} data={items.enum as string[]} value={values} onChange={onChange} />;
}
return <TagsInput {...common} value={values} onChange={onChange} />;
}
return <NativeJsonInput {...props} value={value} onChange={onChange} />;
}
function NativeJsonInput({ label, value, onChange, disabled, required }: Omit<NativeLiteralInputProps, "schema">) {
const t = useI18n("customWidget.flow");
const serialized = JSON.stringify(value ?? null, null, 2);
const [text, setText] = useState(serialized);
const [error, setError] = useState(false);
useEffect(() => {
setText(serialized);
setError(false);
}, [serialized]);
return (
<Textarea
data-workbench-local-input={error || undefined}
label={label}
description={t("nativeNestedJson")}
value={text}
disabled={disabled}
required={required}
autosize
minRows={3}
error={error && t("nativeInvalidJson")}
onChange={(event) => {
const next = event.currentTarget.value;
setText(next);
try {
const parsed: unknown = JSON.parse(next);
onChange(parsed);
setError(false);
} catch {
setError(true);
}
}}
/>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { Alert, Button, Code, Fieldset, Select, Stack, Text } from "@mantine/core";
import { fetchApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { nativeLiteralDefault, nativeParameterFields } from "./native-editor-model";
import type { NativeInputSchema } from "./native-editor-model";
import { NativeLiteralInput } from "./native-literal-input";
export interface NativePreviewProps {
previewSessionId?: string;
previewStale?: boolean;
previewLiveActions?: boolean;
}
export function NativeRequestTest({
id,
input,
schema,
action,
previewSessionId,
previewStale,
previewLiveActions,
}: NativePreviewProps & {
id: string;
input: unknown;
schema: NativeInputSchema;
action: boolean;
}) {
const t = useI18n("customWidget.flow");
const fields = nativeParameterFields(input, schema);
const [values, setValues] = useState<Record<string, unknown>>({});
const [types, setTypes] = useState<Record<string, string>>({});
const [running, setRunning] = useState(false);
const pending = useRef(false);
const [result, setResult] = useState<{
context: string;
error?: string;
data?: unknown;
status?: number;
simulated?: boolean;
}>();
const responseText = useMemo(() => JSON.stringify(result?.data, null, 2) ?? "", [result?.data]);
const params: Record<string, string | number | boolean> = {};
let valid = true;
const controls = fields.map(({ name, schema: fieldSchema }) => {
const type = types[name] ?? fieldSchema.type;
const primitive = ["string", "number", "integer", "boolean"].includes(String(type));
const needsType = !["string", "number", "integer", "boolean"].includes(String(fieldSchema.type));
const selectedSchema = primitive ? { ...fieldSchema, type } : { type: "string" };
const value = values[name] ?? nativeLiteralDefault(selectedSchema);
if (
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value))
)
params[name] = value;
else valid = false;
if ((type === "integer" || type === "number") && typeof value !== "number") valid = false;
return (
<Stack gap="xs" key={name}>
{needsType && (
<Select
label={t("nativeParameterType", { name })}
value={types[name] ?? "string"}
data={["string", "number", "boolean"]}
onChange={(next) => {
if (!next) return;
setTypes((current) => ({ ...current, [name]: next }));
setValues((current) => ({ ...current, [name]: nativeLiteralDefault({ type: next }) }));
}}
/>
)}
<NativeLiteralInput
label={name}
schema={selectedSchema}
value={value}
required
disabled={running}
onChange={(next) => setValues((current) => ({ ...current, [name]: next }))}
/>
</Stack>
);
});
const context = JSON.stringify({ previewSessionId, id, input, params });
const unavailable = !previewSessionId || previewStale;
const liveAction = action && previewLiveActions;
const run = async () => {
if (!previewSessionId || previewStale || liveAction || !valid || pending.current) return;
pending.current = true;
setRunning(true);
try {
const request = { previewSessionId, nativeId: id, params };
// confirmed=false also prevents a live action if the session mode changed before dispatch.
const response = await (action
? fetchApi.customWidget.nativeAction.mutate({ ...request, confirmed: false })
: fetchApi.customWidget.nativeQuery.query(request));
setResult({
context,
data: response.data,
status: response.status,
simulated: response.simulated,
error: "error" in response ? response.error : undefined,
});
} catch (error) {
setResult({ context, error: error instanceof Error ? error.message : String(error) });
} finally {
pending.current = false;
setRunning(false);
}
};
return (
<Fieldset legend={t("nativeTestTitle")} data-workbench-local-input>
<Stack gap="sm">
{controls}
{unavailable && (
<Text size="xs" c="dimmed">
{t("nativeTestNeedsPreview")}
</Text>
)}
{liveAction && <Alert color="yellow">{t("nativeTestLiveDisabled")}</Alert>}
<Button
variant="light"
loading={running}
disabled={unavailable || liveAction || !valid}
onClick={() => void run()}
>
{action ? t("nativeTestSimulate") : t("nativeTestQuery")}
</Button>
{result && (
<Stack gap="xs">
{(result.context !== context || previewStale) && (
<Text size="xs" c="orange">
{t("nativeTestStaleResult")}
</Text>
)}
{result.error && <Alert color="red">{result.error}</Alert>}
{result.simulated && (
<Text size="sm" component="output">
{t("nativeTestSimulated")}
</Text>
)}
{result.status !== undefined && !result.simulated && (
<Text size="xs" component="output">
{t("nativeTestStatus", { status: result.status })}
</Text>
)}
{result.data !== undefined && !result.simulated && (
<Code block mah={260} style={{ overflow: "auto" }}>
{responseText.slice(0, 32768)}
</Code>
)}
{responseText.length > 32768 && (
<Text size="xs" c="dimmed">
{t("nativeTestDataTruncated")}
</Text>
)}
</Stack>
)}
</Stack>
</Fieldset>
);
}
@@ -0,0 +1,33 @@
import type { WidgetNode } from "./graph";
/** Measurements are editor state; defaults cover nodes before React Flow measures them. */
export function nodeDimensions(node: WidgetNode) {
let fallback = { width: 240, height: 140 };
if (node.data.kind === "widget") {
// The widest preview is 720px; include its border, status, header and stale-state text.
fallback = { width: 722, height: 520 };
}
if (node.data.kind === "group") fallback = { width: 600, height: 400 };
return {
width: dimension(node.measured?.width) ?? dimension(node.width) ?? dimension(node.style?.width) ?? fallback.width,
height:
dimension(node.measured?.height) ?? dimension(node.height) ?? dimension(node.style?.height) ?? fallback.height,
};
}
function dimension(value: unknown) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
return undefined;
}
export function absoluteNodePosition(node: WidgetNode | undefined, nodes: ReadonlyMap<string, WidgetNode>) {
const position = { x: 0, y: 0 };
const visited = new Set<string>();
while (node && !visited.has(node.id)) {
visited.add(node.id);
position.x += node.position.x;
position.y += node.position.y;
node = nodes.get(node.parentId ?? "");
}
return position;
}
@@ -0,0 +1,80 @@
import type { WidgetNode } from "./graph";
import { absoluteNodePosition, nodeDimensions } from "./node-geometry";
interface Position {
x: number;
y: number;
}
interface Bounds extends Position {
width: number;
height: number;
}
const gap = 32;
const coordinateLimit = 100_000;
/** Position only newly created nodes, near a selected branch or an explicit canvas drop. */
export function placeAddedNodes(
previous: WidgetNode[],
nodes: WidgetNode[],
primaryId: string,
origin: string | Position,
): Record<string, Position> {
const previousIds = new Set(previous.map((node) => node.id));
const added = nodes.filter((node) => !previousIds.has(node.id));
const primary = added.find((node) => node.id === primaryId);
if (!primary) return {};
const previousNodes = new Map(previous.map((node) => [node.id, node]));
const current = nodes.map((node) => ({
...previousNodes.get(node.id),
...node,
measured: node.measured ?? previousNodes.get(node.id)?.measured,
}));
const lookup = new Map(current.map((node) => [node.id, node]));
let preferred: Position;
if (typeof origin === "string") {
const anchor = absoluteNodePosition(lookup.get(origin), lookup);
preferred = { x: anchor.x + 300, y: anchor.y + 40 };
} else preferred = origin;
const occupied = current.filter((node) => previousIds.has(node.id)).map((node) => bounds(node, lookup));
const positions: Record<string, Position> = {};
const ordered = [primary, ...added.filter((node) => node.id !== primaryId)];
for (const node of ordered) {
let candidate = preferred;
if (node.id !== primaryId) {
const placed = positions[primaryId] ?? candidate;
candidate = { x: placed.x - 300, y: placed.y };
}
const dimensions = bounds(node, lookup);
const position = findFreePosition(candidate, dimensions, occupied);
positions[node.id] = position;
occupied.push({ ...dimensions, ...position });
}
return positions;
}
function bounds(node: WidgetNode, nodes: Map<string, WidgetNode>): Bounds {
return { ...absoluteNodePosition(node, nodes), ...nodeDimensions(node) };
}
function findFreePosition(preferred: Position, size: Bounds, occupied: Bounds[]): Position {
const clamp = (value: number) => Math.max(-coordinateLimit, Math.min(coordinateLimit, value));
const xs = [clamp(preferred.x), ...occupied.flatMap((box) => [box.x - size.width - gap, box.x + box.width + gap])];
const ys = [clamp(preferred.y), ...occupied.flatMap((box) => [box.y - size.height - gap, box.y + box.height + gap])];
ys.sort((left, right) => Math.abs(left - preferred.y) - Math.abs(right - preferred.y));
// Keep the branch's column when possible; boundary candidates also handle densely packed groups.
for (const x of new Set(xs)) {
if (Math.abs(x) > coordinateLimit) continue;
for (const y of new Set(ys)) {
if (Math.abs(y) > coordinateLimit) continue;
const overlaps = occupied.some(
(box) =>
x < box.x + box.width + gap &&
x + size.width + gap > box.x &&
y < box.y + box.height + gap &&
y + size.height + gap > box.y,
);
if (!overlaps) return { x, y };
}
}
return { x: clamp(preferred.x), y: clamp(preferred.y) };
}
@@ -0,0 +1,10 @@
.handle::after {
content: "";
position: absolute;
inset: -7px;
}
.handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 3px;
}
@@ -0,0 +1,31 @@
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
export function renameDefinitionBinding(bindings: Record<string, string>, previousId: string, nextId: string) {
const identity = bindings[previousId];
if (!identity || previousId === nextId) return bindings;
const { [previousId]: _previous, ...rest } = bindings;
return { ...rest, [nextId]: identity };
}
export function renameEditorNode(layout: CustomWidgetEditorLayout, previousId: string, nextId: string) {
const entry = layout.nodes[previousId];
if (previousId === nextId || !entry) return layout;
const { [previousId]: _previous, ...nodes } = layout.nodes;
return { ...layout, nodes: { ...nodes, [nextId]: entry } };
}
export function getNodeRenames(previous: Record<string, string>, next: Record<string, string>, prefix: string) {
const names = new Map(Object.entries(next).map(([name, identity]) => [identity, name]));
return Object.fromEntries(
Object.entries(previous).flatMap(([name, identity]) => {
const nextName = names.get(identity);
if (!nextName || nextName === name) return [];
return [[`${prefix}:${name}`, `${prefix}:${nextName}`]];
}),
);
}
export function remapWorkbenchSelection(ids: string[], renames: Readonly<Record<string, string>>) {
if (!ids.some((id) => Object.hasOwn(renames, id))) return ids;
return ids.map((id) => renames[id] ?? id);
}
@@ -0,0 +1,150 @@
"use client";
import { memo, useEffect } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { Handle, Position, useStore, useUpdateNodeInternals } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import {
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconPlayerPlay,
IconFolder,
IconPlug,
IconAdjustments,
IconNotes,
IconLayoutGrid,
} from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetNode } from "./graph";
import { connectionIssue } from "./commands";
import classes from "./workbench.module.css";
import portClasses from "./node-port.module.css";
import { FlowExecutionStatus } from "./execution";
const icons = {
source: IconApi,
query: IconDatabase,
action: IconPlayerPlay,
options: IconBraces,
widget: IconCode,
group: IconFolder,
native: IconPlug,
fragment: IconLayoutGrid,
preference: IconAdjustments,
content: IconNotes,
};
const colors = {
source: "blue",
query: "teal",
action: "orange",
options: "gray",
widget: "red",
group: "gray",
native: "cyan",
fragment: "pink",
preference: "yellow",
content: "cyan",
};
export function useValidConnectionTarget(id: string, isConnectable: boolean) {
return useStore((state) => {
let from = state.connection.fromNode;
if (!from && state.connectionClickStartHandle) {
from = state.nodeLookup.get(state.connectionClickStartHandle.nodeId) ?? null;
}
const node = state.nodeLookup.get(id);
if (!isConnectable || !from || !node || from.id === id) return false;
let source = from.id;
let target = id;
const handleType = state.connection.fromHandle?.type ?? state.connectionClickStartHandle?.type;
if (handleType === "target") {
source = id;
target = from.id;
}
return !connectionIssue({ source, target }, [from.internals.userNode, node.internals.userNode] as WidgetNode[]);
});
}
export const FlowNode = memo(function FlowNode({ id, data, selected, isConnectable }: NodeProps<WidgetNode>) {
const t = useI18n("customWidget.flow");
const compact = useStore((state) => state.transform[2] < 0.65);
const validTarget = useValidConnectionTarget(id, isConnectable);
const updateInternals = useUpdateNodeInternals();
useEffect(() => updateInternals(id), [compact, id, updateInternals]);
const Icon = icons[data.kind];
return (
<div className={classes.node} data-selected={selected} data-kind={data.kind} data-valid-target={validTarget}>
<FlowPort
id="in"
type="target"
position={Position.Left}
connectable={isConnectable}
label={`${t("input")} · ${data.label}`}
title={t("input")}
/>
<Group className="widget-node-drag" gap="xs" wrap="nowrap" p="sm">
<Icon size={18} color={`var(--mantine-color-${colors[data.kind]}-6)`} />
<Text size="sm" fw={600} truncate>
{data.label}
</Text>
</Group>
{!compact && (
<div className={classes.nodeDetails}>
<Badge size="xs" variant="light" color={colors[data.kind]}>
{t(`kind.${data.kind}`)}
</Badge>
{["query", "action", "native"].includes(data.kind) && <FlowExecutionStatus requestId={data.identifier} />}
<Text size="xs" c="dimmed" truncate mt={6}>
{data.summary}
</Text>
</div>
)}
<FlowPort
id="out"
type="source"
position={Position.Right}
connectable={isConnectable}
label={`${t("output")} · ${data.label}`}
title={t("output")}
/>
</div>
);
});
/* oxlint-disable jsx-a11y/prefer-tag-over-role -- React Flow requires its Handle div for measured connection anchors. */
export function FlowPort({
connectable,
label,
...props
}: {
id: string;
type: "source" | "target";
position: Position;
connectable: boolean;
label: string;
title: string;
}) {
let tabIndex = -1;
if (connectable) tabIndex = 0;
return (
<Handle
{...props}
className={portClasses.handle}
isConnectable={connectable}
role="button"
tabIndex={tabIndex}
aria-disabled={!connectable}
aria-label={label}
onKeyDown={(event) => {
if (!["Enter", " "].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
if (connectable && !event.repeat) event.currentTarget.click();
}}
onKeyUp={(event) => {
if (!["Enter", " "].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
}}
/>
);
}
/* oxlint-enable jsx-a11y/prefer-tag-over-role */
@@ -0,0 +1,155 @@
"use client";
import type { ReactNode } from "react";
import { ActionIcon, Badge, Button, Checkbox, Group, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch, IconSettings, IconX } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { FlowKind, WidgetNode } from "./graph";
import { WorkbenchNodeLibrary } from "./library";
import classes from "./workbench.module.css";
export function WorkbenchOutline({
nodes,
selectedIds,
selected,
search,
onSearch,
onSelect,
onClose,
onToggle,
onAdd,
onGroup,
onUngroup,
editable,
start,
}: {
nodes: WidgetNode[];
start?: ReactNode;
selectedIds: string[];
selected: string;
search: string;
onSearch(value: string): void;
onSelect(id: string): void;
onClose(): void;
onToggle(id: string): void;
editable: boolean;
onAdd(kind: FlowKind): void;
onGroup(): void;
onUngroup(): void;
}) {
const t = useI18n("customWidget.flow");
const query = search.trim().toLowerCase();
const filtered = nodes.filter((node) =>
`${node.data.label} ${node.data.identifier} ${node.data.summary} ${t(`kind.${node.data.kind}`)}`
.toLowerCase()
.includes(query),
);
const selectedNodes = nodes.filter((node) => selectedIds.includes(node.id));
return (
<nav className={classes.library} aria-label={t("library")} data-workbench-library>
{start}
<TextInput
size="xs"
data-workbench-local-input
value={search}
onChange={(event) => onSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
if (search) onSearch("");
else onClose();
}
}}
placeholder={t("search")}
aria-label={t("search")}
leftSection={<IconSearch size={14} />}
rightSection={
search && (
<ActionIcon
type="button"
size="xs"
variant="subtle"
aria-label={t("clearSearch")}
onClick={() => onSearch("")}
>
<IconX size={12} />
</ActionIcon>
)
}
/>
<WorkbenchNodeLibrary editable={editable} onAdd={onAdd} />
<Group justify="space-between" gap={4} mt="md" mb="xs">
<Text size="xs" fw={600} c="dimmed">
{t("outline")}
</Text>
<Text size="xs" c="dimmed" aria-live="polite">
{t("nodeCount", { visible: filtered.length, total: nodes.length })}
</Text>
</Group>
<Stack gap={4}>
{filtered.map((node) => (
<Group key={node.id} className={classes.outlineRow} data-nested={!!node.parentId} gap={4} wrap="nowrap">
<Checkbox
size="xs"
checked={selectedIds.includes(node.id)}
onChange={() => onToggle(node.id)}
aria-label={t("selectNode", { name: node.data.label })}
/>
<UnstyledButton
className={classes.outlineItem}
data-selected={node.id === selected}
aria-current={node.id === selected ? "true" : undefined}
title={[node.data.label, node.data.summary].filter(Boolean).join(" · ")}
onClick={() => onSelect(node.id)}
>
<Badge size="xs" variant="dot">
{t(`kind.${node.data.kind}`)}
</Badge>
<Text size="xs" truncate mt={4}>
{node.data.label}
</Text>
{node.data.summary && (
<Text size="xs" c="dimmed" truncate>
{node.data.summary}
</Text>
)}
</UnstyledButton>
</Group>
))}
</Stack>
{filtered.length === 0 && (
<Text size="xs" c="dimmed">
{t("noNodes")}
</Text>
)}
<Group mt="sm" gap={4}>
<Button
type="button"
size="compact-xs"
variant="subtle"
disabled={selectedNodes.filter((node) => node.data.kind !== "group").length < 2}
onClick={onGroup}
>
{t("group")}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
disabled={!selectedNodes.some((node) => node.data.kind === "group" || node.parentId)}
onClick={onUngroup}
>
{t("ungroup")}
</Button>
</Group>
<Button
mt="sm"
fullWidth
variant="subtle"
size="xs"
leftSection={<IconSettings size={14} />}
onClick={() => onSelect("general")}
>
{t("general")}
</Button>
</nav>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Button, CloseButton, Group, Popover, SegmentedControl, Stack, Switch, Text } from "@mantine/core";
import { IconAdjustmentsHorizontal } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { CustomWidgetPreviewSizeControl } from "../_custom-widget-preview-size-control";
import type { PreviewFixture, PreviewPanelProps } from "./preview-panel-types";
export function WorkbenchPreviewControls({
size,
onSizeChange,
inspect,
onInspectChange,
fixture,
onFixtureChange,
disabled,
}: Pick<PreviewPanelProps, "size" | "onSizeChange"> & {
inspect: boolean;
onInspectChange(value: boolean): void;
fixture: PreviewFixture;
onFixtureChange(value: PreviewFixture): void;
disabled: boolean;
}) {
const t = useI18n("customWidget.workbench.preview");
const flowT = useI18n("customWidget.flow");
return (
<Group gap="xs" justify="space-between" className="nodrag nopan nowheel">
<CustomWidgetPreviewSizeControl value={size} onChange={onSizeChange} />
<Switch
size="xs"
label={flowT("inspectPreview")}
checked={inspect}
onChange={(event) => onInspectChange(event.currentTarget.checked)}
/>
<SegmentedControl
size="xs"
value={fixture}
disabled={disabled}
onChange={(value) => onFixtureChange(value as PreviewFixture)}
data={(["live", "loading", "empty", "error"] as const).map((value) => ({
value,
label: t(`fixture.${value}`),
}))}
/>
</Group>
);
}
export function WorkbenchPreviewTools({ children, revealVersion }: { children: ReactNode; revealVersion: number }) {
const t = useI18n("customWidget.flow");
const common = useI18n("common.action");
const [opened, setOpened] = useState(false);
const [returnFocus, setReturnFocus] = useState(true);
const changeOpened = (value: boolean) => {
setReturnFocus(true);
setOpened(value);
};
useEffect(() => {
if (revealVersion > 0) {
setReturnFocus(true);
setOpened(true);
}
}, [revealVersion]);
useEffect(() => {
if (!opened) return;
const followNavigation = () => {
setReturnFocus(false);
setOpened(false);
};
window.addEventListener("homarr:widget-code-insert", followNavigation);
window.addEventListener("homarr:widget-request-inspect", followNavigation);
return () => {
window.removeEventListener("homarr:widget-code-insert", followNavigation);
window.removeEventListener("homarr:widget-request-inspect", followNavigation);
};
}, [opened]);
return (
<Popover
opened={opened}
onChange={changeOpened}
onDismiss={() => changeOpened(false)}
withinPortal
trapFocus
returnFocus={returnFocus}
position="bottom-end"
width="min(680px, calc(100vw - 24px))"
shadow="lg"
zIndex={500}
>
<Popover.Target>
<Button
type="button"
size="compact-xs"
variant="default"
className="nodrag nopan"
leftSection={<IconAdjustmentsHorizontal size={14} />}
onClick={(event) => {
event.stopPropagation();
changeOpened(!opened);
}}
>
{t("previewControls")}
</Button>
</Popover.Target>
<Popover.Dropdown
className="nodrag nopan nowheel"
p="sm"
aria-label={t("previewControls")}
style={{ maxHeight: "min(680px, calc(100dvh - 96px))", overflow: "auto" }}
onKeyDown={(event) => {
if (event.key !== "Tab") event.stopPropagation();
}}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
{t("previewControls")}
</Text>
<CloseButton data-autofocus aria-label={common("close")} onClick={() => changeOpened(false)} />
</Group>
{children}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { useWorkbenchSelection } from "./selection";
export function usePreviewExecution() {
const selection = useWorkbenchSelection();
const [activeTab, setActiveTab] = useState<string | null>("widget");
const [revealVersion, setRevealVersion] = useState(0);
const [focus, setFocus] = useState<{ selection: string | undefined; requestId?: string }>();
let requestId: string | undefined;
if (selection?.startsWith("request:") || selection?.startsWith("native:"))
requestId = selection.slice(selection.indexOf(":") + 1);
if (focus?.selection === selection) requestId = focus?.requestId;
useEffect(() => {
const show = (event: Event) => {
const detail = (event as CustomEvent<{ requestId?: unknown; panel?: unknown }>).detail;
if (typeof detail?.requestId !== "string" || !["data", "journal"].includes(String(detail.panel))) return;
setFocus({ selection, requestId: detail.requestId });
setActiveTab(String(detail.panel));
setRevealVersion((value) => value + 1);
};
window.addEventListener("homarr:widget-execution-panel", show);
return () => window.removeEventListener("homarr:widget-execution-panel", show);
}, [selection]);
return { activeTab, setActiveTab, requestId, revealVersion, showAll: () => setFocus({ selection }) };
}
@@ -0,0 +1,35 @@
import { useRef } from "react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { PreviewPanelProps } from "./preview-panel-types";
/** Keep session mode updates alive when the preview tools close. */
export function usePreviewLiveActions({ preview, onLiveActionsChange }: PreviewPanelProps) {
const t = useI18n("customWidget.workbench.preview");
const mutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const current = useRef({ sessionId: preview.session?.id, onLiveActionsChange });
current.current = { sessionId: preview.session?.id, onLiveActionsChange };
const pending = useRef(false);
const toggle = (enabled: boolean) => {
const sessionId = current.current.sessionId;
if (!sessionId || pending.current) return;
pending.current = true;
mutation.mutate(
{ sessionId, enabled },
{
onSuccess: () => {
if (current.current.sessionId === sessionId) current.current.onLiveActionsChange(enabled);
},
onError: (error) => {
if (current.current.sessionId !== sessionId) return;
showErrorNotification({ title: t("liveActions"), message: error.message });
},
onSettled: () => {
pending.current = false;
},
},
);
};
return { pending: mutation.isPending, toggle };
}
@@ -0,0 +1,15 @@
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { PreviewState } from "./preview-state";
export interface PreviewPanelProps {
candidate: HomarrCustomWidgetV2 | null;
validationIssues: Array<{ path?: string; message: string }>;
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
export type PreviewFixture = "live" | "loading" | "empty" | "error";
@@ -0,0 +1,18 @@
"use client";
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
const PreviewPresentation = createContext<"canvas" | "form">("form");
export function WorkbenchPreviewPresentationProvider({
mode,
children,
}: {
mode: "canvas" | "form";
children: ReactNode;
}) {
return <PreviewPresentation.Provider value={mode}>{children}</PreviewPresentation.Provider>;
}
export const useWorkbenchPreviewPresentation = () => useContext(PreviewPresentation);
@@ -0,0 +1,52 @@
"use client";
import { memo, useMemo } from "react";
import { Button, Group, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { CodeEditor } from "../_code-editor";
import type { PreviewState } from "./preview-state";
import { DataBindings } from "./data-bindings";
import { useWorkbenchQueryData } from "./execution";
export const PreviewRequestData = memo(function PreviewRequestData({
preview,
requestId,
onShowAll,
}: {
preview: PreviewState;
requestId?: string;
onShowAll(): void;
}) {
const t = useI18n("customWidget.flow");
const previewT = useI18n("customWidget.workbench.preview");
const execution = useWorkbenchQueryData();
const data = execution?.data ?? preview.data;
const status = execution?.status ?? preview.status;
const snapshot = useMemo(() => {
if (!requestId) return { data, status };
return { data: { [requestId]: data[requestId] }, status: { [requestId]: status[requestId] } };
}, [data, status, requestId]);
const serialized = useMemo(() => JSON.stringify(snapshot, null, 2), [snapshot]);
return (
<>
{requestId && (
<Group justify="space-between" mb="xs">
<Text size="xs" fw={600}>
{requestId}
</Text>
<Button size="compact-xs" variant="subtle" onClick={onShowAll}>
{t("allQueryData")}
</Button>
</Group>
)}
<DataBindings data={snapshot.data} />
<CodeEditor
id="preview-data"
label={previewT("requestData")}
language="json"
value={serialized}
readOnly
onChange={() => undefined}
/>
</>
);
});
@@ -0,0 +1,52 @@
/** Mirror application CSS into the preview boundary without changing its React tree. */
export function mirrorPreviewStyles(shadow: ShadowRoot) {
const copies = new Map<Element, Element>();
const anchor = document.createComment("Application preview styles");
shadow.prepend(anchor);
const sync = () => {
const originals = [...document.head.querySelectorAll('style, link[rel="stylesheet"]')].filter((element) => {
if (!(element instanceof HTMLLinkElement)) return true;
return new URL(element.href, location.href).origin === location.origin;
});
for (const [original, copy] of copies) {
if (originals.includes(original)) continue;
copy.remove();
copies.delete(original);
}
let cursor = shadow.firstChild;
for (const original of originals) {
let copy = copies.get(original);
if (!copy) {
copy = original.cloneNode(true) as Element;
copies.set(original, copy);
}
if (copy.textContent !== original.textContent) copy.textContent = original.textContent;
// oxlint-disable-next-line unicorn/no-useless-spread -- Removal changes this live NamedNodeMap during iteration.
for (const attribute of [...copy.attributes]) {
if (!original.hasAttribute(attribute.name)) copy.removeAttribute(attribute.name);
}
for (const attribute of original.attributes) {
if (copy.getAttribute(attribute.name) !== attribute.value) copy.setAttribute(attribute.name, attribute.value);
}
// Keep source order: it determines which component rules win the cascade.
if (copy !== cursor) shadow.insertBefore(copy, cursor);
cursor = copy.nextSibling;
}
};
sync();
let scheduled = 0;
const observer = new MutationObserver(() => {
if (scheduled) return;
scheduled = requestAnimationFrame(() => {
scheduled = 0;
sync();
});
});
observer.observe(document.head, { childList: true, subtree: true, characterData: true, attributes: true });
return () => {
observer.disconnect();
cancelAnimationFrame(scheduled);
for (const copy of copies.values()) copy.remove();
anchor.remove();
};
}
@@ -0,0 +1,9 @@
import type { PreviewOutcome } from "../_custom-widget-preview-status";
export interface PreviewState {
stale?: boolean;
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
}
@@ -0,0 +1,111 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Group, MantineProvider, SegmentedControl, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { mirrorPreviewStyles } from "./preview-shadow";
const PreviewScheme = createContext<"light" | "dark">("light");
const PreviewThemeControl = createContext<{ scheme: string; setScheme(value: string): void } | null>(null);
export function WorkbenchPreviewTheme({ children, controls = true }: { children: ReactNode; controls?: boolean }) {
const inheritedScheme = useComputedColorScheme("light");
const [scheme, setScheme] = useState("app");
let colorScheme = inheritedScheme;
if (scheme === "light" || scheme === "dark") colorScheme = scheme;
const control = useMemo(() => ({ scheme, setScheme }), [scheme]);
return (
<PreviewScheme.Provider value={colorScheme}>
<PreviewThemeControl.Provider value={control}>
{controls && (
<Group justify="end" mb="xs">
<WorkbenchPreviewThemeControl />
</Group>
)}
{children}
</PreviewThemeControl.Provider>
</PreviewScheme.Provider>
);
}
export function WorkbenchPreviewThemeControl() {
const t = useI18n("customWidget.flow");
const control = useContext(PreviewThemeControl);
if (!control) return null;
return (
<SegmentedControl
size="xs"
aria-label={t("previewTheme")}
value={control.scheme}
onChange={control.setScheme}
data={[
{ value: "app", label: t("themeApp") },
{ value: "light", label: t("themeLight") },
{ value: "dark", label: t("themeDark") },
]}
/>
);
}
export function WorkbenchPreviewSurface({ children }: { children: ReactNode }) {
const scheme = useContext(PreviewScheme);
const inheritedTheme = useMantineTheme();
const host = useRef<HTMLDivElement>(null);
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const id = `widget-preview-${useId().replace(/[^a-zA-Z0-9_-]/gu, "")}`;
useEffect(() => {
if (!host.current) return;
const shadow = host.current.shadowRoot ?? host.current.attachShadow({ mode: "open" });
const container = document.createElement("div");
container.className = "nokey";
container.id = id;
container.style.color = "var(--mantine-color-text)";
container.style.width = "fit-content";
container.style.maxWidth = "100%";
shadow.append(container);
const cleanup = mirrorPreviewStyles(shadow);
setRoot(container);
return () => {
cleanup();
container.remove();
};
}, [id]);
const getRootElement = useCallback(() => root ?? undefined, [root]);
const theme = useMemo(
() => ({
...inheritedTheme,
components: {
...inheritedTheme.components,
Portal: {
...inheritedTheme.components.Portal,
defaultProps: { ...inheritedTheme.components.Portal?.defaultProps, target: root },
},
},
}),
[inheritedTheme, root],
);
return (
<div
ref={host}
data-preview-surface
className="nodrag nopan nowheel nokey"
style={{ maxWidth: "100%", width: "fit-content" }}
>
{root &&
createPortal(
<MantineProvider
theme={theme}
forceColorScheme={scheme}
getRootElement={getRootElement}
cssVariablesSelector={`#${id}`}
withGlobalClasses={false}
>
<div data-preview-theme className="nokey" style={{ colorScheme: scheme }}>
{children}
</div>
</MantineProvider>,
root,
)}
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Alert, Paper } from "@mantine/core";
import { PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { WorkbenchPreviewSurface } from "./preview-theme";
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
export const workbenchPreviewWidth = (size: string) => widths[size] ?? 480;
export function WorkbenchPreviewWidget({
data,
size,
inspect,
resetKey,
}: {
data: Record<string, unknown> | null;
size: string;
inspect: boolean;
resetKey: string;
}) {
const t = useI18n("customWidget.workbench.preview");
const errors = useI18n("customWidget.editor.errorBoundary");
const actions = useI18n("common.action");
return (
<WorkbenchPreviewSurface>
<Paper withBorder p="sm" h={360} w={workbenchPreviewWidth(size)} maw="100%" style={{ overflow: "auto" }}>
{data ? (
<PreviewErrorBoundary
title={errors("title")}
description={t("containedFailures")}
retryLabel={actions("tryAgain")}
resetKeys={[resetKey]}
>
<CustomJsxDisplay
data={data}
inspect={inspect}
onInspect={(location) => {
window.dispatchEvent(new CustomEvent("homarr:widget-inspect", { detail: location }));
requestAnimationFrame(() =>
window.dispatchEvent(
new CustomEvent("homarr:widget-code-reveal", {
detail: {
editorId: location.fragment ? `fragment-${location.fragment}` : "jsx-editor",
index: location.index,
},
}),
),
);
}}
/>
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</WorkbenchPreviewSurface>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Group, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetFormAnalysisField } from "../_use-custom-widget-form-analysis";
import classes from "./workbench.module.css";
export function WorkbenchProblems({ onSelect }: { onSelect(id: string): void }) {
const [opened, setOpened] = useState(false);
const t = useI18n("customWidget.flow");
const diagnosticsT = useI18n("customWidget.editor.diagnostics");
const issues = useCustomWidgetFormAnalysisField("previewValidationIssues");
const template = useCustomWidgetFormAnalysisField("templateDiagnostics");
const requests = useCustomWidgetFormAnalysisField("requestDiagnostics");
const entries = [
...issues.map((issue) => ({ ...issue, index: undefined as number | undefined })),
...template.map((entry) => ({
path: "template",
index: entry.index,
message: diagnosticsT(entry.code, { value: entry.value ?? "" }),
})),
...requests.map((entry) => ({
path: "requests",
index: entry.index,
message: diagnosticsT(entry.code, { value: entry.value ?? "" }),
})),
];
const reveal = (path: string | undefined, index: number | undefined) => {
const [field, id, nativeId] = (path ?? "").split(".");
let nodeId = "widget";
if (field === "sources" && id) nodeId = `source:${id}`;
if (field === "requests" && id) nodeId = `request:${id}`;
if (field === "options") nodeId = "options";
if (field === "extensions" && id === "native" && nativeId) nodeId = `native:${nativeId}`;
onSelect(nodeId);
if (index !== undefined)
window.dispatchEvent(
new CustomEvent("homarr:widget-code-reveal", {
detail: { editorId: field === "requests" ? "requests-editor" : "jsx-editor", index },
}),
);
};
return (
<div className={classes.problems} style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
<Group px="xs" justify="space-between">
<Button size="compact-xs" variant="subtle" aria-expanded={opened} onClick={() => setOpened(!opened)}>
{t("problems", { count: entries.length })}
</Button>
<Text size="xs" c="dimmed" component="output">
{entries.length === 0 && t("noProblems")}
</Text>
</Group>
<Collapse expanded={opened}>
<ScrollArea mah={220}>
<Stack gap={4} p="xs">
{entries.map((entry, index) => (
<UnstyledButton key={`${entry.path}:${index}`} onClick={() => reveal(entry.path, entry.index)}>
<Text size="xs" c="red">
{entry.path}: {entry.message}
</Text>
</UnstyledButton>
))}
</Stack>
</ScrollArea>
</Collapse>
</div>
);
}
@@ -0,0 +1,164 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { Alert, Button, Group, Switch } from "@mantine/core";
import { customWidgetEditorLayoutSchema } from "@homarr/custom-widgets/core";
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { buildDefinition } from "../_custom-widget-form-utils";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { withoutCredentials } from "./history";
import type { EditorSnapshot } from "./history";
type RecoveryDraft = Pick<EditorSnapshot, "content" | "layout">;
interface RecoveryState {
key: string;
enabled: boolean;
recovered: RecoveryDraft | null;
}
interface RecoveryContextValue {
enabled: boolean;
available: boolean;
setEnabled(value: boolean): void;
restore(): void;
discard(): void;
}
const RecoveryContext = createContext<RecoveryContextValue | null>(null);
/** One document observer serves the always-visible alert and contextual settings. */
export function DraftRecoveryProvider({
form,
definitionId,
children,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
children: ReactNode;
}) {
const store = useCustomWidgetFormDocumentStore();
const key = `homarr:custom-widget:draft:${definitionId ?? "new"}`;
const [state, setState] = useState<RecoveryState>({ key: "", enabled: false, recovered: null });
useEffect(() => {
let enabled = false;
let recovered: RecoveryDraft | null = null;
try {
enabled = localStorage.getItem("homarr:custom-widget:recovery-enabled") === "true";
const raw = localStorage.getItem(key);
if (raw && raw.length <= 1_000_000) {
const saved = JSON.parse(raw) as EditorSnapshot;
const values = customWidgetFormSchema.parse({ ...saved.content, secrets: [] });
recovered = { content: withoutCredentials(values), layout: customWidgetEditorLayoutSchema.parse(saved.layout) };
}
} catch {
/* Storage may be disabled or contain an obsolete draft. */
}
setState({ key, enabled, recovered });
}, [key]);
useEffect(() => {
// Wait for this document's storage read before removing or replacing its draft.
if (state.key !== key || !state.enabled || state.recovered) return;
let timeout: ReturnType<typeof setTimeout>;
const writeSnapshot = () => {
try {
if (!store.getDirty()) {
localStorage.removeItem(key);
return;
}
const content = withoutCredentials(store.getValues());
// Validate credential-bearing fields even while the JSX draft has errors.
if (!buildDefinition({ ...content, template: "<Text />", secrets: [] }).success) return;
localStorage.setItem(key, JSON.stringify({ content, layout: store.getLayout() }));
} catch {
/* Recovery is optional; unavailable storage must not block editing. */
}
};
const persist = () => {
clearTimeout(timeout);
timeout = setTimeout(writeSnapshot, 800);
};
const unsubscribe = store.subscribeEditor(persist);
writeSnapshot();
return () => {
clearTimeout(timeout);
unsubscribe();
};
}, [state.enabled, state.key, state.recovered, key, store]);
const value = useMemo<RecoveryContextValue>(
() => ({
enabled: state.key === key && state.enabled,
available: state.key === key && state.recovered !== null,
setEnabled(enabled) {
setState((current) => ({ ...current, enabled }));
try {
localStorage.setItem("homarr:custom-widget:recovery-enabled", String(enabled));
if (!enabled) localStorage.removeItem(key);
} catch {
/* Recovery remains optional when storage is unavailable. */
}
},
restore() {
if (state.key !== key || !state.recovered) return;
const recovered = state.recovered;
store.transaction(() => {
form.setValues({ ...recovered.content, secrets: store.getValues().secrets });
store.setLayout(recovered.layout);
});
setState((current) => ({ ...current, recovered: null }));
},
discard() {
try {
localStorage.removeItem(key);
} catch {
/* Recovery remains optional when storage is unavailable. */
}
setState((current) => ({ ...current, recovered: null }));
},
}),
[form, key, state, store],
);
return <RecoveryContext.Provider value={value}>{children}</RecoveryContext.Provider>;
}
export function DraftRecoverySettings() {
const recovery = useContext(RecoveryContext);
const t = useI18n("customWidget.flow");
if (!recovery) return null;
return (
<Switch
size="xs"
label={t("recovery")}
checked={recovery.enabled}
onChange={(event) => recovery.setEnabled(event.currentTarget.checked)}
/>
);
}
export function DraftRecoveryAlert() {
const recovery = useContext(RecoveryContext);
const t = useI18n("customWidget.flow");
if (!recovery?.available) return null;
return (
<Alert title={t("recoverTitle")} color="blue" role="alert">
<Group>
<Button size="xs" onClick={(event) => completeRecoveryAction(event.currentTarget, recovery.restore)}>
{t("recover")}
</Button>
<Button
size="xs"
variant="subtle"
onClick={(event) => completeRecoveryAction(event.currentTarget, recovery.discard)}
>
{t("discardRecovery")}
</Button>
</Group>
</Alert>
);
}
function completeRecoveryAction(button: HTMLButtonElement, action: () => void) {
const workbench = button.closest<HTMLElement>("[data-widget-workbench]");
action();
// The alert unmounts after either action; keep keyboard Undo in the workbench.
workbench?.focus({ preventScroll: true });
}
@@ -0,0 +1,52 @@
"use client";
import { memo, useMemo } from "react";
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { PreviewState } from "./preview-state";
import { LazyOnceAccordion } from "../_lazy-once-accordion";
import { DataBindings } from "./data-bindings";
import { FlowExecutionStatus, useWorkbenchQuery, useWorkbenchRequestStatus } from "./execution";
import { openExecutionPanel, WorkbenchRequestJournal } from "./request-journal";
export const RequestExecutionInspector = memo(function RequestExecutionInspector({
requestId,
preview,
}: {
requestId: string;
preview: PreviewState;
}) {
const t = useI18n("customWidget.flow");
const status = useWorkbenchRequestStatus(requestId);
const query = useWorkbenchQuery(requestId);
let value = preview.data[requestId];
if (query) value = query.data;
const data = useMemo(() => ({ [requestId]: value }), [value, requestId]);
const hasData = query?.hasData ?? Object.hasOwn(preview.data, requestId);
return (
<Stack gap="xs" mb="sm">
<Group justify="space-between">
<FlowExecutionStatus requestId={requestId} />
<Button size="compact-xs" variant="subtle" onClick={() => openExecutionPanel(requestId, "data")}>
{t("viewQueryData")}
</Button>
</Group>
{status.error && (
<Alert color="red" p="xs">
{status.error}
</Alert>
)}
<LazyOnceAccordion label={t("latestResponse")}>
{hasData ? (
<DataBindings data={data} />
) : (
<Text size="xs" c="dimmed">
{t("execution.noData")}
</Text>
)}
</LazyOnceAccordion>
<LazyOnceAccordion label={t("requestJournal")}>
<WorkbenchRequestJournal requestId={requestId} />
</LazyOnceAccordion>
</Stack>
);
});
@@ -0,0 +1,82 @@
"use client";
import { Badge, Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { useWorkbenchJournal } from "./execution";
import { executionRequestIdentifier } from "./execution-store";
import { useWorkbenchSelection } from "./selection";
export function inspectExecutedRequest(requestId: string) {
window.dispatchEvent(new CustomEvent("homarr:widget-request-inspect", { detail: { requestId, field: "path" } }));
}
export function openExecutionPanel(requestId: string, panel: "data" | "journal") {
window.dispatchEvent(new CustomEvent("homarr:widget-execution-panel", { detail: { requestId, panel } }));
}
export function WorkbenchRequestJournal({ requestId }: { requestId?: string }) {
const t = useI18n("customWidget.flow");
const selection = useWorkbenchSelection();
const journal = useWorkbenchJournal();
const entries = journal.filter((entry) => !requestId || executionRequestIdentifier(entry.requestId) === requestId);
return (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t("requestJournal")}
</Text>
{requestId && (
<Button size="compact-xs" variant="subtle" onClick={() => openExecutionPanel(requestId, "journal")}>
{t("viewJournal")}
</Button>
)}
</Group>
{entries.length === 0 && (
<Text size="xs" c="dimmed">
{t("execution.noJournal")}
</Text>
)}
{entries.map((entry) => {
const identifier = executionRequestIdentifier(entry.requestId);
let label = String(entry.status ?? t("execution.error"));
let color = "teal";
if (entry.status === null || entry.status >= 400) color = "red";
if (entry.simulated) {
label = t("execution.simulated");
color = "grape";
}
const selected = selection === `request:${identifier}` || selection === `native:${identifier}`;
return (
<UnstyledButton
key={entry.id}
p="xs"
aria-label={t("inspectRequest", { name: identifier })}
style={{
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-sm)",
background: selected ? "var(--mantine-primary-color-light)" : undefined,
}}
onClick={() => inspectExecutedRequest(identifier)}
>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={600} truncate>
{identifier}
</Text>
<Badge size="xs" color={color}>
{label}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
{entry.method} {entry.path}
</Text>
<Group gap="xs" mt={4}>
<Text size="xs" c="dimmed">
{Math.round(entry.durationMs)} ms
</Text>
<Text component="time" dateTime={new Date(entry.timestamp).toISOString()} size="xs" c="dimmed">
{new Date(entry.timestamp).toLocaleTimeString()}
</Text>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useRef, useState } from "react";
import type { PointerEvent } from "react";
import classes from "./workbench.module.css";
/** Feature-local separator: resizing changes a CSS variable, never the widget document. */
export function WorkbenchResizeHandle({
name,
variable,
direction,
initial,
minimum,
maximum,
reverse = false,
}: {
name: string;
variable: string;
direction: "horizontal" | "vertical";
initial: number;
minimum: number;
maximum: number;
reverse?: boolean;
}) {
const [value, setValue] = useState(initial);
const drag = useRef<{ coordinate: number; size: number } | null>(null);
const apply = (element: HTMLElement, size: number) => {
const next = Math.min(maximum, Math.max(minimum, Math.round(size)));
element.closest<HTMLElement>("[data-widget-workbench]")?.style.setProperty(variable, `${next}px`);
setValue(next);
};
const coordinate = (event: PointerEvent) => (direction === "horizontal" ? event.clientX : event.clientY);
return (
<div
className={classes.resizeHandle}
data-direction={direction}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Adjustable window splitters require a focusable separator role.
role="separator"
tabIndex={0}
aria-label={name}
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
aria-valuenow={value}
aria-valuemin={minimum}
aria-valuemax={maximum}
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { coordinate: coordinate(event), size: value };
}}
onPointerMove={(event) => {
if (!drag.current) return;
let delta = coordinate(event) - drag.current.coordinate;
if (reverse) delta *= -1;
apply(event.currentTarget, drag.current.size + delta);
}}
onPointerUp={() => {
drag.current = null;
}}
onPointerCancel={() => {
drag.current = null;
}}
onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
let delta = 20;
if (["ArrowLeft", "ArrowUp"].includes(event.key)) delta *= -1;
if (reverse) delta *= -1;
let next = value + delta;
if (event.key === "Home") next = minimum;
if (event.key === "End") next = maximum;
apply(event.currentTarget, next);
}}
/>
);
}
@@ -0,0 +1,10 @@
/** Popup navigation may remove the original opener; keep keyboard focus in the workbench. */
export function restoreWorkbenchFocus(workspace: HTMLElement | null, previous: HTMLElement | null, selected: string) {
const node = workspace?.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(selected)}"]`);
for (const target of [previous, node, workspace]) {
if (!target?.isConnected || !target.checkVisibility() || target.closest("[inert]")) continue;
target.focus({ preventScroll: true });
if (document.activeElement === target) return target;
}
return null;
}
@@ -0,0 +1,22 @@
"use client";
import { createContext, useContext, useEffect } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { CustomWidgetFormDocumentStore } from "./document-store";
import { remapWorkbenchSelection } from "./node-renames";
export const WorkbenchSelectionContext = createContext<string | undefined>(undefined);
export const useWorkbenchSelection = () => useContext(WorkbenchSelectionContext);
export function useWorkbenchRenames(
store: CustomWidgetFormDocumentStore,
setSelected: Dispatch<SetStateAction<string>>,
setSelection: Dispatch<SetStateAction<string[]>>,
) {
useEffect(
() =>
store.onNodeRenames((renames) => {
setSelected((id) => renames[id] ?? id);
setSelection((ids) => remapWorkbenchSelection(ids, renames));
}),
[store, setSelected, setSelection],
);
}
@@ -0,0 +1,52 @@
"use client";
import { Button, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
function suggestedScope(baseUrl: string): CustomWidgetSource["networkScope"] | undefined {
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.startsWith("127.") ||
hostname === "[::1]"
)
return "loopback";
if (
/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/u.test(hostname) ||
/^\[(?:fc|fd)/u.test(hostname) ||
(!hostname.includes(".") && !hostname.startsWith("[")) ||
hostname.endsWith(".local") ||
hostname.endsWith(".lan")
)
return "private";
} catch {
/* URL validation is shown by the source field. */
}
return undefined;
}
export function SourceNetworkHint({
source,
onChange,
}: {
source: CustomWidgetSource;
onChange(scope: CustomWidgetSource["networkScope"]): void;
}) {
const t = useI18n("customWidget.flow");
const suggestion = suggestedScope(source.baseUrl);
return (
<Stack gap={4}>
<Text size="xs" c="dimmed">
{t("serverFetchHint")}
</Text>
{suggestion && suggestion !== source.networkScope && (
<Button size="compact-xs" variant="light" onClick={() => onChange(suggestion)}>
{t("suggestNetworkScope", { scope: suggestion })}
</Button>
)}
</Stack>
);
}
@@ -0,0 +1,10 @@
/** Starter JSX uses named references so its initial graph already explains the data flow. */
export function createHttpStarterTemplate(requestId: string, title: string, setupHint: string) {
const request = JSON.stringify(requestId);
const data = `data[${request}]`;
const status = `status[${request}]`;
return `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={600}>{${JSON.stringify(title)}}</Text><RefreshButton /></Group>
{${status}?.loading ? <Skeleton height={100} /> : ${status}?.error ? <Alert color="red">{${status}.error}</Alert> : (${data} ?? null) === null ? <Text c="dimmed">{${JSON.stringify(setupHint)}}</Text> : <Code block>{JSON.stringify(${data})}</Code>}
</Stack>`;
}
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { Button, Stack, Text } from "@mantine/core";
import {
IconAppWindow,
IconCode,
IconChevronDown,
IconExternalLink,
IconPlugConnected,
IconSparkles,
IconWorld,
} from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useConfirmModal } from "@homarr/modals";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import { createFlowCommands } from "./commands";
import { createHttpStarterTemplate } from "./start-templates";
const choiceStyles = {
root: { height: "auto", padding: "8px 10px" },
label: { whiteSpace: "normal", textAlign: "left" },
} as const;
export function WorkbenchStart({
form,
onSelect,
onAssistant,
onIntegration,
}: {
form: CustomWidgetWorkbenchForm;
onSelect(id: string): void;
onAssistant(): void;
onIntegration(): void;
}) {
const store = useCustomWidgetFormDocumentStore();
const [opened, setOpened] = useState(() => !store.getDirty());
const t = useI18n("customWidget.flow");
const { openConfirmModal } = useConfirmModal();
const start = (http: boolean, after?: () => void) => {
const apply = () => {
store.transaction(() => {
form.setValues({
...store.getValues(),
sources: "{}",
requests: "{}",
options: "{}",
extensions: "",
secrets: [],
template:
'<Stack align="center" justify="center" h="100%"><Text size="xl" fw={600}>{' +
JSON.stringify(t("welcomeTemplate")) +
"}</Text></Stack>",
});
store.setLayout({ version: 1, nodes: {}, groups: {} });
if (http) {
const id = createFlowCommands(form, store).add("query");
form.setFieldValue(
"template",
createHttpStarterTemplate(id.replace(/^request:/u, ""), t("httpResponse"), t("httpSetupHint")),
);
onSelect("source:default");
} else onSelect("widget");
after?.();
});
setOpened(false);
};
if (!store.getDirty()) {
apply();
return;
}
openConfirmModal({ title: t("startOver"), children: t("startOverDescription"), onConfirm: apply });
};
const embed = () =>
start(false, () => {
store.transaction(() => {
form.setFieldValue("extensions", "{}");
form.setFieldValue(
"options",
JSON.stringify({ appUrl: { control: "url", label: t("embedUrl"), default: "" } }, null, 2),
);
form.setFieldValue(
"template",
"{options.appUrl ? <AppEmbed src={options.appUrl} title=" +
JSON.stringify(t("embeddedApp")) +
" /> : <Alert>{" +
JSON.stringify(t("configureEmbed")) +
"}</Alert>}",
);
});
onSelect("options");
});
const choices = [
{
key: "describe",
label: "describe",
icon: IconSparkles,
onClick: () => {
setOpened(false);
onAssistant();
},
},
{
key: "integration",
label: "useIntegration",
icon: IconPlugConnected,
onClick: () =>
start(false, () => {
onSelect("native:new");
onIntegration();
}),
},
{ key: "http", label: "connectHttp", icon: IconWorld, onClick: () => start(true) },
{ key: "static", label: "withoutData", icon: IconCode, onClick: () => start(false) },
{ key: "embed", label: "embedApp", icon: IconAppWindow, onClick: embed },
] as const;
return (
<Stack gap="xs" mb="md">
<Button
type="button"
variant="subtle"
size="compact-xs"
justify="start"
aria-expanded={opened}
onClick={() => setOpened((current) => !current)}
rightSection={<IconChevronDown size={14} style={{ transform: opened ? "rotate(180deg)" : undefined }} />}
>
{t("startingPoints")}
</Button>
{opened && (
<Stack gap={6}>
{choices.map(({ key, label, icon: Icon, onClick }) => (
<Button
key={key}
type="button"
size="xs"
variant="default"
fullWidth
justify="start"
styles={choiceStyles}
leftSection={<Icon size={18} />}
onClick={onClick}
>
<span>
<Text component="span" display="block" size="xs" fw={600}>
{t(label)}
</Text>
<Text component="span" display="block" size="xs" fw={400} c="dimmed">
{t(`startDescription.${key}`)}
</Text>
</span>
</Button>
))}
<Button
size="xs"
variant="default"
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpened(false)}
fullWidth
justify="start"
styles={choiceStyles}
leftSection={<IconExternalLink size={18} />}
>
<span>
<Text component="span" display="block" size="xs" fw={600}>
{t("browseWorkshop")}
</Text>
<Text component="span" display="block" size="xs" fw={400} c="dimmed">
{t("startDescription.workshop")}
</Text>
</span>
</Button>
</Stack>
)}
</Stack>
);
}
@@ -0,0 +1,128 @@
"use client";
import dynamic from "next/dynamic";
import { useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
import {
IconArrowBackUp,
IconArrowForwardUp,
IconDots,
IconForms,
IconMaximize,
IconMinimize,
IconPlus,
IconRestore,
IconSparkles,
} from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useConfirmModal } from "@homarr/modals";
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import classes from "./workbench.module.css";
import { WorkbenchFrameControls } from "./frame-controls";
const WorkbenchWorkshop = dynamic(() => import("./workshop").then((module) => module.WorkbenchWorkshop), {
ssr: false,
});
export function WorkbenchToolbar({
actions,
definitionId,
mode,
setMode,
libraryOpened,
onToggleLibrary,
onAssistant,
onGeneral,
fullscreen,
onToggleFullscreen,
}: {
actions: ReactNode;
definitionId?: string;
mode: string;
setMode(value: string): void;
libraryOpened: boolean;
onToggleLibrary(): void;
onAssistant(): void;
onGeneral(): void;
fullscreen: boolean;
onToggleFullscreen(): void;
}) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
useSyncExternalStore(store.subscribeEditor, store.getRevision, store.getRevision);
const { openConfirmModal } = useConfirmModal();
const reset = () => openConfirmModal({ title: t("reset"), children: t("resetDescription"), onConfirm: store.reset });
let viewLabel = t("form");
let nextMode = "form";
if (mode !== "flow") {
viewLabel = t("canvas");
nextMode = "flow";
}
let fullscreenLabel = t("enterFullscreen");
let FullscreenIcon = IconMaximize;
if (fullscreen) {
fullscreenLabel = t("exitFullscreen");
FullscreenIcon = IconMinimize;
}
return (
<Group className={classes.toolbar} justify="space-between" gap="sm">
<WorkbenchFrameControls onGeneral={onGeneral} />
<Group gap={6}>
<Tooltip label={t("undo")}>
<ActionIcon
type="button"
variant="default"
aria-label={t("undo")}
disabled={!store.canUndo()}
onClick={store.undo}
>
<IconArrowBackUp size={17} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("redo")}>
<ActionIcon
type="button"
variant="default"
aria-label={t("redo")}
disabled={!store.canRedo()}
onClick={store.redo}
>
<IconArrowForwardUp size={17} />
</ActionIcon>
</Tooltip>
<Button
size="compact-sm"
variant="default"
leftSection={<IconPlus size={16} />}
aria-expanded={libraryOpened}
data-workbench-library-toggle
onClick={onToggleLibrary}
>
{t("add")}
</Button>
<Button size="compact-sm" variant="subtle" leftSection={<IconSparkles size={16} />} onClick={onAssistant}>
{t("assistant")}
</Button>
<WorkbenchWorkshop definitionId={definitionId} />
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon type="button" variant="subtle" aria-label={t("moreOptions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconForms size={16} />} onClick={() => setMode(nextMode)}>
{viewLabel}
</Menu.Item>
<Menu.Item leftSection={<FullscreenIcon size={16} />} onClick={onToggleFullscreen}>
{fullscreenLabel}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconRestore size={16} />} onClick={reset}>
{t("reset")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
{actions}
</Group>
);
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
import type { PreviewState } from "./preview-state";
export interface FlowWorkbenchProps {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
actions: ReactNode;
feedback?: ReactNode;
onPreview(): Promise<unknown>;
general: ReactNode;
sources: ReactNode;
requests: ReactNode;
options: ReactNode;
template: ReactNode;
assistant: ReactNode;
advanced: ReactNode;
preview: ReactNode;
execution: PreviewState;
}
@@ -0,0 +1,253 @@
.workbench {
position: fixed;
inset-block-start: var(--app-shell-header-offset, 0px);
inset-block-end: 0;
inset-inline-start: var(--app-shell-navbar-offset, 0px);
inset-inline-end: 0;
z-index: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--mantine-color-body);
container: widget-workbench / inline-size;
--workbench-inspector-width: 420px;
}
.workbench[data-fullscreen="true"] {
inset: 0;
height: 100dvh;
z-index: var(--homarr-z-index-widget-focus);
}
.workbench:focus {
outline: none;
}
.workbench[data-code="true"] {
--workbench-inspector-width: 620px;
}
.toolbar {
flex-shrink: 0;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.toolbar,
.library,
.inspector,
.canvasTools,
.problems {
--mantine-color-dimmed: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
--mantine-primary-color-light-color: var(--mantine-color-text);
--mantine-color-primaryColor-light-color: var(--mantine-color-text);
}
:is(.toolbar, .library, .inspector, .canvasTools, .problems)
:global(.mantine-Button-root):is([data-variant="light"], [data-variant="subtle"]) {
color: var(--mantine-color-text);
}
.body {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
.center {
position: absolute;
inset: 0;
min-width: 0;
}
.workbench[data-inspector-hidden="false"] .center {
right: min(calc(var(--workbench-inspector-width) + 24px), calc(62% + 24px));
}
.canvas {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 300px;
}
.canvasTools {
position: absolute;
top: 12px;
left: 12px;
z-index: 4;
padding: 0;
}
.library,
.inspector {
position: absolute;
top: 12px;
bottom: 12px;
z-index: 5;
overflow: auto;
overscroll-behavior: contain;
padding: 12px;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
box-shadow: var(--mantine-shadow-md);
min-width: 0;
}
.library {
left: 12px;
width: 270px;
}
.inspector {
right: 12px;
width: min(var(--workbench-inspector-width), 62%);
}
.workbench[data-library-hidden="true"] .library,
.workbench[data-inspector-hidden="true"] .inspector {
display: none;
}
.inspectorHeader {
position: sticky;
top: -12px;
background: var(--mantine-color-body);
z-index: 2;
padding-block: 10px;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.inspector :global(.mantine-Card-root) {
padding: 8px;
box-shadow: none;
}
.workbench[data-mode="flow"] .inspector [data-workbench-section-title] {
display: none;
}
.inspector :global(.cm-editor),
.inspector :global(.cm-scroller) {
min-height: 380px;
}
.inspector [data-workbench-request-row] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
}
.inspector [data-workbench-request-row] > * {
min-width: 0;
max-width: none;
}
.outlineRow[data-nested="true"] {
margin-inline-start: 10px;
border-inline-start: 1px solid var(--mantine-color-default-border);
padding-inline-start: 4px;
}
.outlineItem {
display: block;
min-width: 0;
flex: 1;
padding: 0.5rem;
border-radius: var(--mantine-radius-sm);
}
.outlineItem:hover {
background: var(--mantine-color-default-hover);
}
.outlineItem[data-selected="true"] {
background: var(--mantine-primary-color-light);
}
.outlineItem:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
}
.node,
.canvasWidgetNode {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-xs);
}
.node {
width: 240px;
--mantine-color-dimmed: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.canvasWidgetNode {
width: max-content;
min-width: 320px;
}
.node[data-selected="true"],
.canvasWidgetNode[data-selected="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.node[data-valid-target="true"],
.canvasWidgetNode[data-valid-target="true"] {
border-color: var(--mantine-color-teal-6);
box-shadow: 0 0 0 3px var(--mantine-color-teal-light);
}
.nodeDetails {
padding: 0 0.75rem 0.75rem;
}
.node :global(.mantine-Badge-label) {
color: var(--mantine-color-text);
}
:is(.node, .canvasWidgetNode) :global(.react-flow__handle) {
width: 12px;
height: 12px;
border: 2px solid var(--mantine-color-body);
background: var(--mantine-color-dimmed);
}
:is(.node, .canvasWidgetNode) :global(.react-flow__handle.valid) {
background: var(--mantine-color-teal-6);
}
:is(.node, .canvasWidgetNode, .group) :global(.widget-node-drag) {
cursor: grab;
}
.canvas :global(.react-flow__edge-text) {
fill: var(--mantine-color-text);
}
.canvas :global(.react-flow__controls) {
box-shadow: var(--mantine-shadow-xs);
}
.canvas :global(.react-flow__controls-button) {
background: var(--mantine-color-body);
fill: var(--mantine-color-text);
border-bottom-color: var(--mantine-color-default-border);
}
.group {
width: 100%;
height: 100%;
border: 1px dashed var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
padding: 12px;
background: light-dark(rgba(0, 0, 0, 0.025), rgba(255, 255, 255, 0.025));
}
.group[data-selected="true"] {
border-color: var(--mantine-primary-color-filled);
}
.group :global(.widget-node-drag) {
min-height: 26px;
}
.assistantPane {
min-height: 0;
}
.workbench[data-mode="form"] .library {
display: none;
}
.workbench[data-mode="form"] .inspector {
width: 48%;
}
.workbench[data-mode="form"] .center {
right: 50%;
}
@container widget-workbench (max-width: 700px) {
.inspector,
.workbench[data-mode="form"] .inspector {
width: auto;
left: 8px;
right: 8px;
top: 8px;
bottom: 8px;
}
.workbench[data-mode="form"] .center {
right: 0;
}
.workbench[data-inspector-hidden="false"] .center {
right: 0;
}
.library {
width: min(320px, calc(100% - 24px));
}
}
@media (prefers-reduced-motion: reduce) {
.workbench *,
.workbench *::before,
.workbench *::after {
animation: none !important;
transition: none !important;
}
}
@@ -0,0 +1,290 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { Alert, Loader } from "@mantine/core";
import type { ReactFlowInstance } from "@xyflow/react";
import { useI18n } from "@homarr/translation/client";
import {
useCustomWidgetFormDocumentStore,
useDeferredCustomWidgetFormDocumentValues,
} from "../_custom-widget-form-state";
import { createFlowCommands, isGraphEditable } from "./commands";
import { projectWidgetGraph } from "./graph";
import { placeAddedNodes } from "./node-placement";
import type { FlowKind, WidgetNode, WidgetEdge } from "./graph";
import { useWorkbenchRenames, WorkbenchSelectionContext } from "./selection";
import { captureLayout, groupNodes, projectLayout, ungroupNodes } from "./layout";
import { WorkbenchOutline } from "./outline";
import { WorkbenchStart } from "./start";
import { DeleteSelection } from "./delete-selection";
import { WorkbenchProblems } from "./problems";
import { useWorkbenchKeybindings } from "./keybindings";
import { WorkbenchToolbar } from "./toolbar";
import { CanvasPreviewProvider } from "./canvas-preview";
import { FlowConnectionProvider } from "./connection-provider";
import { DraftRecoveryAlert, DraftRecoveryProvider } from "./recovery";
import { WorkbenchExecutionProvider } from "./execution";
import { WorkbenchInspector } from "./inspector";
import { useWorkbenchFullscreen } from "./fullscreen";
import { restoreWorkbenchFocus } from "./return-focus";
import type { FlowWorkbenchProps } from "./workbench-types";
import classes from "./workbench.module.css";
const Canvas = dynamic(() => import("./canvas"), { ssr: false, loading: () => <Loader m="auto" size="sm" /> });
export function FlowWorkbench(props: FlowWorkbenchProps) {
const { fullscreen, ref: workspaceRef, toggleFullscreen } = useWorkbenchFullscreen();
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const values = useDeferredCustomWidgetFormDocumentValues();
const [selected, setSelected] = useState("");
const [selection, setSelection] = useState<string[]>([]);
useWorkbenchRenames(store, setSelected, setSelection);
const [search, setSearch] = useState("");
const [pane, setPane] = useState("inspector");
const [extensionsOpened, setExtensionsOpened] = useState(false);
const [mode, setMode] = useState("flow");
const [libraryHidden, setLibraryHidden] = useState(true);
const [inspectorHidden, setInspectorHidden] = useState(true);
const [deleteOpened, setDeleteOpened] = useState(false);
const returnFocus = useRef<HTMLElement | null>(null);
const inspectorRef = useRef<HTMLDivElement>(null);
const canvas = useRef<ReactFlowInstance<WidgetNode, WidgetEdge> | null>(null);
const layout = useSyncExternalStore(store.subscribeEditor, store.getLayout, store.getLayout);
const semanticGraph = useMemo(() => projectWidgetGraph(values, { version: 1, nodes: {}, groups: {} }), [values]);
const graph = useMemo(
() => ({ ...semanticGraph, nodes: projectLayout(semanticGraph.nodes, layout) }),
[semanticGraph, layout],
);
const commands = useMemo(() => createFlowCommands(props.form, store), [props.form, store]);
const node = graph.nodes.find((entry) => entry.id === selected);
const category = node?.data.kind ?? "general";
const editable = isGraphEditable(values);
const inspect = useCallback((id: string) => {
if (document.activeElement instanceof HTMLElement) returnFocus.current = document.activeElement;
setSelected(id);
setInspectorHidden(false);
if (["native:", "fragment:", "preference:", "content:"].some((prefix) => id.startsWith(prefix))) {
setPane("extensions");
setExtensionsOpened(true);
} else setPane("inspector");
}, []);
const select = useCallback(
(id: string) => {
inspect(id);
setLibraryHidden(true);
const ids: string[] = [];
if (id !== "general" && !id.endsWith(":new")) ids.push(id);
setSelection(ids);
},
[inspect],
);
const edit = useCallback(
(id: string) => {
select(id);
requestAnimationFrame(() => {
let selector = '.cm-content, input:not([type="hidden"]), textarea';
if (id === "general") selector = "#general input";
const fields = inspectorRef.current?.querySelectorAll<HTMLElement>(selector);
Array.from(fields ?? [])
.find((field) => field.checkVisibility())
?.focus();
});
},
[select],
);
const selectMany = useCallback((ids: string[]) => {
setSelection((previous) => {
if (previous.length === ids.length && previous.every((id) => ids.includes(id))) return previous;
return ids;
});
}, []);
const toggle = (id: string) => {
setSelection((previous) => {
if (previous.includes(id)) return previous.filter((entry) => entry !== id);
return [...previous, id];
});
};
const group = (remove: boolean) => {
const nodes = (canvas.current?.getNodes() ?? graph.nodes).map((entry) => ({
...entry,
selected: selection.includes(entry.id),
}));
let next;
if (remove) next = ungroupNodes(nodes);
else next = groupNodes(nodes, t("kind.group"));
store.setLayout(captureLayout(next, store.getLayout()));
const ids = next.filter((entry) => entry.selected).map((entry) => entry.id);
setSelection(ids);
if (ids[0]) inspect(ids[0]);
};
const add = (kind: FlowKind) => {
if (!isGraphEditable(store.getValues())) return;
store.transaction(() => {
const measured = new Map(canvas.current?.getNodes().map((entry) => [entry.id, entry]));
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes.map((entry) => ({
...measured.get(entry.id),
...entry,
}));
const id = commands.add(kind);
if (id.endsWith(":new")) {
select(id);
return;
}
const next = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
store.setLayout({
...store.getLayout(),
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, next, id, selected) },
});
select(id);
setLibraryHidden(true);
});
};
const closeInspector = () => {
setMode("flow");
setInspectorHidden(true);
requestAnimationFrame(() => {
returnFocus.current = restoreWorkbenchFocus(workspaceRef.current, returnFocus.current, selected);
});
};
const closeLibrary = () => {
setLibraryHidden(true);
workspaceRef.current?.querySelector<HTMLElement>("[data-workbench-library-toggle]")?.focus();
};
const openAssistant = () => {
if (document.activeElement instanceof HTMLElement) returnFocus.current = document.activeElement;
setPane("assistant");
setInspectorHidden(false);
};
useWorkbenchKeybindings({
store,
selection,
onDelete: () => setDeleteOpened(true),
returnFocus,
onSelect: select,
onShowExecution: () => undefined,
onCloseLibrary: closeLibrary,
onClear: () => {
setLibraryHidden(true);
closeInspector();
setSelected("");
setSelection([]);
},
});
return (
<WorkbenchExecutionProvider preview={props.execution}>
<WorkbenchSelectionContext.Provider value={mode === "flow" ? selected : undefined}>
<FlowConnectionProvider form={props.form}>
<DraftRecoveryProvider form={props.form} definitionId={props.definitionId}>
<CanvasPreviewProvider preview={props.preview} edit={edit}>
<section
ref={workspaceRef}
tabIndex={-1}
className={classes.workbench}
data-widget-workbench
data-fullscreen={fullscreen}
aria-label={t("workbench")}
data-mode={mode}
data-code={category === "widget" && pane === "inspector"}
data-library-hidden={libraryHidden}
data-inspector-hidden={inspectorHidden && mode === "flow"}
>
<WorkbenchToolbar
fullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
actions={props.actions}
definitionId={props.definitionId}
mode={mode}
setMode={(next) => {
setMode(next);
setInspectorHidden(next === "flow");
}}
libraryOpened={!libraryHidden}
onToggleLibrary={() => setLibraryHidden(!libraryHidden)}
onAssistant={openAssistant}
onGeneral={() => edit("general")}
/>
{props.feedback}
<DraftRecoveryAlert />
{!editable && (
<Alert color="yellow" p="xs">
{t("invalidGraphJson")}
</Alert>
)}
<div className={classes.body}>
<WorkbenchOutline
start={
!props.definitionId && (
<WorkbenchStart
form={props.form}
onSelect={select}
onAssistant={openAssistant}
onIntegration={() => select("native:new")}
/>
)
}
nodes={graph.nodes}
selectedIds={selection}
selected={selected}
search={search}
onSearch={setSearch}
onSelect={edit}
onClose={closeLibrary}
onToggle={toggle}
onAdd={add}
editable={editable}
onGroup={() => group(false)}
onUngroup={() => group(true)}
/>
<div className={classes.center}>
<div className={classes.canvas}>
<Canvas
form={props.form}
selected={selected}
inspectorOpened={!inspectorHidden}
selection={selection}
onSelection={selectMany}
onSelect={edit}
onInspect={inspect}
onInit={(instance) => {
canvas.current = instance;
}}
/>
</div>
</div>
<WorkbenchInspector
sections={props}
node={node}
graph={graph}
selected={selected}
selection={selection}
pane={pane}
setPane={setPane}
onClose={closeInspector}
onAssistant={openAssistant}
mode={mode}
editable={editable}
extensionsOpened={extensionsOpened}
setExtensionsOpened={setExtensionsOpened}
select={select}
onDelete={() => setDeleteOpened(true)}
inspectorRef={inspectorRef}
/>
</div>
<WorkbenchProblems onSelect={select} />
<DeleteSelection
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
selection={selection}
nodes={graph.nodes}
edges={graph.edges}
form={props.form}
onSelect={select}
/>
</section>
</CanvasPreviewProvider>
</DraftRecoveryProvider>
</FlowConnectionProvider>
</WorkbenchSelectionContext.Provider>
</WorkbenchExecutionProvider>
);
}
@@ -0,0 +1,73 @@
"use client";
import { Accordion, Code, SimpleGrid, Stack, Text } from "@mantine/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
function reviewSections(widget: HomarrCustomWidgetV2) {
return {
sources: Object.fromEntries(
Object.entries(widget.sources).map(([id, source]) => {
const { auth: _auth, ...connection } = source;
return [id, connection];
}),
),
authentication: Object.fromEntries(Object.entries(widget.sources).map(([id, source]) => [id, source.auth])),
queries: Object.fromEntries(Object.entries(widget.requests).filter(([, request]) => request.kind === "query")),
actions: Object.fromEntries(Object.entries(widget.requests).filter(([, request]) => request.kind === "action")),
permissions: Object.fromEntries(Object.entries(widget.requests).map(([id, request]) => [id, request.permission])),
options: widget.options,
template: widget.template,
native: widget.extensions?.native ?? {},
stylesheet: widget.extensions?.stylesheet ?? "",
fragments: widget.extensions?.fragments ?? {},
preferences: widget.extensions?.preferences ?? {},
content: widget.extensions?.content ?? {},
};
}
function display(value: unknown) {
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
}
export function WorkshopUpdateDiff({
current,
incoming,
}: {
current: HomarrCustomWidgetV2;
incoming: HomarrCustomWidgetV2;
}) {
const t = useI18n("customWidget.flow");
const before = reviewSections(current);
const after = reviewSections(incoming);
const fields = (Object.keys(before) as (keyof typeof before)[]).filter(
(field) => display(before[field]) !== display(after[field]),
);
return (
<Accordion multiple variant="contained">
{fields.map((field) => (
<Accordion.Item key={field} value={field}>
<Accordion.Control>{t(`diff.${field}`)}</Accordion.Control>
<Accordion.Panel>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="xs">{t("installed")}</Text>
<Code block style={{ maxHeight: 260, overflow: "auto" }}>
{display(before[field])}
</Code>
</Stack>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="xs">{t("upstream")}</Text>
<Code block style={{ maxHeight: 260, overflow: "auto" }}>
{display(after[field])}
</Code>
</Stack>
</SimpleGrid>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
);
}
@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { parseCustomWidgetWorkshopOrigin } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { useConfirmModal } from "@homarr/modals";
import { Link } from "@homarr/ui";
import { getWorkshopApiUrl } from "~/components/workshop/workshop-client";
import { useCustomWidgetFormDocumentDirty, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
import { WorkshopUpdateDiff } from "./workshop-diff";
export function WorkbenchWorkshop({ definitionId }: { definitionId?: string }) {
const t = useI18n("customWidget.flow");
const store = useCustomWidgetFormDocumentStore();
const dirty = useCustomWidgetFormDocumentDirty();
const [opened, setOpened] = useState(false);
const { openConfirmModal } = useConfirmModal();
const saved = clientApi.customWidget.get.useQuery({ id: definitionId ?? "" }, { enabled: Boolean(definitionId) });
const origin = parseCustomWidgetWorkshopOrigin(saved.data?.workshopOrigin);
const matchesEndpoint = origin?.endpoint === getWorkshopApiUrl();
const review = clientApi.customWidget.workshopUpdateReview.useQuery(
{ id: definitionId ?? "" },
{
enabled: opened && Boolean(origin) && matchesEndpoint,
refetchOnWindowFocus: false,
},
);
const recovery = clientApi.customWidget.workshopRollbackReview.useQuery(
{ id: definitionId ?? "" },
{
enabled: opened && Boolean(origin),
refetchOnWindowFocus: false,
},
);
const install = clientApi.customWidget.workshopUpdateInstall.useMutation({
onSuccess: () => window.location.reload(),
});
const rollback = clientApi.customWidget.workshopRollback.useMutation({ onSuccess: () => window.location.reload() });
const update = review.data;
const hasUpdate = update && update.revision > update.origin.revision;
const error = review.error ?? recovery.error ?? install.error ?? rollback.error;
const pending = install.isPending || rollback.isPending;
const confirmUpdate = () => {
if (!definitionId || !update) return;
openConfirmModal({
title: t("installUpdate"),
children: t("updateConfirmation"),
onConfirm: () => {
if (store.getDirty()) return;
install.mutate({
id: definitionId,
revision: update.revision,
expectedFingerprint: update.expectedFingerprint,
upstreamFingerprint: update.upstreamFingerprint,
expectedState: update.expectedState,
});
},
});
};
const confirmRollback = () => {
if (!definitionId || !recovery.data) return;
const reviewed = recovery.data;
openConfirmModal({
title: t("rollback"),
children: t("rollbackConfirmation"),
onConfirm: () => {
if (store.getDirty()) return;
rollback.mutate({
id: definitionId,
expectedFingerprint: reviewed.expectedFingerprint,
expectedState: reviewed.expectedState,
});
},
});
};
return (
<>
<Button size="compact-xs" variant="subtle" onClick={() => setOpened(true)}>
{t("workshop")}
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title={t("workshop")} size="xl">
<Stack>
<Group>
<Button component={Link} href="/manage/custom-widgets/workshop" variant="default">
{t("browse")}
</Button>
{definitionId && (
<Button component={Link} href={`/manage/custom-widgets/publish/${definitionId}`} disabled={dirty}>
{t("publish")}
</Button>
)}
{origin && matchesEndpoint && (
<Button component={Link} href={`/manage/custom-widgets/workshop/${origin.submissionId}`} variant="light">
{t("viewSubmission")}
</Button>
)}
</Group>
{dirty && <Alert color="yellow">{t("saveBeforeUpdate")}</Alert>}
{!origin && (
<Text size="sm" c="dimmed">
{t("unlinked")}
</Text>
)}
{origin && !matchesEndpoint && <Alert color="yellow">{t("differentWorkshop")}</Alert>}
{origin && matchesEndpoint && (
<Button
variant="subtle"
loading={review.isFetching}
disabled={pending}
onClick={() => {
void review.refetch();
void recovery.refetch();
}}
>
{t("checkUpdates")}
</Button>
)}
{error && <Alert color="red">{error.message}</Alert>}
{update && matchesEndpoint && (
<>
<Text fw={600}>{t("revision", { installed: update.origin.revision, latest: update.revision })}</Text>
{update.changelog && <Text size="sm">{update.changelog}</Text>}
{update.locallyModified && <Alert color="yellow">{t("localChanges")}</Alert>}
{update.sourceBindingChanged && <Alert color="yellow">{t("sourceChanges")}</Alert>}
{hasUpdate && <WorkshopUpdateDiff current={update.current} incoming={update.incoming} />}
<Group>
{hasUpdate && (
<Button
disabled={dirty || pending || update.locallyModified || update.sourceBindingChanged}
loading={install.isPending}
onClick={confirmUpdate}
>
{t("installUpdate")}
</Button>
)}
{hasUpdate && (
<Button
variant="default"
component={Link}
href={`/manage/custom-widgets/workshop/${update.origin.submissionId}`}
>
{t("installCopy")}
</Button>
)}
</Group>
</>
)}
{recovery.data?.rollbackAvailable && (
<Button variant="subtle" disabled={dirty || pending} loading={rollback.isPending} onClick={confirmRollback}>
{t("rollback")}
</Button>
)}
</Stack>
</Modal>
</>
);
}
@@ -24,10 +24,12 @@ import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
import { clearSaveIssues, reportSaveIssues } from "./_custom-widget-save-issue-utils";
import type { CustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
import { getSourceSecretRenames, markSourceSecretsSaved } from "./_custom-widget-saved-secrets";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
savedRevision?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
documentStore: CustomWidgetFormDocumentStore;
setPreview: Dispatch<SetStateAction<PreviewState>>;
@@ -60,7 +62,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
Object.keys(preview.status).length === 0
)
return preview;
return { data: {}, status: {}, session: null, outcome: "idle" };
return { ...preview, session: null, outcome: "idle", stale: true };
});
}, [setPreview]);
useLayoutEffect(() => {
@@ -77,6 +79,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
let actionLabel = tCommon("action.save");
if (input.mode === "create") actionLabel = tCommon("action.create");
const savedRevision = useRef(input.savedRevision);
const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues);
setSaveIssues([]);
@@ -92,33 +95,59 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
return;
}
const changedSecrets = getChangedSecrets(values);
const savedLayout = input.documentStore.getLayout();
const savedSourceBindings = input.documentStore.getSourceBindings();
const savedRequestBindings = input.documentStore.getRequestBindings();
try {
if (input.mode === "create") {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
const result = await createMutation.mutateAsync({
...definition.data,
secrets: changedSecrets,
editorLayout: savedLayout,
});
const savedValues = { ...values, secrets: markSourceSecretsSaved(values.secrets, values.secrets) };
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: tCommon("action.create"),
message: t("notification.created", { name: values.name }),
});
input.form.setInitialValues(values);
input.form.setValues({
secrets: markSourceSecretsSaved(values.secrets, input.documentStore.getValues().secrets),
});
input.form.setInitialValues(savedValues);
input.form.resetDirty();
input.documentStore.markSaved(values);
input.documentStore.markSaved(savedValues, savedLayout, savedSourceBindings, savedRequestBindings);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
window.location.assign(result.managementPath);
} else if (input.definitionId) {
await updateMutation.mutateAsync({
const updated = await updateMutation.mutateAsync({
expectedSavedRevision: savedRevision.current,
id: input.definitionId,
sourceRenames: getSourceSecretRenames(values.secrets),
editorLayout: savedLayout,
...definition.data,
secrets: changedSecrets.length ? changedSecrets : undefined,
});
savedRevision.current = updated.savedRevision;
const savedValues = {
...values,
secrets: markSourceSecretsSaved(values.secrets, values.secrets, updated.secretPresence),
};
await Promise.all([
utils.customWidget.list.invalidate(),
utils.customWidget.get.invalidate({ id: input.definitionId }),
utils.widget.customApi.getData.invalidate(),
]);
input.form.setInitialValues(values);
input.form.setValues({
secrets: markSourceSecretsSaved(
values.secrets,
input.documentStore.getValues().secrets,
updated.secretPresence,
),
});
input.form.setInitialValues(savedValues);
input.form.resetDirty();
input.documentStore.markSaved(values);
input.documentStore.markSaved(savedValues, savedLayout, savedSourceBindings, savedRequestBindings);
showSuccessNotification({
title: tCommon("action.save"),
message: t("notification.updated", { name: values.name }),
@@ -161,12 +190,13 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
activePreviewGeneration.current = generation;
const isCurrent = () => previewGeneration.current === generation;
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
input.setPreview((current) => ({ ...current, session: null, outcome: "loading" }));
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
definition: candidateAtStart.data,
definitionId: input.definitionId,
sourceRenames: getSourceSecretRenames(valuesAtStart.secrets),
options: optionsAtStart,
secrets: secretsAtStart,
});
@@ -187,15 +217,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
failed,
}),
});
} else {
showSuccessNotification({
title: w("preview.result.success.title"),
message: w("preview.result.success.description", {
succeeded: Object.keys(snapshot.status).length,
failed: 0,
}),
});
}
return { success: failed === 0, status: snapshot.status, sessionId: created.previewSession.id };
} catch {
if (!isCurrent()) return;
input.setPreview((current) => ({ ...current, outcome: "error" }));
@@ -204,7 +227,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
if (activePreviewGeneration.current === generation) {
activePreviewGeneration.current = null;
setPreviewPending(false);
if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
if (!isCurrent()) setPreview((current) => ({ ...current, session: null, outcome: "idle", stale: true }));
}
}
};
@@ -105,7 +105,7 @@ export function useCustomWidgetFormAnalysis(values: CustomWidgetFormValues) {
() => (candidate.success ? candidate.data : null),
// The preview renderer consumes only these definition fields; other fields affect the validity boolean below.
// eslint-disable-next-line react-hooks/exhaustive-deps
[analysisValues.requests, analysisValues.template, candidate.success],
[analysisValues.requests, analysisValues.template, analysisValues.extensions, candidate.success],
);
const previewValidationIssues = useMemo(
() =>
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more