Skip to main content

qa_spec/
render.rs

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/// Status labels returned by the renderers.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RenderStatus {
18    /// More input is required.
19    NeedInput,
20    /// All visible questions are filled.
21    Complete,
22    /// Something unexpected occurred.
23    Error,
24}
25
26impl RenderStatus {
27    /// Human-friendly label that matches the renderer requirements.
28    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/// Progress counters exposed to renderers.
38#[derive(Debug, Clone)]
39pub struct RenderProgress {
40    pub answered: usize,
41    pub total: usize,
42}
43
44/// Describes a single question for render outputs.
45#[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/// Collected payload used by both text and JSON renderers.
63#[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
76/// Build the renderer payload from the specification, context, and answers.
77pub fn build_render_payload(spec: &FormSpec, ctx: &Value, answers: &Value) -> RenderPayload {
78    build_render_payload_with_i18n(spec, ctx, answers, None)
79}
80
81/// Build the renderer payload from the specification, context, and answers with optional i18n map.
82pub 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
164/// Render the payload as a structured JSON-friendly value.
165pub 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
230/// Render the payload as human-friendly text.
231pub 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
287/// Render the payload as an Adaptive Card v1.3 transport.
288pub 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}