use schemars::schema_for;
use serde_json::Value;
use crate::component::RichTextEditorProps;
use crate::data::resolve_path;
use crate::plugin::{Asset, JsonUiPlugin};
use crate::render::html_escape;
const QUILL_CSS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css";
const QUILL_CSS_SRI: &str =
"sha384-ecIckRi4QlKYya/FQUbBUjS4qp65jF/J87Guw5uzTbO1C1Jfa/6kYmd6dXUF6D7i";
const QUILL_JS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js";
const QUILL_JS_SRI: &str =
"sha384-utBUCeG4SYaCm4m7GQZYr8Hy8Fpy3V4KGjBZaf4WTKOcwhCYpt/0PfeEe3HNlwx8";
pub struct RichTextEditorPlugin;
impl JsonUiPlugin for RichTextEditorPlugin {
fn component_type(&self) -> &str {
"RichTextEditor"
}
fn props_schema(&self) -> Value {
serde_json::to_value(schema_for!(RichTextEditorProps)).unwrap_or(Value::Null)
}
fn render(&self, props: &Value, data: &Value) -> String {
let parsed: RichTextEditorProps = match serde_json::from_value(props.clone()) {
Ok(p) => p,
Err(e) => {
return format!(
"<!-- ferro-json-ui: failed to decode RichTextEditor props: {e} -->"
)
}
};
let initial = parsed
.data_path
.as_deref()
.and_then(|p| resolve_path(data, p))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| parsed.default_value.clone())
.unwrap_or_default();
let field_esc = html_escape(&parsed.field);
let label_esc = html_escape(&parsed.label);
let initial_esc = html_escape(&initial);
let mut html = String::new();
html.push_str(&format!(
"<label for=\"{field_esc}-editor\" class=\"text-sm font-medium text-text\">{label_esc}</label>"
));
html.push_str(&format!(
"<div id=\"{field_esc}-editor\" data-ferro-quill data-ferro-field=\"{field_esc}\">{initial_esc}</div>"
));
html.push_str(&format!(
"<input type=\"hidden\" name=\"{field_esc}\" id=\"{field_esc}-value\" value=\"{initial_esc}\">"
));
if let Some(ref err) = parsed.error {
html.push_str(&format!(
"<p class=\"text-sm text-destructive mt-1\">{}</p>",
html_escape(err)
));
}
html
}
fn css_assets(&self) -> Vec<Asset> {
vec![Asset::new(QUILL_CSS_URL).integrity(QUILL_CSS_SRI)]
}
fn js_assets(&self) -> Vec<Asset> {
vec![Asset::new(QUILL_JS_URL).integrity(QUILL_JS_SRI)]
}
fn init_script(&self) -> Option<String> {
Some(
r#"(function(){
if (typeof Quill === 'undefined') return;
document.querySelectorAll('[data-ferro-quill]').forEach(function(el){
var field = el.dataset.ferroField;
var quill = new Quill(el, { theme: 'snow' });
var input = document.getElementById(field + '-value');
if (input && input.value) {
quill.root.innerHTML = input.value;
}
quill.on('text-change', function(){
if (input) input.value = quill.root.innerHTML;
});
});
})();"#
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rich_text_editor_plugin_component_type_is_rich_text_editor() {
let p = RichTextEditorPlugin;
assert_eq!(p.component_type(), "RichTextEditor");
}
#[test]
fn rich_text_editor_plugin_assets_include_quill_2_0_3() {
let p = RichTextEditorPlugin;
let js = p.js_assets();
let css = p.css_assets();
assert!(
js.iter().any(|a| a.url.contains("quill@2.0.3")),
"js asset must reference quill@2.0.3"
);
assert!(
css.iter().any(|a| a.url.contains("quill@2.0.3")),
"css asset must reference quill@2.0.3"
);
}
#[test]
fn rich_text_editor_plugin_init_script_binds_data_ferro_quill() {
let p = RichTextEditorPlugin;
let script = p.init_script().expect("init_script returns Some");
assert!(script.contains("data-ferro-quill"));
assert!(script.contains("text-change"));
}
#[test]
fn rich_text_editor_plugin_assets_carry_sri_hashes() {
let p = RichTextEditorPlugin;
let js = p.js_assets();
let css = p.css_assets();
let js_asset = js.first().expect("js asset present");
let css_asset = css.first().expect("css asset present");
assert_eq!(
js_asset.integrity.as_deref(),
Some(QUILL_JS_SRI),
"js asset must pin sha384 SRI hash"
);
assert_eq!(
css_asset.integrity.as_deref(),
Some(QUILL_CSS_SRI),
"css asset must pin sha384 SRI hash"
);
assert!(QUILL_JS_SRI.starts_with("sha384-"));
assert!(QUILL_CSS_SRI.starts_with("sha384-"));
}
#[test]
fn rich_text_editor_plugin_render_emits_container_and_hidden_input() {
let p = RichTextEditorPlugin;
let out = p.render(&json!({"field": "bio", "label": "Bio"}), &json!({}));
assert!(
out.contains("data-ferro-quill"),
"output must contain data-ferro-quill"
);
assert!(
out.contains("name=\"bio\""),
"output must contain hidden input name"
);
assert!(
out.contains("id=\"bio-editor\""),
"output must contain editor container id"
);
}
}