Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

216 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { List } from "../src/widgets/list";
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, icon, "aria-label": ariaLabel, disabled }: any) => (
<button type="button" onClick={onClick} aria-label={ariaLabel} disabled={disabled}>
{icon}
{children}
</button>
),
Input: ({ label, value, onChange, type, id }: any) => (
<label>
{label}
<input id={id} type={type ?? "text"} value={value ?? ""} onChange={onChange} />
</label>
),
InputArea: ({ label, value, onChange, id }: any) => (
<label>
{label}
<textarea id={id} value={value ?? ""} onChange={onChange} />
</label>
),
Select: ({ label, value, onValueChange, items }: any) => (
<label>
{label}
<select value={value ?? ""} onChange={(e) => onValueChange?.(e.target.value)}>
<option value=""></option>
{(items ?? []).map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
),
Switch: ({ label, checked, onCheckedChange, id }: any) => (
<label>
{label}
<input
id={id}
role="switch"
type="checkbox"
checked={!!checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
/>
</label>
),
}));
vi.mock("@phosphor-icons/react", () => ({
CaretRight: () => <span></span>,
CaretUp: () => <span></span>,
CaretDown: () => <span></span>,
Plus: () => <span>+</span>,
X: () => <span>×</span>,
}));
afterEach(() => cleanup());
const fields = [
{ key: "name", label: "Name", type: "text" as const },
{ key: "amount", label: "Amount", type: "text" as const },
];
describe("List widget", () => {
it("renders each item as a summary row using the summary template", () => {
render(
<List
value={[
{ name: "Flour", amount: "500g" },
{ name: "Sugar", amount: "100g" },
]}
onChange={() => {}}
label="Ingredients"
id="ing"
options={{ fields, summary: "{{name}} — {{amount}}" }}
/>,
);
expect(screen.getByRole("button", { name: /Flour — 500g/ })).not.toBeNull();
expect(screen.getByRole("button", { name: /Sugar — 100g/ })).not.toBeNull();
});
it("falls back to itemLabel + index when no summary template", () => {
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, itemLabel: "Thing" }}
/>,
);
// Summary buttons for both rows use itemLabel + index
const summaryButtons = screen
.getAllByRole("button")
.filter((b) => /Thing \d$/.test(b.textContent ?? ""));
expect(summaryButtons.map((b) => b.textContent?.trim())).toEqual(
expect.arrayContaining([
expect.stringMatching(/Thing 1$/),
expect.stringMatching(/Thing 2$/),
]),
);
});
it("adds a new empty item when Add is clicked", () => {
const onChange = vi.fn();
render(<List value={[]} onChange={onChange} label="Items" id="x" options={{ fields }} />);
fireEvent.click(screen.getByRole("button", { name: /Add Item/ }));
expect(onChange).toHaveBeenCalledWith([{ name: undefined, amount: undefined }]);
});
it("removes an item", () => {
const onChange = vi.fn();
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={onChange}
label="Items"
id="x"
options={{ fields }}
/>,
);
const [, removeB] = screen.getAllByRole("button", {
name: /Remove Item/,
});
fireEvent.click(removeB!);
expect(onChange).toHaveBeenCalledWith([{ name: "a", amount: undefined }]);
});
it("reorders items with move down", () => {
const onChange = vi.fn();
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={onChange}
label="Items"
id="x"
options={{ fields }}
/>,
);
const downButtons = screen.getAllByLabelText("Move down");
fireEvent.click(downButtons[0]!);
expect(onChange).toHaveBeenCalledWith([
{ name: "b", amount: undefined },
{ name: "a", amount: undefined },
]);
});
it("respects max: add button disappears at limit", () => {
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, max: 2 }}
/>,
);
expect(screen.queryByRole("button", { name: /Add/i })).toBeNull();
});
it("respects min: remove buttons disappear at limit", () => {
render(
<List
value={[{ name: "a" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, min: 1 }}
/>,
);
expect(screen.queryAllByRole("button", { name: /Remove Item/ })).toHaveLength(0);
});
it("shows empty-state message when no items", () => {
render(<List value={[]} onChange={() => {}} label="Items" id="x" options={{ fields }} />);
expect(screen.queryByText(/No items yet/i)).not.toBeNull();
});
it("shows misconfigured warning when fields is empty", () => {
render(<List value={[]} onChange={() => {}} label="Items" id="x" options={{ fields: [] }} />);
expect(screen.queryByText(/Widget misconfigured/i)).not.toBeNull();
});
it("scopes sub-field ids under the parent field id for each expanded item", () => {
const { container } = render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Ingredients"
id="ing"
options={{ fields }}
/>,
);
// Default: first item expanded → sub-field id scoped to parent "ing"
let nameInputs = container.querySelectorAll('input[id*="-name"]');
expect(nameInputs.length).toBe(1);
const firstId = (nameInputs[0] as HTMLInputElement).id;
expect(firstId.startsWith("ing-")).toBe(true);
expect(firstId.endsWith("-name")).toBe(true);
// Collapse first, expand second → distinct id because stable key differs
fireEvent.click(screen.getByRole("button", { name: /^▸ Item 1$/ }));
fireEvent.click(screen.getByRole("button", { name: /^▸ Item 2$/ }));
nameInputs = container.querySelectorAll('input[id*="-name"]');
expect(nameInputs.length).toBe(1);
const secondId = (nameInputs[0] as HTMLInputElement).id;
expect(secondId.startsWith("ing-")).toBe(true);
expect(secondId.endsWith("-name")).toBe(true);
expect(secondId).not.toBe(firstId);
});
});