feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
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 }));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user