fix(custom-widgets): address review and CI
This commit is contained in:
1 parent
2153069a2f
commit
da57a937ed
10 files changed
+49
-46
No files matched your search
@@ -1400,8 +1400,8 @@ This is the shortest useful map from the report back to the current code. Line n
|
||||
| Renderer session and synchronous render | [`custom-jsx-renderer.tsx`](packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx) | Stable session/binding reconciliation is delivered; interpretation and binding hashing still belong in a shared compile artifact. |
|
||||
| Existing editor undo/redo | [`code-editor.tsx`](packages/custom-widgets/src/workbench/code-editor.tsx) and [`direct-code-mirror.tsx`](packages/custom-widgets/src/workbench/direct-code-mirror.tsx) | CodeMirror history, depth counters, toolbar commands, and `basicSetup` already exist; the missing layer is coherent workbench-wide undo. |
|
||||
| Interpreter parse/render entry | [`interpreter.tsx`](packages/custom-widgets/src/jsx/interpreter.tsx) and [`interpreter-parser.ts`](packages/custom-widgets/src/jsx/interpreter-parser.ts) | Candidate for a shared generation-tagged compile artifact. |
|
||||
| Runtime display bindings | [`custom-jsx-display.tsx`](packages/widgets/src/custom-api/custom-jsx-display.tsx) | Template change state clearing and fresh data/status wrapper objects. |
|
||||
| Prompt and channel contract | [`ai-prompt.ts`](packages/custom-widgets/src/core/ai-prompt.ts), [`_copy-ai-prompt-button.tsx`](apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx), and [`assistant-tool-policy.ts`](apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts) | Clipboard final-output instruction conflicts with the in-product tool lifecycle. |
|
||||
| Runtime display bindings | [`custom-jsx-display.tsx`](packages/widgets/src/custom-api/custom-jsx-display.tsx) | Published/query-cache state is now definition-scoped while template-only edits preserve compatible state; shared compile/binding artifacts remain future work. |
|
||||
| Prompt and channel contract | [`ai-prompt.ts`](packages/custom-widgets/src/core/ai-prompt.ts), [`_copy-ai-prompt-button.tsx`](apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx), and [`assistant-tool-policy.ts`](apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts) | Clipboard, Assistant, and MCP contracts are now separate and trust-bounded; authoritative session/fingerprint/evidence context remains the next seam. |
|
||||
| Assistant dynamic context | [`custom-widget-authoring-context.ts`](apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts) | Adapter point for session/fingerprint/diagnostic/evidence context. |
|
||||
| Exact-tested creation | [`creation-procedures.ts`](packages/api/src/router/custom-widget/creation-procedures.ts), especially 53–109 | Existing invariant to preserve and generalize into evidence-bound workbench save/update. |
|
||||
| Preview session authority | [`preview-sessions.ts`](packages/custom-widgets/src/server/preview-sessions.ts) | Existing short-lived preview state; its internal update protection should not become a local authoring/version model. |
|
||||
|
||||
@@ -84,13 +84,6 @@ describe("Custom Widget workbench preview options", () => {
|
||||
it("normalizes only changed secrets", () => {
|
||||
expect(
|
||||
getChangedSecrets({
|
||||
name: "Widget",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "<Text />",
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
|
||||
{ sourceId: "other", kind: "password", value: "replacement" },
|
||||
|
||||
@@ -108,13 +108,17 @@ function useBoundProps(componentName: string, props: Record<string, unknown>): R
|
||||
const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
|
||||
const registerInput = context?.registerInput;
|
||||
useEffect(() => {
|
||||
if (!isBound || !binding || !registerInput || !inputType) return;
|
||||
if (!isBound || !binding || !registerInput || !inputType || serializedInitialValue === undefined) return;
|
||||
return registerInput(binding, inputType, JSON.parse(serializedInitialValue) as WidgetInputValue);
|
||||
}, [binding, inputType, isBound, registerInput, serializedInitialValue]);
|
||||
if (!isBound || !binding || !context || !inputType) return sanitized;
|
||||
delete sanitized.defaultValue;
|
||||
delete sanitized.defaultChecked;
|
||||
const currentValue = context.inputs[binding] ?? initialValue;
|
||||
let currentValue = initialValue;
|
||||
if (context.inputTypes[binding] === inputType && Object.hasOwn(context.inputs, binding)) {
|
||||
const storedValue = context.inputs[binding];
|
||||
if (storedValue !== undefined) currentValue = storedValue;
|
||||
}
|
||||
|
||||
const update = (value: unknown, checked = false) => {
|
||||
const extracted = extractEventValue(value, checked);
|
||||
|
||||
@@ -24,6 +24,7 @@ const permissions = new Set(["view", "modify", "full"]);
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
templateWarnings(count: number): string;
|
||||
bindingTypeConflict(name: string, firstType: WidgetInputType, secondType: WidgetInputType): string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRendererProps {
|
||||
@@ -146,6 +147,7 @@ function CustomJsxRendererSession({
|
||||
const registrations = useRef(new Map<symbol, InputRegistration>());
|
||||
const registrationVersion = useRef(0);
|
||||
const mounted = useRef(true);
|
||||
const bindingTypeConflict = messages.bindingTypeConflict;
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
@@ -163,7 +165,7 @@ function CustomJsxRendererSession({
|
||||
continue;
|
||||
}
|
||||
if (existing.type === registration.type) continue;
|
||||
const message = `BINDING_TYPE_CONFLICT: '${registration.name}' is bound as both ${existing.type} and ${registration.type}`;
|
||||
const message = bindingTypeConflict(registration.name, existing.type, registration.type);
|
||||
if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message);
|
||||
}
|
||||
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
|
||||
@@ -172,15 +174,19 @@ function CustomJsxRendererSession({
|
||||
for (const [name, registration] of active) nextTypes[name] = registration.type;
|
||||
const nextValues: Record<string, WidgetInputValue> = {};
|
||||
for (const [name, registration] of active) {
|
||||
nextValues[name] =
|
||||
current.types[name] === registration.type && Object.hasOwn(current.values, name)
|
||||
? current.values[name]
|
||||
: registration.initialValue;
|
||||
if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) {
|
||||
const previousValue = current.values[name];
|
||||
if (previousValue !== undefined) {
|
||||
nextValues[name] = previousValue;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextValues[name] = registration.initialValue;
|
||||
}
|
||||
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
|
||||
return { values: nextValues, types: nextTypes };
|
||||
});
|
||||
}, []);
|
||||
}, [bindingTypeConflict]);
|
||||
const registerInput = useCallback(
|
||||
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
|
||||
const id = Symbol(name);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch,
|
||||
import { createCustomJsxComponents } from "../jsx";
|
||||
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
|
||||
import type {
|
||||
CustomJsxRendererMessages,
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetPublishedQueryState,
|
||||
CustomWidgetRequestResult,
|
||||
@@ -36,6 +37,13 @@ const messages: CustomWidgetRuntimeMessages = {
|
||||
refresh: "Refresh",
|
||||
};
|
||||
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
bindingTypeConflict: (name: string, firstType: string, secondType: string) =>
|
||||
`Input ${name} conflicts between ${firstType} and ${secondType}`,
|
||||
} satisfies CustomJsxRendererMessages;
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
@@ -125,7 +133,7 @@ describe("Custom Widget runtime ports", () => {
|
||||
createBindings={() => {
|
||||
throw new RangeError("Response data exceeded the depth limit");
|
||||
}}
|
||||
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
@@ -147,7 +155,7 @@ describe("Custom Widget runtime ports", () => {
|
||||
data={{}}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
@@ -159,11 +167,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
|
||||
@@ -183,11 +186,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
|
||||
await render(
|
||||
<>
|
||||
<input aria-label="Outside" name="shared" type="radio" />
|
||||
@@ -217,10 +215,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
|
||||
it("exposes temporary bindings through inputs without browser storage", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
@@ -252,7 +246,7 @@ describe("Custom Widget runtime ports", () => {
|
||||
data={{}}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
@@ -273,10 +267,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
});
|
||||
|
||||
it("resets temporary bindings when the template changes", async () => {
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
@@ -313,10 +303,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
});
|
||||
|
||||
it("preserves temporary bindings when data changes without changing the template", async () => {
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
|
||||
}
|
||||
.accessibleMuted[data-invalid="true"] {
|
||||
color: var(--mantine-color-red-6);
|
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-4));
|
||||
}
|
||||
.viewport {
|
||||
max-height: min(62vh, 680px);
|
||||
|
||||
@@ -104,6 +104,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
|
||||
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
const isCharacterLimitExceeded = props.maxLength !== undefined && props.value.length > props.maxLength;
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value);
|
||||
@@ -175,6 +176,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
theme={colorScheme}
|
||||
invalid={isCharacterLimitExceeded}
|
||||
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
|
||||
readOnly={props.readOnly ?? false}
|
||||
diagnosticMessage={props.messages.diagnostic}
|
||||
@@ -211,9 +213,11 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
component="output"
|
||||
size="xs"
|
||||
className={classes.accessibleMuted}
|
||||
data-invalid={Boolean(props.maxLength && props.value.length > props.maxLength)}
|
||||
data-invalid={isCharacterLimitExceeded}
|
||||
aria-live="polite"
|
||||
>
|
||||
{props.messages.characters(props.value.length, props.maxLength)}
|
||||
</Text>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface DirectCodeMirrorProps {
|
||||
placeholder?: string;
|
||||
height: string;
|
||||
theme: "light" | "dark";
|
||||
invalid: boolean;
|
||||
readOnly: boolean;
|
||||
diagnosticMessage(diagnostic: EditorDiagnostic): string;
|
||||
onChange(value: string): void;
|
||||
@@ -124,9 +125,10 @@ export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
|
||||
tabindex: "0",
|
||||
"aria-label": props.label,
|
||||
"aria-labelledby": props.labelledBy,
|
||||
"aria-invalid": props.invalid.toString(),
|
||||
}),
|
||||
);
|
||||
}, [compartments.accessibility, instanceId, props.id, props.label, props.labelledBy]);
|
||||
}, [compartments.accessibility, instanceId, props.id, props.invalid, props.label, props.labelledBy]);
|
||||
useEffect(() => {
|
||||
reconfigure(instanceId, compartments.editability, createEditabilityExtension(props.readOnly));
|
||||
}, [compartments.editability, instanceId, props.readOnly]);
|
||||
@@ -166,6 +168,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps, comp
|
||||
tabindex: "0",
|
||||
"aria-label": props.label,
|
||||
"aria-labelledby": props.labelledBy,
|
||||
"aria-invalid": props.invalid.toString(),
|
||||
}),
|
||||
),
|
||||
compartments.editability.of(createEditabilityExtension(props.readOnly)),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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";
|
||||
@@ -21,6 +21,7 @@ interface ScopedQueryState {
|
||||
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
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");
|
||||
@@ -28,6 +29,10 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
() => createCustomWidgetComponents({ copy: copyLabel, copied: copiedLabel }),
|
||||
[copiedLabel, copyLabel],
|
||||
);
|
||||
const bindingTypeConflict = useCallback<CustomJsxRendererMessages["bindingTypeConflict"]>(
|
||||
(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;
|
||||
@@ -77,6 +82,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
messages={{
|
||||
noTemplate: t("noTemplate"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
bindingTypeConflict,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user