дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
1891119f9d
коміт
dc69c36604
39 files changed
+502
-41
No files matched your search
@@ -3792,7 +3792,10 @@ export type FacetConfig = {
|
||||
webRoot?: string,
|
||||
};
|
||||
|
||||
export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: {
|
||||
export type FieldValue = { kind: "label"; value: string } | { kind: "readonlyText"; value: {
|
||||
text: string,
|
||||
multiline: boolean,
|
||||
} } | { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: {
|
||||
options: string[],
|
||||
selected: string,
|
||||
} };
|
||||
@@ -8127,7 +8130,7 @@ export type ViewButton = {
|
||||
};
|
||||
|
||||
/** A user action in a view. */
|
||||
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean };
|
||||
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean } | { kind: "tableSelect"; row: number } | { kind: "tableActivate"; row: number };
|
||||
|
||||
export type ViewField = {
|
||||
key: string,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -226,3 +226,22 @@
|
||||
width: var(--h-floating-toolbar-button);
|
||||
height: var(--h-floating-toolbar-button);
|
||||
}
|
||||
|
||||
.readonlyValue {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Selectable, but framed like a disabled field so it doesn't read as editable. */
|
||||
.readonlyField:read-only {
|
||||
border: 0;
|
||||
border-radius: var(--radius-control, 4px);
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
|
||||
.tableRow:focus-visible {
|
||||
outline: 1px solid var(--focus-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
|
||||
import { tx, VerbatimTextContext } from "@/platform/i18n";
|
||||
import {
|
||||
@@ -46,7 +47,7 @@ export interface PluginViewProps {
|
||||
revision?: number;
|
||||
/** Called after an event was handled (custom editors re-render on the document instead). */
|
||||
label?: string;
|
||||
/** Context menu entries of a tree or list item, by item key. */
|
||||
/** Context menu entries by item key (tables use the absolute row index). */
|
||||
itemMenu?: (key: string) => MenuItem[];
|
||||
}
|
||||
|
||||
@@ -203,7 +204,7 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps
|
||||
</div>
|
||||
);
|
||||
case "table":
|
||||
return <TableBody table={body.value} send={send} onViewport={onViewport} />;
|
||||
return <TableBody table={body.value} send={send} onViewport={onViewport} itemMenu={itemMenu} />;
|
||||
case "html":
|
||||
return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />;
|
||||
}
|
||||
@@ -283,6 +284,17 @@ function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) =>
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
{v.kind === "label" && (
|
||||
<output id={id} className={styles.readonlyValue}>
|
||||
{v.value}
|
||||
</output>
|
||||
)}
|
||||
{v.kind === "readonlyText" &&
|
||||
(v.value.multiline ? (
|
||||
<textarea id={id} className={clsx(styles.textarea, styles.readonlyField)} value={v.value.text} readOnly />
|
||||
) : (
|
||||
<Input id={id} className={styles.readonlyField} value={v.value.text} readOnly />
|
||||
))}
|
||||
{v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />}
|
||||
{v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />}
|
||||
{v.kind === "checkbox" && (
|
||||
@@ -347,10 +359,12 @@ function TableBody({
|
||||
table,
|
||||
send,
|
||||
onViewport,
|
||||
itemMenu,
|
||||
}: {
|
||||
table: ViewTable;
|
||||
send: (e: ViewEvent) => void;
|
||||
onViewport: (v: Viewport) => void;
|
||||
itemMenu?: (key: string) => MenuItem[];
|
||||
}) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
@@ -375,10 +389,13 @@ function TableBody({
|
||||
if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows));
|
||||
}, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]);
|
||||
|
||||
const rowMenu = (row: number, e: React.MouseEvent) => {
|
||||
const rowMenu = (row: number, e: ReactMouseEvent | ReactKeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row];
|
||||
if (!selected.has(row)) setSelected(new Set([row]));
|
||||
if (!selected.has(row)) {
|
||||
setSelected(new Set([row]));
|
||||
send({ kind: "tableSelect", row });
|
||||
}
|
||||
const items: MenuItem[] = [
|
||||
{ label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) },
|
||||
{ label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) },
|
||||
@@ -390,7 +407,14 @@ function TableBody({
|
||||
run: () => send({ kind: "deleteRows", rows }),
|
||||
},
|
||||
];
|
||||
if (table.editable) showContextMenu(e, items);
|
||||
const contributed = itemMenu?.(String(row)) ?? [];
|
||||
const menu = table.editable
|
||||
? [...contributed, ...(contributed.length ? [{ kind: "separator" } as const] : []), ...items]
|
||||
: contributed;
|
||||
if (menu.length) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu("clientX" in e ? e : { x: rect.left, y: rect.bottom }, menu);
|
||||
}
|
||||
};
|
||||
|
||||
// A sortable table (`table` through the plugin API's `other` body case):
|
||||
@@ -411,15 +435,35 @@ function TableBody({
|
||||
aria-selected={selected.has(r)}
|
||||
className={styles.tableRow}
|
||||
style={{ top: (r + 1) * ROW_HEIGHT }}
|
||||
onClick={(e) =>
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("input, textarea")) return;
|
||||
setSelected((s) => {
|
||||
if (!(e.metaKey || e.ctrlKey)) return new Set([r]);
|
||||
const next = new Set(s);
|
||||
if (next.has(r)) next.delete(r);
|
||||
else next.add(r);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
});
|
||||
send({ kind: "tableSelect", row: r });
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (!table.editable || (e.target as HTMLElement).closest('[role="rowheader"]'))
|
||||
send({ kind: "tableActivate", row: r });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
send({ kind: "tableActivate", row: r });
|
||||
} else if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(new Set([r]));
|
||||
send({ kind: "tableSelect", row: r });
|
||||
} else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
|
||||
rowMenu(r, e);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => rowMenu(r, e)}
|
||||
>
|
||||
<div className={styles.rowNumber} role="rowheader">
|
||||
|
||||
Reference in new issue
Заблокувати користувача