mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10: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]>
This commit is contained in:
1 parent
1891119f9d
commit
dc69c36604
39 files changed
+502
-41
No files matched your search
@@ -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() });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user