From 52d1460cccdd0003d2c496ef9a158faa5409ee36 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Fri, 5 Jun 2026 11:07:34 -0700 Subject: [PATCH] [API-373] Add dashboard field flags to create/update Expose description, is_favorite, is_default, position, and config on `coval dashboards create` and `coval dashboards update`, plus the same fields on the dashboard response (with a DEFAULT table column). Mirrors the v1 API gaining full dashboard field parity. --- README.md | 6 +++ src/client/models/dashboard.rs | 38 ++++++++++++- src/commands/dashboards.rs | 40 ++++++++++++++ tests/cli_tests.rs | 98 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00ea68e..4aa7a17 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ coval test-sets create \ coval test-cases create \ --test-set-id ts123456 \ --input "I need help with my order" + +# Create a dashboard and make it the organization default +coval dashboards create \ + --name "Production Metrics" \ + --description "Latency and quality overview" \ + --default true ``` ### JSON Output for Scripting diff --git a/src/client/models/dashboard.rs b/src/client/models/dashboard.rs index 8d08f6e..dae1f47 100644 --- a/src/client/models/dashboard.rs +++ b/src/client/models/dashboard.rs @@ -21,6 +21,16 @@ pub struct Dashboard { pub name: String, #[serde(default)] pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_favorite: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, pub create_time: DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub update_time: Option>, @@ -31,12 +41,32 @@ pub struct Dashboard { #[derive(Debug, Serialize, Deserialize)] pub struct CreateDashboardRequest { pub display_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_favorite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, } #[derive(Debug, Default, Serialize, Deserialize)] pub struct UpdateDashboardRequest { #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_favorite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, } #[derive(Debug, Deserialize)] @@ -62,7 +92,7 @@ pub struct UpdateDashboardResponse { impl Tabular for Dashboard { fn headers() -> Vec<&'static str> { - vec!["ID", "NAME", "CREATED", "UPDATED"] + vec!["ID", "NAME", "DEFAULT", "CREATED", "UPDATED"] } fn row(&self) -> Vec { @@ -70,9 +100,15 @@ impl Tabular for Dashboard { .update_time .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_default(); + let default = if self.is_default.unwrap_or(false) { + "yes".to_string() + } else { + String::new() + }; vec![ extract_id(&self.name), truncate(self.display_name.as_deref().unwrap_or(""), 30), + default, self.create_time.format("%Y-%m-%d %H:%M").to_string(), updated, ] diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index 68afbe7..b33cd63 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -62,6 +62,20 @@ pub struct CreateArgs { input_json: InputJsonArg, #[arg(long)] name: Option, + #[arg(long)] + description: Option, + /// Mark as a favorite (true/false). + #[arg(long)] + favorite: Option, + /// Make this the organization's default dashboard (true/false). Omit to auto-default the first dashboard. + #[arg(long)] + default: Option, + /// Ordering position (>= 0). Omit to append to the end. + #[arg(long)] + position: Option, + /// Free-form JSON config: a JSON string or @path to a file. + #[arg(long)] + config: Option, } #[derive(Args)] @@ -71,6 +85,20 @@ pub struct UpdateArgs { input_json: InputJsonArg, #[arg(long)] name: Option, + #[arg(long)] + description: Option, + /// Mark as a favorite (true/false). + #[arg(long)] + favorite: Option, + /// Make this the organization's default dashboard (true/false). Setting true unsets any other default. + #[arg(long)] + default: Option, + /// Ordering position (>= 0). + #[arg(long)] + position: Option, + /// Replacement free-form JSON config: a JSON string or @path to a file. + #[arg(long)] + config: Option, } #[derive(Args)] @@ -227,7 +255,13 @@ pub async fn execute( } DashboardCommands::Create(args) => { let mut input = args.input_json.object()?; + let config = args.config.as_ref().map(|c| parse_config(c)).transpose()?; input_json::insert(&mut input, "display_name", args.name)?; + input_json::insert(&mut input, "description", args.description)?; + input_json::insert(&mut input, "is_favorite", args.favorite)?; + input_json::insert(&mut input, "is_default", args.default)?; + input_json::insert(&mut input, "position", args.position)?; + input_json::insert(&mut input, "config", config)?; let req: CreateDashboardRequest = input_json::finish(input)?; let dashboard = client.dashboards().create(req).await?; let dashboard_id = resource_id(&dashboard.name); @@ -241,7 +275,13 @@ pub async fn execute( } DashboardCommands::Update(args) => { let mut input = args.input_json.object()?; + let config = args.config.as_ref().map(|c| parse_config(c)).transpose()?; input_json::insert(&mut input, "display_name", args.name)?; + input_json::insert(&mut input, "description", args.description)?; + input_json::insert(&mut input, "is_favorite", args.favorite)?; + input_json::insert(&mut input, "is_default", args.default)?; + input_json::insert(&mut input, "position", args.position)?; + input_json::insert(&mut input, "config", config)?; let req: UpdateDashboardRequest = input_json::finish(input)?; let dashboard = client.dashboards().update(&args.dashboard_id, req).await?; emit_one_with_actions( diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 8e448b9..620c9d9 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1880,6 +1880,104 @@ async fn test_input_json_stdin() { .stdout(predicate::str::contains("dash123")); } +#[tokio::test] +async fn test_dashboard_create_with_full_fields() { + let mock_server = MockServer::start().await; + + // body_partial_json asserts the new flags are serialized into the request body; + // a mismatch yields no matching mock (404) and the command fails. + Mock::given(method("POST")) + .and(path("/v1/dashboards")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "display_name": "Ops", + "description": "desc", + "is_favorite": true, + "is_default": true, + "position": 3, + "config": {"date_preferences": {"preset": "last-7-days"}} + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "dashboard": { + "name": "dashboards/dash123", + "display_name": "Ops", + "description": "desc", + "is_default": true, + "is_favorite": true, + "position": 3, + "config": {"date_preferences": {"preset": "last-7-days"}}, + "create_time": "2025-01-15T10:30:00Z", + "update_time": "2025-01-15T10:30:00Z" + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("dashboards") + .arg("create") + .arg("--name") + .arg("Ops") + .arg("--description") + .arg("desc") + .arg("--favorite") + .arg("true") + .arg("--default") + .arg("true") + .arg("--position") + .arg("3") + .arg("--config") + .arg(r#"{"date_preferences":{"preset":"last-7-days"}}"#) + .assert() + .success() + .stdout(predicate::str::contains("dash123")); +} + +#[tokio::test] +async fn test_dashboard_update_sets_default_and_position() { + let mock_server = MockServer::start().await; + + Mock::given(method("PATCH")) + .and(path("/v1/dashboards/dash123")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "is_default": true, + "position": 5 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "dashboard": { + "name": "dashboards/dash123", + "display_name": "Ops", + "is_default": true, + "position": 5, + "create_time": "2025-01-15T10:30:00Z", + "update_time": "2025-01-16T10:30:00Z" + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("dashboards") + .arg("update") + .arg("dash123") + .arg("--default") + .arg("true") + .arg("--position") + .arg("5") + .assert() + .success() + .stdout(predicate::str::contains("dash123")); +} + #[test] fn test_input_json_invalid_agent_error() { let value = stdout_json(