feat(plugins): read-only form fields and table row interactions (#2013)

API 1.3 through the released `other` extension cases, no WIT change: form fields `label` (plain value) and `readonly-text` (selectable, optionally multi-line, drawn as read-only), and table view-events `table-select` / `table-activate` (click, Space; double-click, Enter) with absolute row indices. Table rows also get the view's `viewItem.context` commands, keyed by row. SDK: `FieldValue::label`, `FieldValue::readonly_text`, `ViewEvent::table_select` / `table_activate`. Closes #1953.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 23:36:59 +08:00
1 джерело 1891119f9d
коміт dc69c36604
39 files changed
+502 -41

No files matched your search

+181 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import type { View, ViewEvent } from "@/bindings";
@@ -419,6 +419,7 @@ describe("PluginView", () => {
render(<PluginView pluginId="csv" view="table" doc={7} />);
await user.dblClick(await screen.findByText("London"));
const input = screen.getByLabelText("Cell value");
expect(events.some((e) => e.kind === "tableActivate")).toBe(false);
await user.clear(input);
await user.type(input, "Paris{Enter}");
await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" }));
@@ -533,3 +534,182 @@ describe("PluginView", () => {
expect(await screen.findByText("boom")).toBeInTheDocument();
});
});
describe("API 1.3 read-only values and table rows", () => {
it("shows selectable labels and readonly text without sending field changes", async () => {
const events: ViewEvent[] = [];
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "form",
value: [
{ key: "title", label: "Title", value: { kind: "label", value: "Merge request" } },
{
key: "author",
label: "Author",
value: { kind: "readonlyText", value: { text: "Ada", multiline: false } },
},
{
key: "body",
label: "Description",
value: { kind: "readonlyText", value: { text: "one\ntwo", multiline: true } },
},
],
},
}),
events,
),
);
const user = userEvent.setup();
render(<PluginView pluginId="jobs" view="form" />);
expect(await screen.findByLabelText("Title")).toHaveTextContent("Merge request");
for (const label of ["Author", "Description"]) {
const input = screen.getByLabelText(label);
expect(input).toHaveAttribute("readonly");
await user.type(input, "changed");
await user.tab();
}
expect(screen.getByLabelText("Author")).toHaveValue("Ada");
expect(screen.getByLabelText("Description")).toHaveValue("one\ntwo");
expect(events).toEqual([]);
});
it("keeps editable table row actions beside contributed commands", async () => {
const events: ViewEvent[] = [];
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Value"],
rows: [["Editable"]],
totalRows: 1,
firstRow: 0,
editable: true,
sortable: false,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="table" itemMenu={() => [{ label: "Inspect editable", run: () => {} }]} />
</>,
);
const cell = await screen.findByText("Editable");
await user.pointer({ target: cell, keys: "[MouseRight]" });
expect(screen.getByText("Inspect editable")).toBeInTheDocument();
expect(screen.getByText("Insert Row Above")).toBeInTheDocument();
await user.click(screen.getByText("Delete Row"));
expect(events).toContainEqual({ kind: "deleteRows", rows: [0] });
hideContextMenu();
});
it("uses absolute row indices after paging and supports keyboard context menus", async () => {
const events: ViewEvent[] = [];
const itemMenu = vi.fn(() => [{ label: "Inspect paged row", run: () => {} }]);
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Route"],
rows: [["/paged"]],
totalRows: 100,
firstRow: 50,
editable: false,
sortable: true,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="routes" itemMenu={itemMenu} />
</>,
);
const grid = await screen.findByRole("grid");
fireEvent.scroll(grid, { target: { scrollTop: 50 * 24 } });
const cell = await screen.findByText("/paged");
await user.click(cell);
expect(events).toContainEqual({ kind: "tableSelect", row: 50 });
(cell.closest('[role="row"]') as HTMLElement).focus();
await user.keyboard("{Enter}{Shift>}{F10}{/Shift}");
expect(events).toContainEqual({ kind: "tableActivate", row: 50 });
expect(itemMenu).toHaveBeenCalledWith("50");
expect(screen.getByText("Inspect paged row")).toBeInTheDocument();
hideContextMenu();
});
it.each([false, true])(
"sends absolute row events and contributed context commands (sortable=%s)",
async (sortable) => {
const events: ViewEvent[] = [];
const menuRun = vi.fn();
const itemMenu = vi.fn((key: string) => [{ label: `Inspect ${key}`, run: menuRun }]);
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Route"],
rows: [["/users"], ["/posts"]],
totalRows: 2,
firstRow: 0,
editable: false,
sortable,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="routes" itemMenu={itemMenu} />
</>,
);
const cell = await screen.findByText("/posts");
await user.click(cell);
expect(events).toContainEqual({ kind: "tableSelect", row: 1 });
expect(cell.closest('[role="row"]')).toHaveAttribute("aria-selected", "true");
await user.dblClick(cell);
expect(events).toContainEqual({ kind: "tableActivate", row: 1 });
const row = cell.closest('[role="row"]') as HTMLElement;
row.focus();
await user.keyboard("{Enter} ");
expect(events.filter((e) => e.kind === "tableActivate")).toHaveLength(2);
await user.pointer({ target: cell, keys: "[MouseRight]" });
expect(itemMenu).toHaveBeenCalledWith("1");
await user.click(screen.getByText("Inspect 1"));
expect(menuRun).toHaveBeenCalledOnce();
expect(screen.queryByText("Insert Row Above")).not.toBeInTheDocument();
hideContextMenu();
},
);
});