ftml_viewer_components/components/
problem.rs

1use flams_ontology::{
2    narration::problems::{
3        BlockFeedback, CheckedResult, FillinFeedback, FillinFeedbackKind, ProblemFeedback,
4        ProblemResponse as OrigResponse, ProblemResponseType, Solutions,
5    },
6    uris::{DocumentElementURI, Name, NarrativeURI},
7};
8use flams_utils::prelude::HMap;
9use flams_web_utils::inject_css;
10use ftml_extraction::prelude::FTMLElements;
11use leptos::{
12    context::Provider,
13    either::Either::{Left, Right},
14    prelude::*,
15};
16use leptos_posthoc::OriginalNode;
17use smallvec::SmallVec;
18
19use crate::{
20    components::{counters::SectionCounters, documents::ForcedName},
21    ts::{FragmentContinuation, JsOrRsF},
22    FragmentKind,
23};
24
25//use crate::ProblemOptions;
26
27#[derive(Debug, Clone)]
28pub enum ProblemState {
29    Interactive {
30        current_response: Option<OrigResponse>,
31        solution: Option<Solutions>,
32    },
33    Finished {
34        current_response: Option<OrigResponse>,
35    },
36    Graded {
37        feedback: ProblemFeedback,
38    },
39}
40
41pub struct ProblemOptions {
42    pub on_response: Option<JsOrRsF<OrigResponse, ()>>,
43    pub states: HMap<DocumentElementURI, ProblemState>,
44}
45
46impl std::fmt::Debug for ProblemOptions {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("ProblemOptions")
49            .field("on_response", &self.on_response.is_some())
50            .field("states", &self.states)
51            .finish()
52    }
53}
54
55#[derive(Clone, Debug)]
56pub struct CurrentProblem {
57    uri: DocumentElementURI,
58    solutions: RwSignal<u8>,
59    initial: Option<OrigResponse>,
60    responses: RwSignal<SmallVec<ProblemResponse, 4>>,
61    interactive: bool,
62    feedback: RwSignal<Option<ProblemFeedback>>,
63}
64impl CurrentProblem {
65    fn to_response(
66        uri: &DocumentElementURI,
67        responses: &SmallVec<ProblemResponse, 4>,
68    ) -> OrigResponse {
69        OrigResponse {
70            uri: uri.clone(),
71            responses: responses
72                .iter()
73                .map(|r| match r {
74                    ProblemResponse::MultipleChoice(_, sigs) => {
75                        flams_ontology::narration::problems::ProblemResponseType::MultipleChoice {
76                            value: sigs.clone(),
77                        }
78                    }
79                    ProblemResponse::SingleChoice(_, sig, _) => {
80                        flams_ontology::narration::problems::ProblemResponseType::SingleChoice {
81                            value: *sig,
82                        }
83                    }
84                    ProblemResponse::Fillinsol(s) => {
85                        flams_ontology::narration::problems::ProblemResponseType::Fillinsol {
86                            value: s.clone(),
87                        }
88                    }
89                })
90                .collect(),
91        }
92    }
93}
94
95#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
96enum ProblemResponse {
97    MultipleChoice(bool, SmallVec<bool, 8>),
98    SingleChoice(bool, Option<u16>, u16),
99    Fillinsol(String),
100}
101
102pub(super) fn problem<V: IntoView + 'static>(
103    uri: &DocumentElementURI,
104    autogradable: bool,
105    sub_problem: bool,
106    styles: Box<[Name]>,
107    children: impl FnOnce() -> V + Send + 'static,
108) -> impl IntoView {
109    let kind = FragmentKind::Problem {
110        is_sub_problem: sub_problem,
111        is_autogradable: autogradable,
112    };
113    inject_css("ftml-sections", include_str!("sections.css"));
114    let mut counters: SectionCounters = expect_context();
115    let style = counters.get_problem(&styles);
116    let cls = {
117        let mut s = String::new();
118        s.push_str("ftml-problem");
119        for style in styles {
120            s.push(' ');
121            s.push_str("ftml-problem-");
122            s.push_str(style.first_name().as_ref());
123        }
124        s
125    };
126
127    let uri = with_context::<ForcedName, _>(|n| n.update(uri)).unwrap_or_else(|| uri.clone());
128    let mut ex = CurrentProblem {
129        solutions: RwSignal::new(0),
130        uri,
131        initial: None,
132        interactive: true,
133        responses: RwSignal::new(SmallVec::new()),
134        feedback: RwSignal::new(None),
135    };
136    let responses = ex.responses;
137    let is_done = with_context(|opt: &ProblemOptions| {
138        match opt.states.get(&ex.uri) {
139            Some(ProblemState::Graded { feedback }) => {
140                ex.feedback
141                    .update_untracked(|v| *v = Some(feedback.clone()));
142                return Left(true);
143            }
144            Some(ProblemState::Interactive {
145                current_response: Some(resp),
146                ..
147            }) => ex.initial = Some(resp.clone()),
148            Some(ProblemState::Finished {
149                current_response: Some(resp),
150            }) => {
151                ex.initial = Some(resp.clone());
152                ex.interactive = false;
153            }
154            _ => (),
155        }
156        if let Some(f) = &opt.on_response {
157            tracing::debug!("Problem: Using onResponse callback");
158            Right(f.clone())
159        } else {
160            Left(false)
161        }
162    })
163    .unwrap_or(Left(false));
164    let uri = ex.uri.clone();
165    let uuri = NarrativeURI::Element(ex.uri.clone());
166    FragmentContinuation::wrap(
167        &(uri.clone(), kind),
168        view! {
169          <Provider value=ex><Provider value=counters><Provider value=ForcedName::default()><Provider value=uuri><div class=cls style=style>
170              {//<form>{
171                let r = children();
172                match is_done {
173                  Left(true) => Left(r),
174                  Right(f) => {
175                    let _ = Effect::new(move |_| {
176                      if let Some(resp) = responses.try_with(|resp|
177                        CurrentProblem::to_response(&uri, resp)
178                      ) {
179                        let _ = f.apply(&resp);
180                      }
181                    });
182                    Left(r)
183                  }
184                  _ if responses.get_untracked().is_empty() =>
185                    Left(r),
186                  _ => Right(view!{
187                    {r}
188                    {submit_answer()}
189                  })
190                }
191              }//</form>
192          </div></Provider></Provider></Provider></Provider>
193        },
194    )
195}
196
197fn submit_answer() -> impl IntoView {
198    use thaw::{Button, ButtonSize};
199    with_context(|current: &CurrentProblem| {
200        let uri = current.uri.clone();
201        let responses = current.responses;
202        let feedback = current.feedback;
203        move || {
204            if feedback.with(Option::is_none) {
205                let do_solution = move |uri: &_, r: &Solutions| {
206                    let resp = responses
207                        .with_untracked(|responses| CurrentProblem::to_response(uri, responses));
208                    if let Some(r) = r.check(&resp) {
209                        feedback.set(Some(r));
210                    } else {
211                        tracing::error!("Answer to Problem does not match solution");
212                    }
213                };
214                let uri = uri.clone();
215                let foract = if let Some(s) = with_context(|opt: &ProblemOptions| {
216                    if let Some(ProblemState::Interactive {
217                        solution: Some(sol),
218                        ..
219                    }) = opt.states.get(&uri)
220                    {
221                        Some(sol.clone())
222                    } else {
223                        None
224                    }
225                })
226                .flatten()
227                {
228                    leptos::either::Either::Left(move || do_solution(&uri, &s))
229                } else {
230                    leptos::either::Either::Right(Action::new(move |()| {
231                        let uri = uri.clone();
232                        let do_solution = do_solution.clone();
233                        async move {
234                            match crate::remote::server_config.solution(uri.clone()).await {
235                                Ok(r) => do_solution(&uri, &r),
236                                Err(s) => tracing::error!("{s}"),
237                            }
238                        }
239                    }))
240                };
241                let foract = move || match &foract {
242                    leptos::either::Either::Right(act) => {
243                        act.dispatch(());
244                    }
245                    leptos::either::Either::Left(sol) => sol(),
246                };
247                Some(view! {
248                  <div style="margin:5px 0;"><div style="margin-left:auto;width:fit-content;">
249                    <Button size=ButtonSize::Small on_click=move |_| {foract()}>"Submit Answer"</Button>
250                  </div></div>
251                })
252            } else {
253                None
254            }
255        }
256    })
257}
258
259pub(super) fn hint<V: IntoView + 'static>(
260    children: impl FnOnce() -> V + Send + 'static,
261) -> impl IntoView {
262    use flams_web_utils::components::{Collapsible, Header};
263    view! {
264      <Collapsible>
265        <Header slot><span style="font-style:italic;color:gray">"Hint"</span></Header>
266        {children()}
267      </Collapsible>
268    }
269}
270
271#[allow(clippy::needless_pass_by_value)]
272#[allow(unused_variables)]
273pub(super) fn solution(
274    _skip: usize,
275    _elements: FTMLElements,
276    orig: OriginalNode,
277    _id: Option<Box<str>>,
278) -> impl IntoView {
279    let Some((solutions, feedback)) =
280        with_context::<CurrentProblem, _>(|e| (e.solutions, e.feedback))
281    else {
282        tracing::error!("solution outside of problem!");
283        return None;
284    };
285    let idx = solutions.get_untracked();
286    solutions.update_untracked(|i| *i += 1);
287    #[cfg(any(feature = "csr", feature = "hydrate"))]
288    {
289        if orig.child_element_count() == 0 {
290            tracing::debug!("Solution removed!");
291        } else {
292            tracing::debug!("Solution exists!");
293        }
294        Some(move || {
295            feedback.with(|f| {
296                f.as_ref().and_then(|f| {
297                    let Some(f) = f.solutions.get(idx as usize) else {
298                        tracing::error!("No solution!");
299                        return None;
300                    };
301                    Some(view! {
302                      <div style="background-color:lawngreen;">
303                        <span inner_html=f.to_string()/>
304                      </div>
305                    })
306                })
307            })
308        })
309        // TODO
310    }
311    #[cfg(not(any(feature = "csr", feature = "hydrate")))]
312    {
313        Some(())
314    }
315}
316
317#[allow(clippy::needless_pass_by_value)]
318#[allow(unused_variables)]
319pub(super) fn gnote(_skip: usize, _elements: FTMLElements, orig: OriginalNode) -> impl IntoView {
320    #[cfg(any(feature = "csr", feature = "hydrate"))]
321    {
322        if orig.child_element_count() == 0 {
323            tracing::debug!("Grading note removed!");
324        } else {
325            tracing::debug!("Grading note exists!");
326        }
327        // TODO
328    }
329    #[cfg(not(any(feature = "csr", feature = "hydrate")))]
330    {
331        ()
332    }
333}
334
335#[derive(Clone)]
336struct CurrentChoice(usize);
337
338pub(super) fn choice_block<V: IntoView + 'static>(
339    multiple: bool,
340    inline: bool,
341    children: impl FnOnce() -> V + Send + 'static,
342) -> impl IntoView {
343    let response = if multiple {
344        ProblemResponse::MultipleChoice(inline, SmallVec::new())
345    } else {
346        ProblemResponse::SingleChoice(inline, None, 0)
347    };
348    let Some(i) = with_context::<CurrentProblem, _>(|ex| {
349        ex.responses.try_update_untracked(|ex| {
350            let i = ex.len();
351            ex.push(response);
352            i
353        })
354    })
355    .flatten() else {
356        tracing::error!(
357            "{} choice block outside of a problem!",
358            if multiple { "multiple" } else { "single" }
359        );
360        return None;
361    };
362    Some(view! {<Provider value=CurrentChoice(i)>{children()}</Provider>})
363}
364
365pub(super) fn problem_choice<V: IntoView + 'static>(
366    children: impl Fn() -> V + Send + 'static + Clone,
367) -> impl IntoView {
368    let Some(CurrentChoice(block)) = use_context() else {
369        tracing::error!("choice outside of choice block!");
370        return None;
371    };
372    let Some(ex) = use_context::<CurrentProblem>() else {
373        tracing::error!("choice outside of problem!");
374        return None;
375    };
376    let Some((multiple, inline)) = ex
377        .responses
378        .try_update_untracked(|resp| {
379            resp.get_mut(block).map(|l| match l {
380                ProblemResponse::MultipleChoice(inline, sigs) => {
381                    let idx = sigs.len();
382                    sigs.push(false);
383                    Some((Left(idx), *inline))
384                }
385                ProblemResponse::SingleChoice(inline, _, total) => {
386                    let val = *total;
387                    *total += 1;
388                    Some((Right(val), *inline))
389                }
390                ProblemResponse::Fillinsol(_) => None,
391            })
392        })
393        .flatten()
394        .flatten()
395    else {
396        tracing::error!("choice outside of choice block!");
397        return None;
398    };
399    let selected = if let Some(init) = ex.initial.as_ref().and_then(|i| i.responses.get(block)) {
400        match (init, multiple) {
401            (ProblemResponseType::MultipleChoice { value }, Left(idx)) => {
402                value.get(idx).copied().unwrap_or_default()
403            }
404            (ProblemResponseType::SingleChoice { value }, Right(val)) => {
405                value.is_some_and(|v| v == val)
406            }
407            _ => false,
408        }
409    } else {
410        false
411    };
412    let disabled = !ex.interactive;
413    Some(match multiple {
414        Left(idx) => Left(multiple_choice(
415            idx,
416            block,
417            inline,
418            selected,
419            disabled,
420            ex.responses,
421            ex.feedback,
422            children,
423        )),
424        Right(idx) => Right(single_choice(
425            idx,
426            block,
427            inline,
428            selected,
429            disabled,
430            ex.responses,
431            ex.uri,
432            ex.feedback,
433            children,
434        )),
435    })
436}
437
438fn multiple_choice<V: IntoView + 'static>(
439    idx: usize,
440    block: usize,
441    inline: bool,
442    orig_selected: bool,
443    disabled: bool,
444    responses: RwSignal<SmallVec<ProblemResponse, 4>>,
445    feedback: RwSignal<Option<ProblemFeedback>>,
446    children: impl Fn() -> V + Send + 'static + Clone,
447) -> impl IntoView {
448    use leptos::either::{Either::Left, Either::Right, EitherOf3 as Either};
449    use thaw::Icon;
450    move || {
451        feedback.with(|v|
452      if let Some(feedback) = v.as_ref() {
453        let err = || {
454          tracing::error!("Answer to problem does not match solution:");
455          Either::C(view!(<div style="color:red;">"ERROR"</div>))
456        };
457        let Some(CheckedResult::MultipleChoice{selected,choices}) = feedback.data.get(block) else {return err()};
458        let Some(selected) = selected.get(idx).copied() else { return err() };
459        let Some(BlockFeedback{is_correct,verdict_str,feedback}) = choices.get(idx) else { return err() };
460        let icon = if selected == *is_correct {
461          view!(<Icon icon=icondata_ai::AiCheckCircleOutlined style="color:green;"/>)
462        } else {
463          view!(<Icon icon=icondata_ai::AiCloseCircleOutlined style="color:red;"/>)
464        };
465        let bx = if selected {
466          Left(view!(<input type="checkbox" checked disabled/>))
467        } else {
468          Right(view!(<input type="checkbox" disabled/>))
469        };
470        let verdict = if *is_correct {
471          Left(view!(<span style="color:green;" inner_html=verdict_str.clone()/>))
472        } else {
473          Right(view!(<span style="color:red;" inner_html=verdict_str.clone()/>))
474        };
475        Either::B(view!{
476          {icon}{bx}{children()}" "{verdict}" "
477          {if inline {None} else {Some(view!(<br/>))}}
478          <span style="background-color:lightgray;" inner_html=feedback.clone()/>
479        })
480      } else {
481        let sig = create_write_slice(responses,
482          move |resp,val| {
483            let resp = resp.get_mut(block).expect("Signal error in problem");
484            let ProblemResponse::MultipleChoice(_,v) = resp else { panic!("Signal error in problem")};
485            v[idx] = val;
486          }
487        );
488        sig.set(orig_selected);
489        let rf = NodeRef::<leptos::html::Input>::new();
490        let on_change = move |_| {
491          let Some(ip) = rf.get_untracked() else {return};
492          let nv = ip.checked();
493          sig.set(nv);
494        };
495        Either::A(
496          view!{
497            <div style="display:inline;margin-right:5px;"><input node_ref=rf type="checkbox" on:change=on_change checked=orig_selected disabled=disabled/>{children()}</div>
498          }
499        )
500      }
501    )
502    }
503}
504
505fn single_choice<V: IntoView + 'static>(
506    idx: u16,
507    block: usize,
508    inline: bool,
509    orig_selected: bool,
510    disabled: bool,
511    responses: RwSignal<SmallVec<ProblemResponse, 4>>,
512    uri: DocumentElementURI,
513    feedback: RwSignal<Option<ProblemFeedback>>,
514    children: impl Fn() -> V + Send + 'static + Clone,
515) -> impl IntoView {
516    use leptos::either::{Either::Left, Either::Right, EitherOf3 as Either};
517    use thaw::Icon;
518    move || {
519        feedback.with(|v| {
520      if let Some(feedback) = v.as_ref() {
521        let err = || {
522          tracing::error!("Answer to problem does not match solution!");
523          Either::C(view!(<div style="color:red;">"ERROR"</div>))
524        };
525        let Some(CheckedResult::SingleChoice{selected,choices}) = feedback.data.get(block) else {return err()};
526        let Some(BlockFeedback{is_correct,verdict_str,feedback}) = choices.get(idx as usize) else { return err() };
527        let icon = if selected.is_some_and(|s| s ==  idx) && *is_correct {
528          Some(Left(view!(<Icon icon=icondata_ai::AiCheckCircleOutlined style="color:green;"/>)))
529        } else if selected.is_some_and(|s| s ==  idx) {
530          Some(Right(view!(<Icon icon=icondata_ai::AiCloseCircleOutlined style="color:red;"/>)))
531        } else {None};
532        let bx = if selected.is_some_and(|s| s ==  idx) {
533          Left(view!(<input type="radio" checked disabled/>))
534        } else {
535          Right(view!(<input type="radio" disabled/>))
536        };
537        let verdict = if *is_correct {
538          Left(view!(<span style="color:green;" inner_html=verdict_str.clone()/>))
539        } else {
540          Right(view!(<span style="color:red;" inner_html=verdict_str.clone()/>))
541        };
542        Either::B(view!{
543          {icon}{bx}{children()}" "{verdict}" "
544          {if inline {None} else {Some(view!(<br/>))}}
545          <span style="background-color:lightgray;" inner_html=feedback.clone()/>
546        })
547      } else {
548        let name = format!("{uri}_{block}");
549        let sig = create_write_slice(responses,
550          move |resp,()| {
551            let resp = resp.get_mut(block).expect("Signal error in problem");
552            let ProblemResponse::SingleChoice(_,i,_) = resp else { panic!("Signal error in problem")};
553            *i = Some(idx);
554          }
555        );
556        if orig_selected {sig.set(());}
557        let rf = NodeRef::<leptos::html::Input>::new();
558        let on_change = move |_| {
559          let Some(ip) = rf.get_untracked() else {return};
560          if ip.checked() { sig.set(()); }
561        };
562        Either::A(view!{
563          <div style="display:inline;margin-right:5px;"><input node_ref=rf type="radio" name=name on:change=on_change checked=orig_selected disabled=disabled/>{children()}</div>
564        })
565      }
566    })
567    }
568}
569
570/*
571  let feedback = ex.feedback;
572  move || {
573    if feedback.with(|f| f.is_some()) {}
574    else {
575
576    }
577  }
578*/
579
580pub(super) fn fillinsol(wd: Option<f32>) -> impl IntoView {
581    use leptos::either::EitherOf3 as Either;
582    use thaw::Icon;
583    let Some(ex) = use_context::<CurrentProblem>() else {
584        tracing::error!("choice outside of problem!");
585        return None;
586    };
587    let Some(choice) = ex.responses.try_update_untracked(|resp| {
588        let i = resp.len();
589        resp.push(ProblemResponse::Fillinsol(String::new()));
590        i
591    }) else {
592        tracing::error!("fillinsol outside of an problem!");
593        return None;
594    };
595    let feedback = ex.feedback;
596    Some(move || {
597        let style = wd.map(|wd| format!("width:{wd}px;"));
598        feedback.with(|v|
599    if let Some(feedback) = v.as_ref() {
600      let err = || {
601        tracing::error!("Answer to problem does not match solution!");
602        Either::C(view!(<div style="color:red;">"ERROR"</div>))
603      };
604      let Some(CheckedResult::FillinSol { matching, text, options }) = feedback.data.get(choice) else {return err()};
605      let (correct,feedback) = if let Some(m) = matching {
606        let Some(FillinFeedback{is_correct,feedback,..}) = options.get(*m) else {return err()};
607
608        (*is_correct,Some(feedback.clone()))
609      } else {(false,None)};
610      let solution = if correct { None } else {
611        options.iter().find_map(|f| match f{
612          FillinFeedback{is_correct:true,kind:FillinFeedbackKind::Exact(s),..} => Some(s.clone()),
613          _ => None
614        })
615      };
616      let icon = if correct {
617        view!(<Icon icon=icondata_ai::AiCheckCircleOutlined style="color:green;"/>)
618      } else {
619        view!(<Icon icon=icondata_ai::AiCloseCircleOutlined style="color:red;"/>)
620      };
621      Either::B(view!{
622        {icon}" "
623        <input type="text" style=style disabled value=text.clone()/>
624        {solution.map(|s| view!(" "<pre style="color:green;display:inline;">{s}</pre>))}
625        {feedback.map(|s| view!(" "<span style="background-color:lightgray;" inner_html=s/>))}
626      })
627    } else {
628      let sig = create_write_slice(ex.responses,
629        move |resps,val| {
630          let resp = resps.get_mut(choice).expect("Signal error in problem");
631          let ProblemResponse::Fillinsol(s) = resp else { panic!("Signal error in problem")};
632          *s = val;
633        }
634      );
635      let txt = if let Some(ProblemResponseType::Fillinsol{value:s}) = ex.initial.as_ref().and_then(|i| i.responses.get(choice)) {
636          sig.set(s.clone());
637          s.clone()
638      } else {String::new()};
639      let disabled = !ex.interactive;
640      Either::A(view!{
641        <input type="text" style=style value=txt disabled=disabled on:input:target=move |ev| {sig.set(ev.target().value());}/>
642      })
643    }
644  )
645    })
646}