From 70790217fed8ac27f5099fdf5c72c03c4629097c Mon Sep 17 00:00:00 2001 From: Renat Galimov <13913521+renatgalimov@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:24:47 +0300 Subject: [PATCH 1/4] Support structured edge app help text --- docs/EdgeApps.md | 92 +++++++++++++------------------ src/api/edge_app/setting.rs | 37 ++++++++++++- src/commands/edge_app/manifest.rs | 57 +++++++++++++++++++ src/commands/serde_utils.rs | 36 ------------ 4 files changed, 129 insertions(+), 93 deletions(-) diff --git a/docs/EdgeApps.md b/docs/EdgeApps.md index 0464886..dd8d1ad 100644 --- a/docs/EdgeApps.md +++ b/docs/EdgeApps.md @@ -418,7 +418,7 @@ settings: #### Input field types -Edge App settings support additional input field types beyond plain text and password. To render a specific input type in the UI, embed a small JSON descriptor inside the setting's `help_text` field. This JSON does not change the underlying storage type (which remains `string` or `secret`); it only instructs the UI how to render the input. +Edge App settings support additional input field types beyond plain text and password. To render a specific input type in the UI, embed a small descriptor inside the setting's `help_text` field. The descriptor can be provided either as an inline JSON string or, more ergonomically, as native YAML mapping values (shown below). This metadata does not change the underlying storage type (which remains `string` or `secret`); it only instructs the UI how to render the input. - **Schema**: The JSON must include `schema_version` and a `properties` object. - **Common keys**: @@ -438,14 +438,11 @@ settings: type: string title: Start Date Time optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "help_text": "The start date and time of the event", - "type": "datetime" - } - } + help_text: + schema_version: 1 + properties: + help_text: The start date and time of the event + type: datetime ``` **Number input** @@ -456,14 +453,11 @@ settings: type: string title: Attendee Count optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "help_text": "The expected count of attendees", - "type": "number" - } - } + help_text: + schema_version: 1 + properties: + help_text: The expected count of attendees + type: number ``` **Select (dropdown) input** @@ -475,19 +469,18 @@ settings: title: Select Role default_value: editor optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "type": "select", - "help_text": "The role of the user", - "options": [ - { "label": "Admin", "value": "admin" }, - { "label": "Editor", "value": "editor" }, - { "label": "Viewer", "value": "viewer" } - ] - } - } + help_text: + schema_version: 1 + properties: + type: select + help_text: The role of the user + options: + - label: Admin + value: admin + - label: Editor + value: editor + - label: Viewer + value: viewer ``` **Boolean (switch) input** @@ -499,14 +492,11 @@ settings: title: Subscribe default_value: "true" # or 'false' optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "help_text": "Subscribe to updates", - "type": "boolean" - } - } + help_text: + schema_version: 1 + properties: + help_text: Subscribe to updates + type: boolean ``` **Text area input** @@ -518,14 +508,11 @@ settings: title: Description default_value: Field description optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "help_text": "The description of the event", - "type": "textarea" - } - } + help_text: + schema_version: 1 + properties: + help_text: The description of the event + type: textarea ``` **URL input** @@ -536,14 +523,11 @@ settings: type: string title: Website URL optional: false - help_text: | - { - "schema_version": 1, - "properties": { - "help_text": "The URL of the website", - "type": "url" - } - } + help_text: + schema_version: 1 + properties: + help_text: The URL of the website + type: url ``` Notes: diff --git a/src/api/edge_app/setting.rs b/src/api/edge_app/setting.rs index 3233bc5..506e3db 100644 --- a/src/api/edge_app/setting.rs +++ b/src/api/edge_app/setting.rs @@ -10,7 +10,6 @@ use strum_macros::{Display, EnumIter, EnumString}; use crate::api::Api; use crate::commands; -use crate::commands::serde_utils::{deserialize_string_field, serialize_non_empty_string_field}; use crate::commands::{CommandError, EdgeAppSettings}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -180,14 +179,46 @@ fn serialize_help_text(value: &str, serializer: S) -> Result where S: serde::Serializer, { - serialize_non_empty_string_field("help_text", value, serializer) + if value.trim().is_empty() { + return Err(serde::ser::Error::custom("Field \"help_text\" cannot be empty")); + } + + match serde_json::from_str::(value) { + Ok(json_value) if json_value.is_object() => json_value.serialize(serializer), + _ => serializer.serialize_str(value), + } } fn deserialize_help_text<'de, D>(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { - deserialize_string_field("help_text", true, deserializer) + #[derive(Deserialize)] + #[serde(untagged)] + enum HelpTextHelper { + Plain(String), + Structured(Value), + } + + match HelpTextHelper::deserialize(deserializer)? { + HelpTextHelper::Plain(value) => { + if value.trim().is_empty() { + Err(serde::de::Error::custom("Field \"help_text\" cannot be empty")) + } else { + Ok(value) + } + } + HelpTextHelper::Structured(value) => { + if !value.is_object() { + return Err(serde::de::Error::custom( + "Field \"help_text\" must be either a string or an object", + )); + } + + serde_json::to_string(&value) + .map_err(|err| serde::de::Error::custom(format!("Failed to serialize help_text: {err}"))) + } + } } impl Setting { diff --git a/src/commands/edge_app/manifest.rs b/src/commands/edge_app/manifest.rs index 712f2e5..bf2cc2c 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -522,6 +522,63 @@ settings: assert!(result.unwrap_err().to_string().contains("help_text")); } + #[test] + fn test_manifest_allows_structured_help_text() { + let dir = tempdir().unwrap(); + let file_path = write_to_tempfile( + &dir, + "screenly.yml", + r#"--- +syntax: manifest_v1 +settings: + username: + type: string + optional: true + help_text: + schemaVersion: 1 + type: input + choices: + - 1 + - 2 + - 3 +"#, + ); + + let manifest = EdgeAppManifest::new(&file_path).unwrap(); + + let actual: serde_json::Value = + serde_json::from_str(&manifest.settings[0].help_text).unwrap(); + let expected: serde_json::Value = serde_yaml::from_str( + "schemaVersion: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n", + ) + .unwrap(); + + assert_eq!(actual, expected); + } + + #[test] + fn test_save_manifest_to_file_serializes_structured_help_text() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("screenly.yml"); + let mut manifest = create_test_manifest(); + manifest.settings[0].help_text = + "{\"schemaVersion\":1,\"type\":\"input\",\"choices\":[1,2,3]}".to_string(); + + EdgeAppManifest::save_to_file(&manifest, &file_path).unwrap(); + + let contents = fs::read_to_string(file_path).unwrap(); + let yaml: serde_json::Value = serde_yaml::from_str(&contents).unwrap(); + let help_text = &yaml["settings"]["username"]["help_text"]; + + assert_eq!( + help_text, + &serde_yaml::from_str::( + "schemaVersion: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n" + ) + .unwrap() + ); + } + #[test] fn test_serialize_deserialize_cycle_should_pass_on_valid_struct() { let manifest = create_test_manifest(); diff --git a/src/commands/serde_utils.rs b/src/commands/serde_utils.rs index 9bb7092..4bb6209 100644 --- a/src/commands/serde_utils.rs +++ b/src/commands/serde_utils.rs @@ -25,42 +25,6 @@ where } } -pub fn serialize_non_empty_string_field( - field_name: &'static str, - value: &str, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - if value.trim().is_empty() { - Err(serde::ser::Error::custom(format!( - "Field \"{field_name}\" cannot be empty" - ))) - } else { - serializer.serialize_str(value) - } -} - -pub fn deserialize_string_field<'de, D>( - field_name: &'static str, - error_on_empty: bool, - deserializer: D, -) -> Result -where - D: Deserializer<'de>, -{ - let s: String = String::deserialize(deserializer)?; - - if s.trim().is_empty() && error_on_empty { - Err(serde::de::Error::custom(format!( - "Field \"{field_name}\" cannot be empty" - ))) - } else { - Ok(s) - } -} - pub fn string_field_is_none_or_empty(opt: &Option) -> bool { match opt.as_ref() { None => true, From de4aa0b869d64edf36fa3686c98f3587b524557c Mon Sep 17 00:00:00 2001 From: Renat Galimov <13913521+renatgalimov@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:44:25 +0300 Subject: [PATCH 2/4] Align structured help text schema key --- src/api/edge_app/setting.rs | 13 +++++++++---- src/commands/edge_app/manifest.rs | 13 ++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/api/edge_app/setting.rs b/src/api/edge_app/setting.rs index 506e3db..a749fc5 100644 --- a/src/api/edge_app/setting.rs +++ b/src/api/edge_app/setting.rs @@ -180,7 +180,9 @@ where S: serde::Serializer, { if value.trim().is_empty() { - return Err(serde::ser::Error::custom("Field \"help_text\" cannot be empty")); + return Err(serde::ser::Error::custom( + "Field \"help_text\" cannot be empty", + )); } match serde_json::from_str::(value) { @@ -203,7 +205,9 @@ where match HelpTextHelper::deserialize(deserializer)? { HelpTextHelper::Plain(value) => { if value.trim().is_empty() { - Err(serde::de::Error::custom("Field \"help_text\" cannot be empty")) + Err(serde::de::Error::custom( + "Field \"help_text\" cannot be empty", + )) } else { Ok(value) } @@ -215,8 +219,9 @@ where )); } - serde_json::to_string(&value) - .map_err(|err| serde::de::Error::custom(format!("Failed to serialize help_text: {err}"))) + serde_json::to_string(&value).map_err(|err| { + serde::de::Error::custom(format!("Failed to serialize help_text: {err}")) + }) } } } diff --git a/src/commands/edge_app/manifest.rs b/src/commands/edge_app/manifest.rs index bf2cc2c..9fc52e2 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -535,7 +535,7 @@ settings: type: string optional: true help_text: - schemaVersion: 1 + schema_version: 1 type: input choices: - 1 @@ -548,10 +548,9 @@ settings: let actual: serde_json::Value = serde_json::from_str(&manifest.settings[0].help_text).unwrap(); - let expected: serde_json::Value = serde_yaml::from_str( - "schemaVersion: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n", - ) - .unwrap(); + let expected: serde_json::Value = + serde_yaml::from_str("schema_version: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n") + .unwrap(); assert_eq!(actual, expected); } @@ -562,7 +561,7 @@ settings: let file_path = dir.path().join("screenly.yml"); let mut manifest = create_test_manifest(); manifest.settings[0].help_text = - "{\"schemaVersion\":1,\"type\":\"input\",\"choices\":[1,2,3]}".to_string(); + "{\"schema_version\":1,\"type\":\"input\",\"choices\":[1,2,3]}".to_string(); EdgeAppManifest::save_to_file(&manifest, &file_path).unwrap(); @@ -573,7 +572,7 @@ settings: assert_eq!( help_text, &serde_yaml::from_str::( - "schemaVersion: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n" + "schema_version: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n" ) .unwrap() ); From 1847468989705ba3145a0ca61d9581a9c1b88034 Mon Sep 17 00:00:00 2001 From: Renat Galimov <13913521+renatgalimov@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:48:52 +0300 Subject: [PATCH 3/4] Use select-field YAML fixture for structured help_text tests --- src/commands/edge_app/manifest.rs | 67 +++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/src/commands/edge_app/manifest.rs b/src/commands/edge_app/manifest.rs index 9fc52e2..706e397 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -531,16 +531,23 @@ settings: r#"--- syntax: manifest_v1 settings: - username: + select_field: type: string - optional: true + title: Select Role + default_value: editor + optional: false help_text: schema_version: 1 - type: input - choices: - - 1 - - 2 - - 3 + properties: + type: select + help_text: The role of the user + options: + - label: Admin + value: admin + - label: Editor + value: editor + - label: Viewer + value: viewer "#, ); @@ -548,9 +555,21 @@ settings: let actual: serde_json::Value = serde_json::from_str(&manifest.settings[0].help_text).unwrap(); - let expected: serde_json::Value = - serde_yaml::from_str("schema_version: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n") - .unwrap(); + let expected: serde_json::Value = serde_yaml::from_str( + r#"schema_version: 1 +properties: + type: select + help_text: The role of the user + options: + - label: Admin + value: admin + - label: Editor + value: editor + - label: Viewer + value: viewer +"#, + ) + .unwrap(); assert_eq!(actual, expected); } @@ -560,8 +579,19 @@ settings: let dir = tempdir().unwrap(); let file_path = dir.path().join("screenly.yml"); let mut manifest = create_test_manifest(); - manifest.settings[0].help_text = - "{\"schema_version\":1,\"type\":\"input\",\"choices\":[1,2,3]}".to_string(); + manifest.settings[0].help_text = serde_json::json!({ + "schema_version": 1, + "properties": { + "type": "select", + "help_text": "The role of the user", + "options": [ + {"label": "Admin", "value": "admin"}, + {"label": "Editor", "value": "editor"}, + {"label": "Viewer", "value": "viewer"}, + ] + } + }) + .to_string(); EdgeAppManifest::save_to_file(&manifest, &file_path).unwrap(); @@ -572,7 +602,18 @@ settings: assert_eq!( help_text, &serde_yaml::from_str::( - "schema_version: 1\ntype: input\nchoices:\n - 1\n - 2\n - 3\n" + r#"schema_version: 1 +properties: + type: select + help_text: The role of the user + options: + - label: Admin + value: admin + - label: Editor + value: editor + - label: Viewer + value: viewer +"# ) .unwrap() ); From 5ef9a08b8477156bff8d3f325872ab52fc8bcbb4 Mon Sep 17 00:00:00 2001 From: Renat Galimov <13913521+renatgalimov@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:49:01 +0300 Subject: [PATCH 4/4] Improve help_text manifest tests --- src/commands/edge_app/manifest.rs | 89 ++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/src/commands/edge_app/manifest.rs b/src/commands/edge_app/manifest.rs index 706e397..4a25d26 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -555,21 +555,18 @@ settings: let actual: serde_json::Value = serde_json::from_str(&manifest.settings[0].help_text).unwrap(); - let expected: serde_json::Value = serde_yaml::from_str( - r#"schema_version: 1 -properties: - type: select - help_text: The role of the user - options: - - label: Admin - value: admin - - label: Editor - value: editor - - label: Viewer - value: viewer -"#, - ) - .unwrap(); + let expected = serde_json::json!({ + "schema_version": 1, + "properties": { + "type": "select", + "help_text": "The role of the user", + "options": [ + {"label": "Admin", "value": "admin"}, + {"label": "Editor", "value": "editor"}, + {"label": "Viewer", "value": "viewer"}, + ] + } + }); assert_eq!(actual, expected); } @@ -599,23 +596,53 @@ properties: let yaml: serde_json::Value = serde_yaml::from_str(&contents).unwrap(); let help_text = &yaml["settings"]["username"]["help_text"]; + let expected = serde_json::json!({ + "schema_version": 1, + "properties": { + "type": "select", + "help_text": "The role of the user", + "options": [ + {"label": "Admin", "value": "admin"}, + {"label": "Editor", "value": "editor"}, + {"label": "Viewer", "value": "viewer"}, + ] + } + }); + + assert_eq!(help_text, &expected); + } + + #[test] + fn test_manifest_round_trips_plain_help_text_as_string() { + let dir = tempdir().unwrap(); + let file_path = write_to_tempfile( + &dir, + "screenly.yml", + r#"--- +syntax: manifest_v1 +settings: + username: + type: string + default_value: stranger + title: username title + optional: true + help_text: some help text +"#, + ); + + let manifest = EdgeAppManifest::new(&file_path).unwrap(); + + assert_eq!(manifest.settings[0].help_text, "some help text"); + + let output_path = dir.path().join("roundtrip.yml"); + EdgeAppManifest::save_to_file(&manifest, &output_path).unwrap(); + + let contents = fs::read_to_string(output_path).unwrap(); + let yaml: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap(); + assert_eq!( - help_text, - &serde_yaml::from_str::( - r#"schema_version: 1 -properties: - type: select - help_text: The role of the user - options: - - label: Admin - value: admin - - label: Editor - value: editor - - label: Viewer - value: viewer -"# - ) - .unwrap() + yaml["settings"]["username"]["help_text"], + serde_yaml::Value::String("some help text".to_string()) ); }