ferro_json_ui/plugins/
rich_text_editor.rs1use schemars::schema_for;
9use serde_json::Value;
10
11use crate::component::RichTextEditorProps;
12use crate::data::resolve_path;
13use crate::plugin::{Asset, JsonUiPlugin};
14use crate::render::html_escape;
15
16const QUILL_CSS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css";
21const QUILL_CSS_SRI: &str =
22 "sha384-ecIckRi4QlKYya/FQUbBUjS4qp65jF/J87Guw5uzTbO1C1Jfa/6kYmd6dXUF6D7i";
23const QUILL_JS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js";
24const QUILL_JS_SRI: &str =
25 "sha384-utBUCeG4SYaCm4m7GQZYr8Hy8Fpy3V4KGjBZaf4WTKOcwhCYpt/0PfeEe3HNlwx8";
26
27pub struct RichTextEditorPlugin;
41
42impl JsonUiPlugin for RichTextEditorPlugin {
43 fn component_type(&self) -> &str {
44 "RichTextEditor"
45 }
46
47 fn props_schema(&self) -> Value {
48 serde_json::to_value(schema_for!(RichTextEditorProps)).unwrap_or(Value::Null)
49 }
50
51 fn render(&self, props: &Value, data: &Value) -> String {
52 let parsed: RichTextEditorProps = match serde_json::from_value(props.clone()) {
53 Ok(p) => p,
54 Err(e) => {
55 return format!(
56 "<!-- ferro-json-ui: failed to decode RichTextEditor props: {e} -->"
57 )
58 }
59 };
60
61 let initial = parsed
63 .data_path
64 .as_deref()
65 .and_then(|p| resolve_path(data, p))
66 .and_then(|v| v.as_str())
67 .map(|s| s.to_string())
68 .or_else(|| parsed.default_value.clone())
69 .unwrap_or_default();
70
71 let field_esc = html_escape(&parsed.field);
73 let label_esc = html_escape(&parsed.label);
74 let initial_esc = html_escape(&initial);
75
76 let mut html = String::new();
77 html.push_str(&format!(
78 "<label for=\"{field_esc}-editor\" class=\"text-sm font-medium text-text\">{label_esc}</label>"
79 ));
80 html.push_str(&format!(
81 "<div id=\"{field_esc}-editor\" data-ferro-quill data-ferro-field=\"{field_esc}\">{initial_esc}</div>"
82 ));
83 html.push_str(&format!(
84 "<input type=\"hidden\" name=\"{field_esc}\" id=\"{field_esc}-value\" value=\"{initial_esc}\">"
85 ));
86 if let Some(ref err) = parsed.error {
87 html.push_str(&format!(
88 "<p class=\"text-sm text-destructive mt-1\">{}</p>",
89 html_escape(err)
90 ));
91 }
92 html
93 }
94
95 fn css_assets(&self) -> Vec<Asset> {
96 vec![Asset::new(QUILL_CSS_URL).integrity(QUILL_CSS_SRI)]
97 }
98
99 fn js_assets(&self) -> Vec<Asset> {
100 vec![Asset::new(QUILL_JS_URL).integrity(QUILL_JS_SRI)]
101 }
102
103 fn init_script(&self) -> Option<String> {
104 Some(
105 r#"(function(){
106 if (typeof Quill === 'undefined') return;
107 document.querySelectorAll('[data-ferro-quill]').forEach(function(el){
108 var field = el.dataset.ferroField;
109 var quill = new Quill(el, { theme: 'snow' });
110 var input = document.getElementById(field + '-value');
111 if (input && input.value) {
112 quill.root.innerHTML = input.value;
113 }
114 quill.on('text-change', function(){
115 if (input) input.value = quill.root.innerHTML;
116 });
117 });
118})();"#
119 .to_string(),
120 )
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use serde_json::json;
128
129 #[test]
130 fn rich_text_editor_plugin_component_type_is_rich_text_editor() {
131 let p = RichTextEditorPlugin;
132 assert_eq!(p.component_type(), "RichTextEditor");
133 }
134
135 #[test]
136 fn rich_text_editor_plugin_assets_include_quill_2_0_3() {
137 let p = RichTextEditorPlugin;
138 let js = p.js_assets();
139 let css = p.css_assets();
140 assert!(
141 js.iter().any(|a| a.url.contains("quill@2.0.3")),
142 "js asset must reference quill@2.0.3"
143 );
144 assert!(
145 css.iter().any(|a| a.url.contains("quill@2.0.3")),
146 "css asset must reference quill@2.0.3"
147 );
148 }
149
150 #[test]
151 fn rich_text_editor_plugin_init_script_binds_data_ferro_quill() {
152 let p = RichTextEditorPlugin;
153 let script = p.init_script().expect("init_script returns Some");
154 assert!(script.contains("data-ferro-quill"));
155 assert!(script.contains("text-change"));
156 }
157
158 #[test]
159 fn rich_text_editor_plugin_assets_carry_sri_hashes() {
160 let p = RichTextEditorPlugin;
161 let js = p.js_assets();
162 let css = p.css_assets();
163 let js_asset = js.first().expect("js asset present");
164 let css_asset = css.first().expect("css asset present");
165 assert_eq!(
166 js_asset.integrity.as_deref(),
167 Some(QUILL_JS_SRI),
168 "js asset must pin sha384 SRI hash"
169 );
170 assert_eq!(
171 css_asset.integrity.as_deref(),
172 Some(QUILL_CSS_SRI),
173 "css asset must pin sha384 SRI hash"
174 );
175 assert!(QUILL_JS_SRI.starts_with("sha384-"));
176 assert!(QUILL_CSS_SRI.starts_with("sha384-"));
177 }
178
179 #[test]
180 fn rich_text_editor_plugin_render_emits_container_and_hidden_input() {
181 let p = RichTextEditorPlugin;
182 let out = p.render(&json!({"field": "bio", "label": "Bio"}), &json!({}));
183 assert!(
184 out.contains("data-ferro-quill"),
185 "output must contain data-ferro-quill"
186 );
187 assert!(
188 out.contains("name=\"bio\""),
189 "output must contain hidden input name"
190 );
191 assert!(
192 out.contains("id=\"bio-editor\""),
193 "output must contain editor container id"
194 );
195 }
196}