feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -8,16 +8,18 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
CustomWidgetRequestResult,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "../runtime";
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: "Migration required",
requestIdRequired: "Request ID required",
unsavedPreview: "Unsaved",
invalidParams: "Invalid params",
loadRequest: "Load",
@@ -74,6 +76,7 @@ async function render(
node: ReactNode,
port: CustomWidgetRuntimePort,
capabilities: readonly CustomJsxRequestCapability[] = [],
setQueryState?: (requestId: string, value: CustomWidgetPublishedQueryState | null) => void,
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
@@ -86,6 +89,7 @@ async function render(
requestCapabilities={capabilities}
port={port}
messages={messages}
setQueryState={setQueryState}
>
{node}
</CustomWidgetRuntimeProvider>
@@ -102,14 +106,300 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("runs an automatic query once and exposes data to a render child", async () => {
const port = createPort();
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<SubFetch requestId="details" render={(data) => <span>{(data as { name: string }).name}</span>} />,
port,
<CustomJsxRenderer
template={
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(" · ")}</Text>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("1 · 2 · 3");
});
it("removes unsafe dynamic link targets at runtime", async () => {
const components = createCustomJsxComponents({
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>"}
data={{ url: "//example.com/escape" }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
expect(host.querySelector("a")?.hasAttribute("href")).toBe(false);
});
it("scopes authored radio names to the widget and preserves uncontrolled defaults", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
</>,
createPort(),
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
expect(other?.name).toBe(inside?.name);
expect(inside?.getAttribute("aria-checked")).toBe("true");
expect(other?.getAttribute("aria-checked")).toBe("false");
});
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" },
});
const widget = (
<CustomJsxRenderer
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget, createPort());
await settle();
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("preserves input focus across temporary binding updates", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="search"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
await settle();
const input = host.querySelector("input") as HTMLInputElement;
input.focus();
await act(async () => {
input.value = "p";
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "p", inputType: "insertText" }));
});
await settle();
expect(input.isConnected).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.value).toBe("p");
expect(host.textContent).toContain("p");
});
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" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="shared" defaultValue="text"/><Text>{inputs.shared}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("text");
await render(
<CustomJsxRenderer
template={
'<Stack><Switch bind="shared" defaultChecked/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("enabled");
expect(host.textContent).not.toContain("BINDING_TYPE_CONFLICT");
});
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" },
});
const template = '<Stack><Switch bind="shared"/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>';
await render(
<CustomJsxRenderer
template={template}
data={{ version: 1 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(host.textContent).toContain("enabled");
await render(
<CustomJsxRenderer
template={template}
data={{ version: 2 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect((host.querySelector("input") as HTMLInputElement).checked).toBe(true);
expect(host.textContent).toContain("enabled");
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
const port = createPort();
const publish = vi.fn();
await render(<SubFetch requestId="details" />, port, [], publish);
await settle();
expect(port.query).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenLastCalledWith("details", {
data: { name: "Bulbasaur" },
status: expect.objectContaining({ loading: false, ok: true, status: 200 }),
});
expect(host.querySelector("button, p, code, [role='alert']")).toBeNull();
});
it("uses a built-in manual trigger and passes successful request metadata to children", async () => {
const port = createPort();
await render(
<SubFetch requestId="details" trigger="manual">
{(data, metadata) => (
<span>{`${(data as { name: string }).name}:${metadata.status}:${String(metadata.loading)}`}</span>
)}
</SubFetch>,
port,
);
expect(port.query).not.toHaveBeenCalled();
expect(host.textContent).toContain("Load");
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur:200:false");
});
it("keeps manual query results local instead of publishing a shared request slot", async () => {
const port = createPort();
const publish = vi.fn();
await render(
<SubFetch requestId="details" trigger="manual" triggerContent={<span>Bulbasaur card</span>}>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
[],
publish,
);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(publish).not.toHaveBeenCalled();
expect(host.textContent).toContain("Bulbasaur");
});
it("runs a manual query from a custom accessible trigger", async () => {
const port = createPort();
await render(
<SubFetch
requestId="details"
trigger="manual"
triggerAriaLabel="Open Bulbasaur"
triggerContent={<span>Bulbasaur card</span>}
>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
);
const trigger = host.querySelector("button") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Open Bulbasaur");
expect(host.textContent).toContain("Bulbasaur card");
await act(async () => trigger.click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur");
});
@@ -129,8 +419,15 @@ describe("Custom Widget runtime ports", () => {
it("confirms DELETE actions, reports success, and invalidates declared targets", async () => {
const port = createPort();
await render(<ActionButton requestId="remove" label="Remove" invalidate={["parent"]} />, port, [
{ id: "remove", kind: "action", method: "DELETE", minimumBoardPermission: "full" },
await render(<ActionButton requestId="remove">Remove</ActionButton>, port, [
{
id: "remove",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
invalidates: ["parent"],
},
]);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
@@ -144,7 +441,10 @@ describe("Custom Widget runtime ports", () => {
const port = createPort({
executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })),
});
await render(<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />, port);
await render(
<ToggleSwitch requestId="toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
@@ -154,4 +454,25 @@ describe("Custom Widget runtime ports", () => {
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" }));
expect(port.invalidate).not.toHaveBeenCalled();
});
it("confirms a DELETE toggle before executing it", async () => {
const port = createPort();
await render(
<ToggleSwitch requestId="delete-toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
[
{
id: "delete-toggle",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
},
],
);
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(port.confirm).toHaveBeenCalledWith(expect.objectContaining({ destructive: true }));
expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true }));
});
});