404 lines
10 KiB
TypeScript
404 lines
10 KiB
TypeScript
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import React from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { BlockRenderer } from "../src/renderer.js";
|
|
import type { BlockInteraction, FormBlock } from "../src/types.js";
|
|
|
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
|
|
vi.mock("@cloudflare/kumo", () => ({
|
|
Button: ({ children, onClick, variant, type }: any) => (
|
|
<button onClick={onClick} data-variant={variant} type={type || "button"}>
|
|
{children}
|
|
</button>
|
|
),
|
|
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
|
|
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
|
|
<div>
|
|
<label>{label}</label>
|
|
<input
|
|
type={type || "text"}
|
|
defaultValue={defaultValue}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
min={min}
|
|
max={max}
|
|
onChange={onChange}
|
|
onBlur={onBlur}
|
|
/>
|
|
</div>
|
|
),
|
|
InputArea: ({ label, defaultValue, onChange, onBlur, placeholder }: any) => (
|
|
<div>
|
|
<label>{label}</label>
|
|
<textarea
|
|
defaultValue={defaultValue}
|
|
placeholder={placeholder}
|
|
onChange={onChange}
|
|
onBlur={onBlur}
|
|
/>
|
|
</div>
|
|
),
|
|
Select: Object.assign(
|
|
({ children, label, defaultValue, onValueChange }: any) => (
|
|
<div>
|
|
<label>{label}</label>
|
|
<select defaultValue={defaultValue} onChange={(e: any) => onValueChange?.(e.target.value)}>
|
|
{children}
|
|
</select>
|
|
</div>
|
|
),
|
|
{
|
|
Option: ({ children, value }: any) => <option value={value}>{children}</option>,
|
|
},
|
|
),
|
|
Switch: ({ label, checked, onCheckedChange }: any) => (
|
|
<div>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
|
|
/>
|
|
{label}
|
|
</label>
|
|
</div>
|
|
),
|
|
SensitiveInput: ({
|
|
label,
|
|
value,
|
|
onValueChange,
|
|
readOnly,
|
|
onFocus,
|
|
onBlur,
|
|
placeholder,
|
|
}: any) => (
|
|
<div>
|
|
<label>{label}</label>
|
|
<input
|
|
type="password"
|
|
value={value}
|
|
readOnly={readOnly}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
placeholder={placeholder}
|
|
onChange={(e: any) => onValueChange?.(e.target.value)}
|
|
/>
|
|
</div>
|
|
),
|
|
Dialog: ({ children }: any) => <div data-testid="dialog">{children}</div>,
|
|
DialogRoot: ({ children, open }: any) =>
|
|
open ? <div data-testid="dialog-root">{children}</div> : null,
|
|
Banner: ({ title, description, variant, icon }: any) => (
|
|
<div data-testid="banner" data-variant={variant}>
|
|
{icon}
|
|
{title && <strong>{title}</strong>}
|
|
{description && <p>{description}</p>}
|
|
</div>
|
|
),
|
|
Meter: ({ label, value, max, min, customValue }: any) => (
|
|
<div data-testid="meter" data-value={value} data-max={max} data-min={min}>
|
|
<span>{label}</span>
|
|
{customValue && <span>{customValue}</span>}
|
|
</div>
|
|
),
|
|
CodeBlock: ({ code, lang }: any) => (
|
|
<pre data-testid="code-block" data-lang={lang}>
|
|
<code>{code}</code>
|
|
</pre>
|
|
),
|
|
Checkbox: {
|
|
Group: ({ children, legend }: any) => (
|
|
<fieldset data-testid="checkbox-group">
|
|
<legend>{legend}</legend>
|
|
{children}
|
|
</fieldset>
|
|
),
|
|
Item: ({ label, value }: any) => (
|
|
<label>
|
|
<input type="checkbox" value={value} />
|
|
{label}
|
|
</label>
|
|
),
|
|
},
|
|
Radio: {
|
|
Group: ({ children, legend }: any) => (
|
|
<fieldset data-testid="radio-group">
|
|
<legend>{legend}</legend>
|
|
{children}
|
|
</fieldset>
|
|
),
|
|
Item: ({ label, value }: any) => (
|
|
<label>
|
|
<input type="radio" value={value} />
|
|
{label}
|
|
</label>
|
|
),
|
|
},
|
|
Combobox: Object.assign(
|
|
({ children, label }: any) => (
|
|
<div data-testid="combobox">
|
|
<label>{label}</label>
|
|
{children}
|
|
</div>
|
|
),
|
|
{
|
|
TriggerInput: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
|
Content: ({ children }: any) => <div>{children}</div>,
|
|
List: ({ children }: any) => <div>{typeof children === "function" ? null : children}</div>,
|
|
Item: ({ children, value }: any) => <div data-value={value}>{children}</div>,
|
|
Empty: ({ children }: any) => <div>{children}</div>,
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("@cloudflare/kumo/components/chart", () => ({
|
|
TimeseriesChart: (props: any) => (
|
|
<div data-testid="timeseries-chart" data-height={props.height} />
|
|
),
|
|
Chart: (props: any) => <div data-testid="custom-chart" data-height={props.height} />,
|
|
ChartPalette: { color: (i: number) => `#color${i}` },
|
|
}));
|
|
|
|
// eslint-disable-next-line unicorn/consistent-function-scoping -- vi.mock is hoisted; cannot reference outer scope
|
|
vi.mock("echarts/core", () => {
|
|
const noop = () => {};
|
|
return { __esModule: true, default: { use: noop }, use: noop };
|
|
});
|
|
|
|
vi.mock("echarts/charts", () => ({
|
|
BarChart: {},
|
|
LineChart: {},
|
|
PieChart: {},
|
|
}));
|
|
|
|
vi.mock("echarts/components", () => ({
|
|
AriaComponent: {},
|
|
AxisPointerComponent: {},
|
|
GridComponent: {},
|
|
TooltipComponent: {},
|
|
}));
|
|
|
|
vi.mock("echarts/renderers", () => ({
|
|
CanvasRenderer: {},
|
|
}));
|
|
|
|
vi.mock("@phosphor-icons/react", () => ({
|
|
ArrowUp: () => <span data-testid="arrow-up" />,
|
|
ArrowDown: () => <span data-testid="arrow-down" />,
|
|
Minus: () => <span data-testid="minus" />,
|
|
Info: () => <span data-testid="icon-info" />,
|
|
Warning: () => <span data-testid="icon-warning" />,
|
|
WarningCircle: () => <span data-testid="icon-warning-circle" />,
|
|
}));
|
|
|
|
afterEach(cleanup);
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function renderForm(form: FormBlock, onAction?: (i: BlockInteraction) => void) {
|
|
const handler = onAction ?? vi.fn();
|
|
return {
|
|
...render(<BlockRenderer blocks={[form]} onAction={handler} />),
|
|
onAction: handler,
|
|
};
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("form conditional fields", () => {
|
|
it("condition eq: field shown when condition met", () => {
|
|
renderForm({
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "toggle",
|
|
action_id: "enable",
|
|
label: "Enable",
|
|
initial_value: true,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "name",
|
|
label: "Name",
|
|
condition: { field: "enable", eq: true },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
});
|
|
|
|
// Toggle is true → Name field should be visible
|
|
expect(screen.getByText("Name")).toBeTruthy();
|
|
});
|
|
|
|
it("condition eq: field hidden when condition not met", () => {
|
|
renderForm({
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "toggle",
|
|
action_id: "enable",
|
|
label: "Enable",
|
|
initial_value: false,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "name",
|
|
label: "Name",
|
|
condition: { field: "enable", eq: true },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
});
|
|
|
|
// Toggle is false → Name field should not be rendered
|
|
expect(screen.queryByText("Name")).toBeNull();
|
|
});
|
|
|
|
it("condition neq: field shown when value differs", () => {
|
|
renderForm({
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "select",
|
|
action_id: "status",
|
|
label: "Status",
|
|
options: [
|
|
{ label: "Active", value: "active" },
|
|
{ label: "Disabled", value: "disabled" },
|
|
],
|
|
initial_value: "active",
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "reason",
|
|
label: "Reason",
|
|
condition: { field: "status", neq: "disabled" },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
});
|
|
|
|
// Status is "active" which is != "disabled" → Reason field visible
|
|
expect(screen.getByText("Reason")).toBeTruthy();
|
|
});
|
|
|
|
it("condition reacts to changes", () => {
|
|
renderForm({
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "toggle",
|
|
action_id: "show_extra",
|
|
label: "Show extra",
|
|
initial_value: false,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "extra",
|
|
label: "Extra field",
|
|
condition: { field: "show_extra", eq: true },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
});
|
|
|
|
// Initially hidden
|
|
expect(screen.queryByText("Extra field")).toBeNull();
|
|
|
|
// Click toggle to enable
|
|
const toggle = screen.getByRole("checkbox");
|
|
fireEvent.click(toggle);
|
|
|
|
// Now visible
|
|
expect(screen.getByText("Extra field")).toBeTruthy();
|
|
});
|
|
|
|
it("hidden field values are included in submit payload", () => {
|
|
const onAction = vi.fn();
|
|
renderForm(
|
|
{
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "toggle",
|
|
action_id: "show_name",
|
|
label: "Show name",
|
|
initial_value: true,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "name",
|
|
label: "Name",
|
|
initial_value: "Alice",
|
|
condition: { field: "show_name", eq: true },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
},
|
|
onAction,
|
|
);
|
|
|
|
// Field is visible, then hide it by toggling off
|
|
const toggle = screen.getByRole("checkbox");
|
|
fireEvent.click(toggle);
|
|
|
|
// Name field is now hidden
|
|
expect(screen.queryByText("Name")).toBeNull();
|
|
|
|
// Submit — hidden field's last known value should still be in payload
|
|
fireEvent.click(screen.getByText("Save"));
|
|
|
|
expect(onAction).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "form_submit",
|
|
action_id: "save",
|
|
values: expect.objectContaining({
|
|
show_name: false,
|
|
name: "Alice",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("multiple conditional fields evaluate independently", () => {
|
|
renderForm({
|
|
type: "form",
|
|
fields: [
|
|
{
|
|
type: "toggle",
|
|
action_id: "toggle_a",
|
|
label: "Toggle A",
|
|
initial_value: true,
|
|
},
|
|
{
|
|
type: "toggle",
|
|
action_id: "toggle_b",
|
|
label: "Toggle B",
|
|
initial_value: false,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "field_a",
|
|
label: "Field A",
|
|
condition: { field: "toggle_a", eq: true },
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "field_b",
|
|
label: "Field B",
|
|
condition: { field: "toggle_b", eq: true },
|
|
},
|
|
],
|
|
submit: { label: "Save", action_id: "save" },
|
|
});
|
|
|
|
// Toggle A is true → Field A visible
|
|
expect(screen.getByText("Field A")).toBeTruthy();
|
|
|
|
// Toggle B is false → Field B hidden
|
|
expect(screen.queryByText("Field B")).toBeNull();
|
|
});
|
|
});
|