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]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-07 23:36:59 +08:00
1 parent 1891119f9d
commit dc69c36604
39 files changed
+502 -41

No files matched your search

+24
View File
@@ -3569,6 +3569,30 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab
}
}
#[test]
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
let f = fixture(Limits::default());
f.install(&sample("tables"));
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
assert_eq!(
fields[2].value,
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
);
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
for (event, status) in [
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
] {
f.take_events();
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
}
}
#[test]
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
+61 -2
View File
@@ -1619,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
"readonly-text" => {
#[derive(serde::Deserialize)]
struct ReadonlyText {
text: String,
#[serde(default)]
multiline: bool,
}
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
FieldValue::ReadonlyText {
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
multiline: value.multiline,
}
}
_ => return None,
},
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
@@ -1673,6 +1688,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
ViewEvent::TableSelect { row } => {
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
}
ViewEvent::TableActivate { row } => {
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
}
// A sortable table's header click, as an `other` view-event (plugins/
// README.md §10): the SDKs decode the kinds they know.
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
@@ -1686,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[test]
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
let fields = [
("label", r#""Title \"quoted\"""#),
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
("readonly-text", r#"{"text":"author"}"#),
("label", "{}"),
("readonly-text", r#"{"text":false}"#),
("future", "null"),
]
.into_iter()
.enumerate()
.map(|(i, (kind, data))| wit_vt::Field {
key: i.to_string(),
label: "Field".into(),
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
})
.collect();
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
let ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
}
#[test]
fn table_row_events_leave_through_released_extension_case() {
for (event, kind) in [
(ViewEvent::TableSelect { row: 51 }, "table-select"),
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
] {
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
assert_eq!(extension.kind, kind);
assert_eq!(extension.data, r#"{"row":51}"#);
}
}
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
+4
View File
@@ -527,6 +527,8 @@ pub struct ViewListItem {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum FieldValue {
Label(String),
ReadonlyText { text: String, multiline: bool },
Text(String),
Multiline(String),
Checkbox(bool),
@@ -644,6 +646,8 @@ pub enum ViewEvent {
// A sortable table's header click: the column and the direction (leaves
// as an `other` view-event, kind `sort`). See `TableSort`.
Sort { column: u32, descending: bool },
TableSelect { row: u32 },
TableActivate { row: u32 },
}
/// A plugin's HTTP route declaration in a project.
@@ -1 +1 @@
627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4
73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b
@@ -1 +1 @@
56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662
4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90
Binary file not shown.
@@ -1 +1 @@
d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88
83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1
@@ -1 +1 @@
21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990
bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0
@@ -1 +1 @@
636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37
1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef
@@ -1 +1 @@
a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2
17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0
Binary file not shown.
@@ -1 +1 @@
a8b92a0ee9b46d8d414457ff287d6092e85d4536e913d146aa49662814ff1836
85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e
@@ -1 +1 @@
2fa3d6146a99eee79d4aec12bf8777369ae5cc76c3f7d6a8c254d5a27d58eeb9
ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6
@@ -1 +1 @@
c14d91010021e3b35f6ae29a2a327bb3ad66087f07cfd744814443862c0050cc
66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d
@@ -1 +1 @@
e9b6c10973ce62057eb3c92a747bcb27fdef1cfb6dd1d8681aa0035d3a3b5fa2
98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018
@@ -1 +1 @@
a873a709240a3d24c45481b220ac283103f10645166952cb5dcc809f7995e085
33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787
Binary file not shown.
@@ -1 +1 @@
8801a468856e0e4783848f89d51d6d6f45426e6cadf2b2532518500ff23aa6f7
92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615
+17 -2
View File
@@ -7,12 +7,27 @@ name = "Tables"
version = "0.1.0"
description = "Test fixture: a sortable table the plugin sorts itself."
authors = ["OpenIDEA contributors"]
engines.openidea = "^1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-view:tables"]
activation = ["on-view:tables", "on-view:fields", "on-command:inspect"]
[[contributes.views]]
id = "tables"
title = "Tables"
icon = "plugin"
location = "right"
[[contributes.views]]
id = "fields"
title = "Read-only Fields"
icon = "plugin"
location = "right"
[[contributes.commands]]
id = "inspect"
title = "Inspect Row"
[[contributes.menus]]
menu = "viewItem.context"
view = "tables"
command = "inspect"
+45 -3
View File
@@ -4,7 +4,7 @@
//! `sort`), and the rows render in the order the plugin was asked for.
use openidea_plugin::host;
use openidea_plugin::views::{Body, Table, TableSort, View, ViewEvent, Viewport, Views};
use openidea_plugin::views::{Body, Field, FieldValue, Table, TableSort, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
pub const VIEW: &str = "tables";
@@ -15,6 +15,8 @@ const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("tom
#[derive(Default)]
pub struct Tables {
sort: Option<TableSort>,
status: Option<String>,
note: Option<String>,
}
impl Tables {
@@ -34,13 +36,34 @@ impl Tables {
}
impl Plugin for Tables {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
if id == "inspect" {
self.status = Some(format!("context:{}", arg.unwrap_or("")));
host::refresh_view(VIEW);
return Ok(self.status.clone());
}
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for Tables {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view == "fields" {
return Ok(View::new(Body::Form(vec![
Field::new("title", "Title", FieldValue::label("Merge request #42")),
Field::new("author", "Author", FieldValue::readonly_text("Ada Lovelace", false)),
Field::new(
"description",
"Description",
FieldValue::readonly_text("Ready for review\nAll checks passed", true),
),
Field::new(
"editable",
"Editable note",
FieldValue::Text(self.note.clone().unwrap_or_else(|| "Edit me".into())),
),
])));
}
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
@@ -51,13 +74,32 @@ impl Views for Tables {
rows: self.rows(),
editable: false,
};
Ok(View::new(Body::sortable_table(&table, self.sort)))
let mut view = View::new(Body::sortable_table(&table, self.sort));
view.status = self.status.clone();
Ok(view)
}
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
if view == "fields" {
if let ViewEvent::Field(key, value) = event {
if key == "editable" {
self.note = Some(value);
host::refresh_view("fields");
}
}
return Ok(());
}
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
if let Some(row) = event.table_select() {
self.status = Some(format!("select:{row}"));
host::refresh_view(VIEW);
}
if let Some(row) = event.table_activate() {
self.status = Some(format!("activate:{row}"));
host::refresh_view(VIEW);
}
// The host may have encoded the click for a newer SDK: the typed
// helper reads the `other` kind `sort` this SDK knows.
if let Some(sort) = event.sort() {
+20
View File
@@ -639,6 +639,26 @@ For a table the user can sort by clicking a header, build it with
column headers clickable and asks for a column and direction with
`ViewEvent::sort()` — sort your rows and render again; the sort you report
shows as an arrow in the header.
API 1.3 adds read-only form values without changing the released field record:
`FieldValue::label(text)` uses `other { kind: "label", data }`, where `data`
is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind
`readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag
defaults to false). Labels render as plain selectable values; read-only text
keeps the single-line or multiline text control and never sends field events.
Table row clicks and Space send `other` kind `table-select`; double-click
and Enter send `table-activate`, both with JSON `{ "row": 0 }`.
`ViewEvent::table_select()` and `table_activate()` decode the absolute,
zero-based index, including `first_row` for paged tables. These work for both
plain tables and the same `other` table shape used by sortable tables above;
no new table shape is needed. Editable cells still double-click to edit;
activate their row with Enter on the focused row or double-click its row number.
`viewItem.context` menus also work on table rows: the command argument and
`viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables
keep their insert/delete entries alongside contributed commands. After sorting
or changing data, plugins resolve row indices against their current row order.
Declare `engines.openidea = "^1.3"` when relying on these additions.
Bodies, field types and view events added by later APIs travel as `Other {
kind, data }`: an IDE that doesn't know a body says it needs updating, and
leaves out a field type it doesn't know.
+1 -1
View File
@@ -1 +1 @@
d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9
abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c
+1 -1
View File
@@ -1 +1 @@
c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc
7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a
d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99
+1 -1
View File
@@ -1 +1 @@
648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781
2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a
+1 -1
View File
@@ -1 +1 @@
f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743
4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb
+1 -1
View File
@@ -1 +1 @@
936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174
d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748
+1 -1
View File
@@ -1 +1 @@
182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c
582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584
+1 -1
View File
@@ -1 +1 @@
f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8
3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868
2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c
+1 -1
View File
@@ -1 +1 @@
e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced
dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47
+1 -1
View File
@@ -1 +1 @@
318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c
8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340
+51
View File
@@ -125,6 +125,21 @@ pub enum FieldValue {
},
}
impl FieldValue {
/// A plain read-only value, carried by `other` kind `label` (API 1.3).
pub fn label(text: &str) -> Self {
Self::Other { kind: "label".into(), data: format!("\"{}\"", crate::text::escape_json(text)) }
}
/// Selectable text with no field-change events (API 1.3).
pub fn readonly_text(text: &str, multiline: bool) -> Self {
Self::Other {
kind: "readonly-text".into(),
data: format!(r#"{{"text":"{}","multiline":{multiline}}}"#, crate::text::escape_json(text)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Field {
pub key: String,
@@ -273,6 +288,21 @@ pub enum ViewEvent {
}
impl ViewEvent {
/// The absolute zero-based row selected in a table (API 1.3).
pub fn table_select(&self) -> Option<u32> {
self.table_row("table-select")
}
/// The absolute zero-based row activated in a table (API 1.3).
pub fn table_activate(&self) -> Option<u32> {
self.table_row("table-activate")
}
fn table_row(&self, expected: &str) -> Option<u32> {
let Self::Other { kind, data } = self else { return None };
(kind == expected).then(|| json_number(data, "row")).flatten()
}
/// The click on a sortable table's header ([`Body::sortable_table`]): the
/// column and the direction to order it by (`other` view-event, kind
/// `sort`; the first click asks ascending, a second on the same column
@@ -342,6 +372,27 @@ pub trait Views {
mod tests {
use super::*;
#[test]
fn readonly_fields_escape_text_without_changing_released_variants() {
assert_eq!(FieldValue::label("a\"\n"), FieldValue::Other { kind: "label".into(), data: r#""a\"\n""#.into() });
assert_eq!(
FieldValue::readonly_text("a\nβ", true),
FieldValue::Other { kind: "readonly-text".into(), data: r#"{"text":"a\nβ","multiline":true}"#.into() }
);
}
#[test]
fn table_row_events_only_decode_their_own_valid_payloads() {
let event = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() };
assert_eq!(event("table-select", r#"{"row":51}"#).table_select(), Some(51));
assert_eq!(event("table-activate", r#"{"row":0}"#).table_activate(), Some(0));
for data in ["{}", r#"{"row":-1}"#, r#"{"row":4294967296}"#, r#"{"row":"1"}"#] {
assert_eq!(event("table-select", data).table_select(), None);
}
assert_eq!(event("select", r#"{"row":1}"#).table_select(), None);
assert_eq!(event("table-select", r#"{"row":1}"#).table_activate(), None);
}
#[test]
fn a_sortable_table_becomes_the_other_table_body() {
let table = Table {
+5 -2
View File
@@ -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,
+181 -1
View File
@@ -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;
}
+52 -8
View File
@@ -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">