Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
167
packages/plugins/field-kit/tests/grid.test.tsx
Normal file
167
packages/plugins/field-kit/tests/grid.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Grid } from "../src/widgets/grid";
|
||||
|
||||
vi.mock("@cloudflare/kumo", () => ({
|
||||
Checkbox: ({ checked, onCheckedChange, "aria-label": ariaLabel }: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={ariaLabel}
|
||||
checked={!!checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
Input: ({ value, onChange, "aria-label": ariaLabel, type }: any) => (
|
||||
<input type={type ?? "text"} aria-label={ariaLabel} value={value ?? ""} onChange={onChange} />
|
||||
),
|
||||
Select: ({ value, onValueChange, items, "aria-label": ariaLabel }: any) => (
|
||||
<select
|
||||
aria-label={ariaLabel}
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const rows = [
|
||||
{ key: "mon", label: "Mon" },
|
||||
{ key: "tue", label: "Tue" },
|
||||
];
|
||||
const columns = [
|
||||
{ key: "am", label: "AM" },
|
||||
{ key: "pm", label: "PM" },
|
||||
];
|
||||
|
||||
describe("Grid widget", () => {
|
||||
it("renders all cells as toggle checkboxes by default", () => {
|
||||
render(<Grid value={{}} onChange={() => {}} label="Grid" id="g" options={{ rows, columns }} />);
|
||||
const boxes = screen.getAllByRole("checkbox");
|
||||
expect(boxes).toHaveLength(4); // 2 rows × 2 cols
|
||||
});
|
||||
|
||||
it("reflects existing toggle values", () => {
|
||||
render(
|
||||
<Grid
|
||||
value={{ mon: { am: true, pm: false }, tue: { am: true } }}
|
||||
onChange={() => {}}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows, columns }}
|
||||
/>,
|
||||
);
|
||||
expect((screen.getByLabelText("Mon — AM") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Mon — PM") as HTMLInputElement).checked).toBe(false);
|
||||
expect((screen.getByLabelText("Tue — AM") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes legacy array format on read", () => {
|
||||
render(
|
||||
<Grid
|
||||
value={{ mon: ["am", "pm"], tue: ["am"] }}
|
||||
onChange={() => {}}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows, columns }}
|
||||
/>,
|
||||
);
|
||||
expect((screen.getByLabelText("Mon — AM") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Mon — PM") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Tue — AM") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Tue — PM") as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("emits object-shape on toggle write (even when input was array format)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Grid
|
||||
value={{ mon: ["am"] }}
|
||||
onChange={onChange}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows, columns }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Mon — PM"));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mon: { am: true, pm: true },
|
||||
tue: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders text cells when cell is 'text'", () => {
|
||||
render(
|
||||
<Grid
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows, columns, cell: "text" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByRole("textbox")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders select cells with cellOptions", () => {
|
||||
render(
|
||||
<Grid
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{
|
||||
rows,
|
||||
columns,
|
||||
cell: "select",
|
||||
cellOptions: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const selects = screen.getAllByRole("combobox");
|
||||
expect(selects).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("preserves unknown cell keys on write so evolving schemas don't drop data", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Grid
|
||||
value={{ mon: { am: true, legacy: "keep-me" } }}
|
||||
onChange={onChange}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows, columns }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Mon — PM"));
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mon: { am: true, pm: true, legacy: "keep-me" },
|
||||
tue: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows misconfigured warning when rows or columns are missing", () => {
|
||||
render(
|
||||
<Grid
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Grid"
|
||||
id="g"
|
||||
options={{ rows: [], columns: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Widget misconfigured/i)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
215
packages/plugins/field-kit/tests/list.test.tsx
Normal file
215
packages/plugins/field-kit/tests/list.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
183
packages/plugins/field-kit/tests/object-form.test.tsx
Normal file
183
packages/plugins/field-kit/tests/object-form.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ObjectForm } from "../src/widgets/object-form";
|
||||
|
||||
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, required }: any) => (
|
||||
<label>
|
||||
{typeof label === "string" ? label : label}
|
||||
<input
|
||||
id={id}
|
||||
type={type ?? "text"}
|
||||
value={value ?? ""}
|
||||
required={required}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
InputArea: ({ label, value, onChange, id, required }: any) => (
|
||||
<label>
|
||||
{typeof label === "string" ? label : label}
|
||||
<textarea id={id} value={value ?? ""} required={required} onChange={onChange} />
|
||||
</label>
|
||||
),
|
||||
Select: ({ label, value, onValueChange, items, required }: any) => (
|
||||
<label>
|
||||
{typeof label === "string" ? label : label}
|
||||
<select
|
||||
value={value ?? ""}
|
||||
required={required}
|
||||
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}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={!!checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
CaretRight: () => <span>▸</span>,
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("ObjectForm widget", () => {
|
||||
it("renders sub-fields from options.fields", () => {
|
||||
render(
|
||||
<ObjectForm
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Nutrition"
|
||||
id="nut"
|
||||
options={{
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "count", label: "Count", type: "number" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Name")).not.toBeNull();
|
||||
expect(screen.getByText("Count")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("populates sub-field values from the stored object", () => {
|
||||
render(
|
||||
<ObjectForm
|
||||
value={{ name: "flour", count: 3 }}
|
||||
onChange={() => {}}
|
||||
label="Nutrition"
|
||||
id="nut"
|
||||
options={{
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "count", label: "Count", type: "number" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue("flour")).not.toBeNull();
|
||||
expect(screen.getByDisplayValue("3")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("emits the full object on field change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ObjectForm
|
||||
value={{ name: "flour", count: 3 }}
|
||||
onChange={onChange}
|
||||
label="Nutrition"
|
||||
id="nut"
|
||||
options={{
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "count", label: "Count", type: "number" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByDisplayValue("flour"), {
|
||||
target: { value: "sugar" },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith({ name: "sugar", count: 3 });
|
||||
});
|
||||
|
||||
it("shows misconfigured warning when fields is empty", () => {
|
||||
render(
|
||||
<ObjectForm
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Empty"
|
||||
id="empty"
|
||||
options={{ fields: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Widget misconfigured/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves unknown keys not defined in options.fields", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ObjectForm
|
||||
value={{ name: "a", stray: "unexpected" }}
|
||||
onChange={onChange}
|
||||
label="Form"
|
||||
id="f"
|
||||
options={{
|
||||
fields: [{ key: "name", label: "Name", type: "text" }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByDisplayValue("a"), {
|
||||
target: { value: "b" },
|
||||
});
|
||||
// onChange should pass along keys not managed by this widget so stored
|
||||
// JSON round-trips cleanly when the schema evolves.
|
||||
const payload = onChange.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(payload).toEqual({ name: "b", stray: "unexpected" });
|
||||
});
|
||||
|
||||
it("gives each sub-field a unique DOM id composed from the parent id", () => {
|
||||
const { container } = render(
|
||||
<ObjectForm
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
label="Form"
|
||||
id="nutrition"
|
||||
options={{
|
||||
fields: [
|
||||
{ key: "calories", label: "Calories", type: "number" },
|
||||
{ key: "protein", label: "Protein", type: "number" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("#nutrition-calories")).not.toBeNull();
|
||||
expect(container.querySelector("#nutrition-protein")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
131
packages/plugins/field-kit/tests/tags.test.tsx
Normal file
131
packages/plugins/field-kit/tests/tags.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Tags } from "../src/widgets/tags";
|
||||
|
||||
// ── Kumo mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock("@cloudflare/kumo", () => ({
|
||||
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
|
||||
Button: ({ children, onClick, icon, "aria-label": ariaLabel }: any) => (
|
||||
<button type="button" onClick={onClick} aria-label={ariaLabel}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
X: () => <span>×</span>,
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// An <input> with a `list` attribute has role="combobox"; without it, "textbox".
|
||||
// Both are the same HTML element in our widget; query by id for consistency.
|
||||
function findInput(id = "tags"): HTMLInputElement | null {
|
||||
return document.querySelector(`input#${id}`);
|
||||
}
|
||||
|
||||
describe("Tags widget", () => {
|
||||
it("renders existing tags as chips", () => {
|
||||
render(<Tags value={["a", "b", "c"]} onChange={() => {}} label="Tags" id="tags" />);
|
||||
const badges = screen.getAllByTestId("badge");
|
||||
// each badge renders tag + mocked remove icon; check the tag text is present
|
||||
expect(badges).toHaveLength(3);
|
||||
expect(badges[0]!.textContent).toContain("a");
|
||||
expect(badges[1]!.textContent).toContain("b");
|
||||
expect(badges[2]!.textContent).toContain("c");
|
||||
});
|
||||
|
||||
it("adds a tag on Enter", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Tags value={[]} onChange={onChange} label="Tags" id="tags" />);
|
||||
const input = findInput()!;
|
||||
fireEvent.change(input, { target: { value: "new-tag" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
|
||||
});
|
||||
|
||||
it("removes a tag when its remove button is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Tags value={["keep", "drop"]} onChange={onChange} label="Tags" id="tags" />);
|
||||
const removeButton = screen.getByLabelText("Remove drop");
|
||||
fireEvent.click(removeButton);
|
||||
expect(onChange).toHaveBeenCalledWith(["keep"]);
|
||||
});
|
||||
|
||||
it("deduplicates tags", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Tags value={["a"]} onChange={onChange} label="Tags" id="tags" />);
|
||||
const input = findInput()!;
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces max", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tags value={["a", "b"]} onChange={onChange} label="Tags" id="tags" options={{ max: 2 }} />,
|
||||
);
|
||||
// input is hidden when at limit
|
||||
expect(findInput()).toBeNull();
|
||||
});
|
||||
|
||||
it("applies lowercase transform", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tags
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
label="Tags"
|
||||
id="tags"
|
||||
options={{ transform: "lowercase" }}
|
||||
/>,
|
||||
);
|
||||
const input = findInput()!;
|
||||
fireEvent.change(input, { target: { value: "FooBar" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["foobar"]);
|
||||
});
|
||||
|
||||
it("rejects non-suggestion when allowCustom is false", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tags
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
label="Tags"
|
||||
id="tags"
|
||||
options={{ allowCustom: false, suggestions: ["apple", "banana"] }}
|
||||
/>,
|
||||
);
|
||||
const input = findInput()!;
|
||||
fireEvent.change(input, { target: { value: "cherry" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts suggestion when allowCustom is false", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Tags
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
label="Tags"
|
||||
id="tags"
|
||||
options={{ allowCustom: false, suggestions: ["apple", "banana"] }}
|
||||
/>,
|
||||
);
|
||||
const input = findInput()!;
|
||||
fireEvent.change(input, { target: { value: "apple" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["apple"]);
|
||||
});
|
||||
|
||||
it("normalizes non-array value to empty array", () => {
|
||||
render(<Tags value={"not-an-array"} onChange={() => {}} label="Tags" id="tags" />);
|
||||
expect(screen.queryAllByTestId("badge")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user