@@ -113,16 +135,10 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
- onUndo={() => {
- const editorView = editorViews.get(editorInstanceId);
- if (editorView) undo(editorView);
- }}
- onRedo={() => {
- const editorView = editorViews.get(editorInstanceId);
- if (editorView) redo(editorView);
- }}
+ onUndo={handleUndo}
+ onRedo={handleRedo}
onFormat={() => {
- const editorView = editorViews.get(editorInstanceId);
+ const editorView = editorViewRef.current;
if (props.language === "jsx" && editorView) {
const selection = editorView.state.selection.main;
editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } });
@@ -157,23 +173,18 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
value={props.value}
language={props.language}
diagnostics={diagnostics}
- completions={props.completions ?? []}
+ completions={props.completions ?? EMPTY_COMPLETIONS}
label={props.label}
placeholder={props.placeholder}
theme={colorScheme}
+ invalid={isEditorInvalid}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
readOnly={props.readOnly ?? false}
diagnosticMessage={props.messages.diagnostic}
onChange={props.readOnly ? () => undefined : props.onChange}
onCreateEditor={handleCreateEditor}
onDestroyEditor={handleDestroyEditor}
- onUpdate={(update) => {
- if (!update.selectionSet && !update.docChanged) return;
- const head = update.state.selection.main.head;
- const line = update.state.doc.lineAt(head);
- setCursor({ line: line.number, column: head - line.from + 1 });
- setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
- }}
+ onUpdate={handleUpdate}
/>
) : (
@@ -193,16 +204,22 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
) : (
-
+
{props.messages.ready}
)}
-
+
{props.messages.position(cursor)}
- props.maxLength ? "red" : "dimmed"}>
+
{props.messages.characters(props.value.length, props.maxLength)}
diff --git a/packages/custom-widgets/src/workbench/direct-code-mirror.tsx b/packages/custom-widgets/src/workbench/direct-code-mirror.tsx
index 5157d6216..1fc8e736d 100644
--- a/packages/custom-widgets/src/workbench/direct-code-mirror.tsx
+++ b/packages/custom-widgets/src/workbench/direct-code-mirror.tsx
@@ -6,12 +6,13 @@ import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
-import { EditorState, StateEffect } from "@codemirror/state";
+import { Annotation, Compartment, EditorSelection, EditorState, Transaction } from "@codemirror/state";
+import type { Extension } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView, keymap, placeholder as codeMirrorPlaceholder } from "@codemirror/view";
import type { ViewUpdate } from "@codemirror/view";
import { basicSetup } from "codemirror";
-import { useCallback, useEffect, useId, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import type { Completion } from "@codemirror/autocomplete";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
@@ -28,11 +29,12 @@ interface DirectCodeMirrorProps {
placeholder?: string;
height: string;
theme: "light" | "dark";
+ invalid: boolean;
readOnly: boolean;
diagnosticMessage(diagnostic: EditorDiagnostic): string;
onChange(value: string): void;
onCreateEditor(view: EditorView): void;
- onDestroyEditor(): void;
+ onDestroyEditor(view: EditorView): void;
onUpdate(update: ViewUpdate): void;
}
@@ -41,99 +43,203 @@ interface EditorCallbacks {
onUpdate(update: ViewUpdate): void;
}
+interface EditorCompartments {
+ language: Compartment;
+ diagnostics: Compartment;
+ accessibility: Compartment;
+ editability: Compartment;
+ placeholder: Compartment;
+ height: Compartment;
+ theme: Compartment;
+}
+
const editorViews = new Map();
const editorCallbacks = new Map();
-const editorContainers = new Map();
+const controlledValueUpdate = Annotation.define();
export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
const instanceId = useId();
const [initialProps] = useState(props);
- const [containerReady, setContainerReady] = useState(false);
- const setContainer = useCallback(
- (container: HTMLDivElement | null) => {
- if (!container) {
- editorContainers.delete(instanceId);
- return;
- }
- editorContainers.set(instanceId, container);
- setContainerReady(true);
- },
- [instanceId],
- );
+ const [compartments] = useState(createEditorCompartments);
+ const containerRef = useRef(null);
useEffect(() => {
editorCallbacks.set(instanceId, { onChange: props.onChange, onUpdate: props.onUpdate });
}, [instanceId, props.onChange, props.onUpdate]);
useEffect(() => {
- const parent = editorContainers.get(instanceId);
+ const parent = containerRef.current;
if (!parent) return;
const view = new EditorView({
parent,
- state: EditorState.create({ doc: initialProps.value, extensions: createExtensions(instanceId, initialProps) }),
+ state: EditorState.create({
+ doc: initialProps.value,
+ extensions: createExtensions(instanceId, initialProps, compartments),
+ }),
});
editorViews.set(instanceId, view);
initialProps.onCreateEditor(view);
return () => {
- editorViews.delete(instanceId);
- editorCallbacks.delete(instanceId);
- initialProps.onDestroyEditor();
+ if (editorViews.get(instanceId) === view) {
+ editorViews.delete(instanceId);
+ editorCallbacks.delete(instanceId);
+ }
+ initialProps.onDestroyEditor(view);
view.destroy();
};
- }, [containerReady, initialProps, instanceId]);
+ }, [compartments, initialProps, instanceId]);
useEffect(() => {
const view = editorViews.get(instanceId);
if (!view || view.state.doc.toString() === props.value) return;
- view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
+ const documentLength = props.value.length;
+ const selection = EditorSelection.create(
+ view.state.selection.ranges.map((range) =>
+ EditorSelection.range(Math.min(range.anchor, documentLength), Math.min(range.head, documentLength)),
+ ),
+ view.state.selection.mainIndex,
+ );
+ view.dispatch({
+ changes: { from: 0, to: view.state.doc.length, insert: props.value },
+ selection,
+ annotations: [Transaction.addToHistory.of(false), controlledValueUpdate.of(true)],
+ });
}, [instanceId, props.value]);
useEffect(() => {
- const view = editorViews.get(instanceId);
- if (!view) return;
- view.dispatch({ effects: StateEffect.reconfigure.of(createExtensions(instanceId, props)) });
- }, [instanceId, props]);
+ reconfigure(instanceId, compartments.language, createLanguageExtension(props.language, props.completions));
+ }, [compartments.language, instanceId, props.completions, props.language]);
+ useEffect(() => {
+ reconfigure(
+ instanceId,
+ compartments.diagnostics,
+ createDiagnosticsExtension(props.diagnostics, props.diagnosticMessage),
+ );
+ }, [compartments.diagnostics, instanceId, props.diagnosticMessage, props.diagnostics]);
+ useEffect(() => {
+ reconfigure(
+ instanceId,
+ compartments.accessibility,
+ EditorView.contentAttributes.of({
+ id: props.id,
+ tabindex: "0",
+ "aria-label": props.label,
+ "aria-labelledby": props.labelledBy,
+ "aria-invalid": props.invalid.toString(),
+ }),
+ );
+ }, [compartments.accessibility, instanceId, props.id, props.invalid, props.label, props.labelledBy]);
+ useEffect(() => {
+ reconfigure(instanceId, compartments.editability, createEditabilityExtension(props.readOnly));
+ }, [compartments.editability, instanceId, props.readOnly]);
+ useEffect(() => {
+ reconfigure(instanceId, compartments.placeholder, createPlaceholderExtension(props.placeholder));
+ }, [compartments.placeholder, instanceId, props.placeholder]);
+ useEffect(() => {
+ reconfigure(instanceId, compartments.height, createHeightExtension(props.height));
+ }, [compartments.height, instanceId, props.height]);
+ useEffect(() => {
+ reconfigure(instanceId, compartments.theme, createThemeExtension(props.theme));
+ }, [compartments.theme, instanceId, props.theme]);
- return ;
+ return ;
}
-function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
- const language =
- props.language === "jsx"
- ? [
- javascript({ jsx: true }),
- autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
- customJsxComponentHover,
- ]
- : props.language === "css"
- ? [css()]
- : [json()];
+function createEditorCompartments(): EditorCompartments {
+ return {
+ language: new Compartment(),
+ diagnostics: new Compartment(),
+ accessibility: new Compartment(),
+ editability: new Compartment(),
+ placeholder: new Compartment(),
+ height: new Compartment(),
+ theme: new Compartment(),
+ };
+}
+
+function createExtensions(instanceId: string, props: DirectCodeMirrorProps, compartments: EditorCompartments) {
return [
basicSetup,
- ...language,
- linter((view) =>
- props.diagnostics.map((diagnostic) => {
- const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
- return {
- from,
- to: Math.min(from + 1, view.state.doc.length),
- severity: diagnostic.severity,
- message: props.diagnosticMessage(diagnostic),
- };
+ compartments.language.of(createLanguageExtension(props.language, props.completions)),
+ compartments.diagnostics.of(createDiagnosticsExtension(props.diagnostics, props.diagnosticMessage)),
+ compartments.accessibility.of(
+ EditorView.contentAttributes.of({
+ id: props.id,
+ tabindex: "0",
+ "aria-label": props.label,
+ "aria-labelledby": props.labelledBy,
+ "aria-invalid": props.invalid.toString(),
}),
),
- EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label, "aria-labelledby": props.labelledBy }),
- EditorView.editable.of(!props.readOnly),
- EditorState.readOnly.of(props.readOnly),
- keymap.of(props.readOnly ? [] : [indentWithTab]),
- props.placeholder ? codeMirrorPlaceholder(props.placeholder) : [],
- EditorView.theme({ "&": { height: props.height }, ".cm-scroller": { overflow: "auto" } }),
- props.theme === "dark" ? oneDark : [],
+ compartments.editability.of(createEditabilityExtension(props.readOnly)),
+ compartments.placeholder.of(createPlaceholderExtension(props.placeholder)),
+ compartments.height.of(createHeightExtension(props.height)),
+ compartments.theme.of(createThemeExtension(props.theme)),
EditorView.updateListener.of((update) => {
const callbacks = editorCallbacks.get(instanceId);
if (!callbacks) return;
- if (update.docChanged) callbacks.onChange(update.state.doc.toString());
+ const isControlledValueUpdate = update.transactions.some(
+ (transaction) => transaction.annotation(controlledValueUpdate) === true,
+ );
+ if (update.docChanged && !isControlledValueUpdate) callbacks.onChange(update.state.doc.toString());
callbacks.onUpdate(update);
}),
];
}
+
+function createLanguageExtension(language: DirectCodeMirrorProps["language"], completions: Completion[]): Extension {
+ if (language === "jsx") {
+ return [
+ javascript({ jsx: true }),
+ autocompletion({ override: [createCustomJsxCompletionSource(completions)] }),
+ customJsxComponentHover,
+ ];
+ }
+ if (language === "css") return css();
+ return json();
+}
+
+function createDiagnosticsExtension(
+ diagnostics: EditorDiagnostic[],
+ diagnosticMessage: DirectCodeMirrorProps["diagnosticMessage"],
+): Extension {
+ return linter((view) =>
+ diagnostics.map((diagnostic) => {
+ const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
+ return {
+ from,
+ to: Math.min(from + 1, view.state.doc.length),
+ severity: diagnostic.severity,
+ message: diagnosticMessage(diagnostic),
+ };
+ }),
+ );
+}
+
+function createEditabilityExtension(readOnly: boolean): Extension {
+ return [
+ EditorView.editable.of(!readOnly),
+ EditorState.readOnly.of(readOnly),
+ keymap.of(readOnly ? [] : [indentWithTab]),
+ ];
+}
+
+function createPlaceholderExtension(placeholder: string | undefined): Extension {
+ if (!placeholder) return [];
+ return codeMirrorPlaceholder(placeholder);
+}
+
+function createHeightExtension(height: string): Extension {
+ return EditorView.theme({ "&": { height }, ".cm-scroller": { overflow: "auto" } });
+}
+
+function createThemeExtension(theme: DirectCodeMirrorProps["theme"]): Extension {
+ if (theme === "dark") return oneDark;
+ return [];
+}
+
+function reconfigure(instanceId: string, compartment: Compartment, extension: Extension) {
+ const view = editorViews.get(instanceId);
+ if (!view) return;
+ view.dispatch({ effects: compartment.reconfigure(extension) });
+}
diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts
index af8e4198e..018b4edb2 100644
--- a/packages/form/src/index.ts
+++ b/packages/form/src/index.ts
@@ -21,7 +21,7 @@ export const useZodForm = <
schema: TSchema,
options: Omit<
Exclude>>[0], undefined>,
- "validate" | "validateInputOnBlur" | "validateInputOnChange"
+ "validate" | "validateInputOnBlur"
>,
) => {
const t = useI18n();
@@ -32,7 +32,7 @@ export const useZodForm = <
return useForm>({
...options,
validateInputOnBlur: true,
- validateInputOnChange: true,
+ validateInputOnChange: options.validateInputOnChange ?? true,
validate: schemaResolver(schema, { sync: true }),
});
};
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 2652ca33d..0442f7233 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -851,6 +851,7 @@
"line": "Line {line, number}",
"ast": "{value}",
"bindingTypeConflict": "The input \"{value}\" is shared by controls with incompatible value types.",
+ "runtimeBindingTypeConflict": "The input \"{value}\" is bound as both {firstType} and {secondType}.",
"dynamicRequestId": "A literal requestId is required. Dynamic request IDs are not accepted.",
"dynamicInputBinding": "A literal input name is required for bind.",
"inlineRequestProps": "Network components accept requestId and params; inline URL, method, header, and body props are blocked.",
@@ -1068,8 +1069,11 @@
},
"size": {
"compact": "Compact",
+ "compactShort": "S",
"standard": "Standard",
- "wide": "Wide"
+ "standardShort": "M",
+ "wide": "Wide",
+ "wideShort": "L"
},
"fixture": {
"live": "Live",
diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx
index 97117086a..a957b9f8a 100644
--- a/packages/widgets/src/custom-api/custom-jsx-display.tsx
+++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useI18n } from "@homarr/translation/client";
-import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
+import type { CustomJsxRendererMessages, CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
@@ -11,32 +11,61 @@ import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./wi
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
+const EMPTY_QUERY_STATE: Record = {};
+
+interface ScopedQueryState {
+ key: string;
+ values: Record;
+}
+
export default function CustomJsxDisplay({ data }: { data: Record }) {
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
+ const diagnosticsT = useI18n("customWidget.editor.diagnostics");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
+ const copyLabel = actionT("copy");
+ const copiedLabel = t("copied");
const components = useMemo(
- () => createCustomWidgetComponents({ copy: actionT("copy"), copied: t("copied") }),
- [actionT, t],
+ () => createCustomWidgetComponents({ copy: copyLabel, copied: copiedLabel }),
+ [copiedLabel, copyLabel],
);
- const [queryState, setQueryState] = useState>({});
- useEffect(
- () => setQueryState({}),
- [data.widgetDefinitionId, data.widgetItemId, data.previewSessionId, data.template],
+ const bindingTypeConflict = useCallback(
+ (name, firstType, secondType) => diagnosticsT("runtimeBindingTypeConflict", { value: name, firstType, secondType }),
+ [diagnosticsT],
+ );
+ const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
+ const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
+ const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
+ const queryCacheKey = typeof data.queryCacheKey === "string" ? data.queryCacheKey : undefined;
+ const queryScopeKey = [definitionId, itemId, previewSessionId, queryCacheKey].join("\u001f");
+ const [publishedQueryState, setPublishedQueryState] = useState(() => ({
+ key: queryScopeKey,
+ values: {},
+ }));
+ const queryState = publishedQueryState.key === queryScopeKey ? publishedQueryState.values : EMPTY_QUERY_STATE;
+ const publishQueryState = useCallback(
+ (requestId: string, value: CustomWidgetPublishedQueryState | null) => {
+ setPublishedQueryState((current) => {
+ const currentValues = current.key === queryScopeKey ? current.values : EMPTY_QUERY_STATE;
+ if (!value) {
+ if (!Object.hasOwn(currentValues, requestId)) {
+ if (current.key === queryScopeKey) return current;
+ return { key: queryScopeKey, values: currentValues };
+ }
+ const next = { ...currentValues };
+ delete next[requestId];
+ return { key: queryScopeKey, values: next };
+ }
+ const previous = currentValues[requestId];
+ if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) {
+ if (current.key === queryScopeKey) return current;
+ return { key: queryScopeKey, values: currentValues };
+ }
+ return { key: queryScopeKey, values: { ...currentValues, [requestId]: value } };
+ });
+ },
+ [queryScopeKey],
);
- const publishQueryState = useCallback((requestId: string, value: CustomWidgetPublishedQueryState | null) => {
- setQueryState((current) => {
- if (!value) {
- if (!Object.hasOwn(current, requestId)) return current;
- const next = { ...current };
- delete next[requestId];
- return next;
- }
- const previous = current[requestId];
- if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) return current;
- return { ...current, [requestId]: value };
- });
- }, []);
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const renderer = (
@@ -53,12 +82,10 @@ export default function CustomJsxDisplay({ data }: { data: Record t("templateWarnings", { count: String(count) }),
+ bindingTypeConflict,
}}
/>
);
- const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
- const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
- const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
if (!itemId && !previewSessionId) {
return (
@@ -70,6 +97,7 @@ export default function CustomJsxDisplay({ data }: { data: Record