1use serde_json::{Map, Value, json};
2
3use crate::{
4 answers_schema,
5 computed::apply_computed_answers,
6 i18n::{ResolvedI18nMap, resolve_i18n_text_with_locale},
7 progress::{ProgressContext, next_question},
8 spec::{
9 form::FormSpec,
10 question::{ListSpec, QuestionType},
11 },
12 visibility::{VisibilityMode, resolve_visibility},
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RenderStatus {
18 NeedInput,
20 Complete,
22 Error,
24}
25
26impl RenderStatus {
27 pub fn as_str(&self) -> &'static str {
29 match self {
30 RenderStatus::NeedInput => "need_input",
31 RenderStatus::Complete => "complete",
32 RenderStatus::Error => "error",
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
39pub struct RenderProgress {
40 pub answered: usize,
41 pub total: usize,
42}
43
44#[derive(Debug, Clone)]
46pub struct RenderQuestion {
47 pub id: String,
48 pub title: String,
49 pub description: Option<String>,
50 pub title_i18n_key: Option<String>,
51 pub description_i18n_key: Option<String>,
52 pub kind: QuestionType,
53 pub required: bool,
54 pub default: Option<String>,
55 pub secret: bool,
56 pub visible: bool,
57 pub current_value: Option<Value>,
58 pub choices: Option<Vec<String>>,
59 pub list: Option<ListSpec>,
60}
61
62#[derive(Debug, Clone)]
64pub struct RenderPayload {
65 pub form_id: String,
66 pub form_title: String,
67 pub form_version: String,
68 pub status: RenderStatus,
69 pub next_question_id: Option<String>,
70 pub progress: RenderProgress,
71 pub help: Option<String>,
72 pub questions: Vec<RenderQuestion>,
73 pub schema: Value,
74}
75
76pub fn build_render_payload(spec: &FormSpec, ctx: &Value, answers: &Value) -> RenderPayload {
78 build_render_payload_with_i18n(spec, ctx, answers, None)
79}
80
81pub fn build_render_payload_with_i18n(
83 spec: &FormSpec,
84 ctx: &Value,
85 answers: &Value,
86 resolved_i18n: Option<&ResolvedI18nMap>,
87) -> RenderPayload {
88 let computed_answers = apply_computed_answers(spec, answers);
89 let visibility = resolve_visibility(spec, &computed_answers, VisibilityMode::Visible);
90 let progress_ctx = ProgressContext::new(computed_answers.clone(), ctx);
91 let next_question_id = next_question(spec, &progress_ctx, &visibility);
92
93 let answered = progress_ctx.answered_count(spec, &visibility);
94 let total = visibility.values().filter(|visible| **visible).count();
95
96 let requested_locale = ctx.get("locale").and_then(Value::as_str);
97 let default_locale = spec
98 .presentation
99 .as_ref()
100 .and_then(|presentation| presentation.default_locale.as_deref());
101
102 let questions = spec
103 .questions
104 .iter()
105 .map(|question| RenderQuestion {
106 id: question.id.clone(),
107 title: resolve_i18n_text_with_locale(
108 &question.title,
109 question.title_i18n.as_ref(),
110 resolved_i18n,
111 requested_locale,
112 default_locale,
113 ),
114 description: resolve_description(
115 question.description.as_deref(),
116 question.description_i18n.as_ref(),
117 resolved_i18n,
118 requested_locale,
119 default_locale,
120 ),
121 title_i18n_key: question.title_i18n.as_ref().map(|text| text.key.clone()),
122 description_i18n_key: question
123 .description_i18n
124 .as_ref()
125 .map(|text| text.key.clone()),
126 kind: question.kind,
127 required: question.required,
128 default: question.default_value.clone(),
129 secret: question.secret,
130 visible: visibility.get(&question.id).copied().unwrap_or(true),
131 current_value: computed_answers.get(&question.id).cloned(),
132 choices: question.choices.clone(),
133 list: question.list.clone(),
134 })
135 .collect::<Vec<_>>();
136
137 let help = spec
138 .presentation
139 .as_ref()
140 .and_then(|presentation| presentation.intro.clone())
141 .or_else(|| spec.description.clone());
142
143 let schema = answers_schema::generate(spec, &visibility);
144
145 let status = if next_question_id.is_some() {
146 RenderStatus::NeedInput
147 } else {
148 RenderStatus::Complete
149 };
150
151 RenderPayload {
152 form_id: spec.id.clone(),
153 form_title: spec.title.clone(),
154 form_version: spec.version.clone(),
155 status,
156 next_question_id,
157 progress: RenderProgress { answered, total },
158 help,
159 questions,
160 schema,
161 }
162}
163
164pub fn render_json_ui(payload: &RenderPayload) -> Value {
166 let questions = payload
167 .questions
168 .iter()
169 .map(|question| {
170 let mut map = Map::new();
171 map.insert("id".into(), Value::String(question.id.clone()));
172 map.insert("title".into(), Value::String(question.title.clone()));
173 map.insert(
174 "description".into(),
175 question
176 .description
177 .clone()
178 .map(Value::String)
179 .unwrap_or(Value::Null),
180 );
181 map.insert(
182 "type".into(),
183 Value::String(question_type_label(question.kind).to_string()),
184 );
185 map.insert("required".into(), Value::Bool(question.required));
186 if let Some(default) = &question.default {
187 map.insert("default".into(), Value::String(default.clone()));
188 }
189 if let Some(current_value) = &question.current_value {
190 map.insert("current_value".into(), current_value.clone());
191 }
192 if let Some(choices) = &question.choices {
193 map.insert(
194 "choices".into(),
195 Value::Array(
196 choices
197 .iter()
198 .map(|choice| Value::String(choice.clone()))
199 .collect(),
200 ),
201 );
202 }
203 map.insert("visible".into(), Value::Bool(question.visible));
204 map.insert("secret".into(), Value::Bool(question.secret));
205 if let Some(list) = &question.list
206 && let Ok(list_value) = serde_json::to_value(list)
207 {
208 map.insert("list".into(), list_value);
209 }
210 Value::Object(map)
211 })
212 .collect::<Vec<_>>();
213
214 json!({
215 "form_id": payload.form_id,
216 "form_title": payload.form_title,
217 "form_version": payload.form_version,
218 "status": payload.status.as_str(),
219 "next_question_id": payload.next_question_id,
220 "progress": {
221 "answered": payload.progress.answered,
222 "total": payload.progress.total,
223 },
224 "help": payload.help,
225 "questions": questions,
226 "schema": payload.schema,
227 })
228}
229
230pub fn render_text(payload: &RenderPayload) -> String {
232 let mut lines = Vec::new();
233 lines.push(format!(
234 "Form: {} ({})",
235 payload.form_title, payload.form_id
236 ));
237 lines.push(format!(
238 "Status: {} ({}/{})",
239 payload.status.as_str(),
240 payload.progress.answered,
241 payload.progress.total
242 ));
243 if let Some(help) = &payload.help {
244 lines.push(format!("Help: {}", help));
245 }
246
247 if let Some(next_question) = &payload.next_question_id {
248 lines.push(format!("Next question: {}", next_question));
249 if let Some(question) = payload
250 .questions
251 .iter()
252 .find(|question| &question.id == next_question)
253 {
254 lines.push(format!(" Title: {}", question.title));
255 if let Some(description) = &question.description {
256 lines.push(format!(" Description: {}", description));
257 }
258 if question.required {
259 lines.push(" Required: yes".to_string());
260 }
261 if let Some(default) = &question.default {
262 lines.push(format!(" Default: {}", default));
263 }
264 if let Some(value) = &question.current_value {
265 lines.push(format!(" Current value: {}", value_to_display(value)));
266 }
267 }
268 } else {
269 lines.push("All visible questions are answered.".to_string());
270 }
271
272 lines.push("Visible questions:".to_string());
273 for question in payload.questions.iter().filter(|question| question.visible) {
274 let mut entry = format!(" - {} ({})", question.id, question.title);
275 if question.required {
276 entry.push_str(" [required]");
277 }
278 if let Some(current_value) = &question.current_value {
279 entry.push_str(&format!(" = {}", value_to_display(current_value)));
280 }
281 lines.push(entry);
282 }
283
284 lines.join("\n")
285}
286
287pub fn render_card(payload: &RenderPayload) -> Value {
289 let mut body = Vec::new();
290
291 body.push(json!({
292 "type": "TextBlock",
293 "text": payload.form_title,
294 "weight": "Bolder",
295 "size": "Large",
296 "wrap": true,
297 }));
298
299 if let Some(help) = &payload.help {
300 body.push(json!({
301 "type": "TextBlock",
302 "text": help,
303 "wrap": true,
304 }));
305 }
306
307 body.push(json!({
308 "type": "FactSet",
309 "facts": [
310 { "title": "Answered", "value": payload.progress.answered.to_string() },
311 { "title": "Total", "value": payload.progress.total.to_string() }
312 ]
313 }));
314
315 let mut actions = Vec::new();
316
317 if let Some(question_id) = &payload.next_question_id {
318 if let Some(question) = payload
319 .questions
320 .iter()
321 .find(|question| &question.id == question_id)
322 {
323 let mut items = Vec::new();
324 items.push(json!({
325 "type": "TextBlock",
326 "text": question.title,
327 "weight": "Bolder",
328 "wrap": true,
329 }));
330 if let Some(description) = &question.description {
331 items.push(json!({
332 "type": "TextBlock",
333 "text": description,
334 "wrap": true,
335 "spacing": "Small",
336 }));
337 }
338 items.push(question_input(question));
339
340 body.push(json!({
341 "type": "Container",
342 "items": items,
343 }));
344
345 actions.push(json!({
346 "type": "Action.Submit",
347 "title": "Next ➡️",
348 "data": {
349 "qa": {
350 "formId": payload.form_id,
351 "mode": "patch",
352 "questionId": question.id,
353 "field": "answer"
354 }
355 }
356 }));
357 }
358 } else {
359 body.push(json!({
360 "type": "TextBlock",
361 "text": "All visible questions are answered.",
362 "wrap": true,
363 }));
364 }
365
366 json!({
367 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
368 "type": "AdaptiveCard",
369 "version": "1.3",
370 "body": body,
371 "actions": actions,
372 })
373}
374
375fn question_input(question: &RenderQuestion) -> Value {
376 match question.kind {
377 QuestionType::String | QuestionType::Integer | QuestionType::Number => {
378 let mut map = Map::new();
379 map.insert("type".into(), Value::String("Input.Text".into()));
380 map.insert("id".into(), Value::String(question.id.clone()));
381 map.insert("isRequired".into(), Value::Bool(question.required));
382 if let Some(value) = &question.current_value {
383 map.insert("value".into(), Value::String(value_to_display(value)));
384 }
385 Value::Object(map)
386 }
387 QuestionType::Boolean => {
388 let mut map = Map::new();
389 map.insert("type".into(), Value::String("Input.Toggle".into()));
390 map.insert("id".into(), Value::String(question.id.clone()));
391 map.insert("title".into(), Value::String(question.title.clone()));
392 map.insert("isRequired".into(), Value::Bool(question.required));
393 map.insert("valueOn".into(), Value::String("true".into()));
394 map.insert("valueOff".into(), Value::String("false".into()));
395 if let Some(value) = &question.current_value {
396 if value.as_bool() == Some(true) {
397 map.insert("value".into(), Value::String("true".into()));
398 } else {
399 map.insert("value".into(), Value::String("false".into()));
400 }
401 }
402 Value::Object(map)
403 }
404 QuestionType::Enum => {
405 let mut map = Map::new();
406 map.insert("type".into(), Value::String("Input.ChoiceSet".into()));
407 map.insert("id".into(), Value::String(question.id.clone()));
408 map.insert("style".into(), Value::String("compact".into()));
409 map.insert("isRequired".into(), Value::Bool(question.required));
410 let choices = question
411 .choices
412 .clone()
413 .unwrap_or_default()
414 .into_iter()
415 .map(|choice| {
416 json!({
417 "title": choice,
418 "value": choice,
419 })
420 })
421 .collect::<Vec<_>>();
422 map.insert("choices".into(), Value::Array(choices));
423 if let Some(value) = &question.current_value {
424 map.insert("value".into(), Value::String(value_to_display(value)));
425 }
426 Value::Object(map)
427 }
428 QuestionType::List => {
429 let mut map = Map::new();
430 map.insert("type".into(), Value::String("TextBlock".into()));
431 map.insert(
432 "text".into(),
433 Value::String(format!(
434 "List group '{}' ({} entries)",
435 question.title,
436 question
437 .current_value
438 .as_ref()
439 .and_then(Value::as_array)
440 .map(|entries| entries.len())
441 .unwrap_or_default()
442 )),
443 );
444 map.insert("wrap".into(), Value::Bool(true));
445 Value::Object(map)
446 }
447 }
448}
449
450fn question_type_label(kind: QuestionType) -> &'static str {
451 match kind {
452 QuestionType::String => "string",
453 QuestionType::Boolean => "boolean",
454 QuestionType::Integer => "integer",
455 QuestionType::Number => "number",
456 QuestionType::Enum => "enum",
457 QuestionType::List => "list",
458 }
459}
460
461fn value_to_display(value: &Value) -> String {
462 match value {
463 Value::String(text) => text.clone(),
464 Value::Bool(flag) => flag.to_string(),
465 Value::Number(num) => num.to_string(),
466 other => other.to_string(),
467 }
468}
469
470fn resolve_description(
471 fallback: Option<&str>,
472 text: Option<&crate::i18n::I18nText>,
473 resolved: Option<&ResolvedI18nMap>,
474 requested_locale: Option<&str>,
475 default_locale: Option<&str>,
476) -> Option<String> {
477 match (fallback, text) {
478 (Some(raw), _) => Some(resolve_i18n_text_with_locale(
479 raw,
480 text,
481 resolved,
482 requested_locale,
483 default_locale,
484 )),
485 (None, Some(i18n_text)) => {
486 let resolved_text = resolve_i18n_text_with_locale(
487 &i18n_text.key,
488 Some(i18n_text),
489 resolved,
490 requested_locale,
491 default_locale,
492 );
493 if resolved_text != i18n_text.key {
494 return Some(resolved_text);
495 }
496 Some(i18n_text.key.clone())
497 }
498 (None, None) => None,
499 }
500}