Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 38 additions & 54 deletions docs/EdgeApps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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**
Expand All @@ -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**
Expand All @@ -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**
Expand All @@ -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**
Expand All @@ -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**
Expand All @@ -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:
Expand Down
42 changes: 39 additions & 3 deletions src/api/edge_app/setting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -180,14 +179,51 @@ fn serialize_help_text<S>(value: &str, serializer: S) -> Result<S::Ok, S::Error>
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>(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<String, D::Error>
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 {
Expand Down
124 changes: 124 additions & 0 deletions src/commands/edge_app/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 0 additions & 36 deletions src/commands/serde_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,6 @@ where
}
}

pub fn serialize_non_empty_string_field<S>(
field_name: &'static str,
value: &str,
serializer: S,
) -> Result<S::Ok, S::Error>
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<String, D::Error>
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<String>) -> bool {
match opt.as_ref() {
None => true,
Expand Down
Loading