ftml_viewer_components/components/
problem.rs1use 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#[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 {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 }</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 }
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 }
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
570pub(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}