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..a749fc5 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,51 @@ 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..4a25d26 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -522,6 +522,130 @@ 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: + select_field: + type: string + 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 +"#, + ); + + 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::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); + } + + #[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 = 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(); + + 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"]; + + 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!( + yaml["settings"]["username"]["help_text"], + serde_yaml::Value::String("some help text".to_string()) + ); + } + #[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,