feat: add custom widget flow workbench
This commit is contained in:
1 parent
c32a5317ca
commit
a6bdbbb4ea
259 files changed
+23875
-1293
No files matched your search
@@ -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;
|
||||
}
|
||||
+124
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+7
-3
@@ -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"),
|
||||
|
||||
+134
-198
@@ -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>
|
||||
|
||||
+10
-2
@@ -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);
|
||||
|
||||
+7
-2
@@ -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"
|
||||
|
||||
+32
-4
@@ -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>
|
||||
);
|
||||
|
||||
+38
-9
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+101
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+234
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+40
-17
@@ -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 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -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
Reference in new issue
Block a user