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