1use std::collections::HashSet;
2
3use crate::input::{InputEvent, InputState};
4use gpui::{
5 App, Context, Entity, EventEmitter, FocusHandle, Focusable as _, SharedString, Subscription,
6 Window,
7};
8
9use super::types::*;
10
11struct ItemRuntime {
12 disabled: bool,
13 choice_disabled: Vec<bool>,
14 input_disabled: bool,
15 answer: QuestionnaireAnswer,
16 initial_answer: QuestionnaireAnswer,
17 initial_input_value: Option<SharedString>,
18 skipped: bool,
19 validation_attempted: bool,
20 internal_error: Option<QuestionnaireValidationError>,
21 external_error: Option<QuestionnaireValidationError>,
22 focus_handle: FocusHandle,
23 choice_focus_handles: Vec<FocusHandle>,
24 input_focus_handle: Option<FocusHandle>,
25}
26
27pub struct QuestionnaireState {
29 items: Vec<QuestionnaireItemDefinition>,
30 runtime: Vec<ItemRuntime>,
31 current: Option<usize>,
32 initial_current: Option<SharedString>,
33 shortcut_mode: Option<QuestionnaireShortcutMode>,
34 complete: bool,
35 focus_handle: FocusHandle,
36 _subscriptions: Vec<Subscription>,
37}
38
39impl QuestionnaireState {
40 pub fn new(
41 items: Vec<QuestionnaireItemDefinition>,
42 cx: &mut Context<Self>,
43 ) -> Result<Self, QuestionnaireSchemaError> {
44 Self::validate_schema(&items)?;
45
46 let mut runtime = Vec::with_capacity(items.len());
47 let mut subscriptions = Vec::new();
48
49 for item in &items {
50 let mut answer = QuestionnaireAnswer::new().with_choices(
51 item.choices()
52 .iter()
53 .filter(|choice| choice.is_default_selected() && !choice.is_disabled())
54 .map(|choice| choice.value().clone()),
55 );
56
57 let mut initial_input_value = None;
58 let mut input_focus_handle = None;
59 if let Some(input) = item.input() {
60 let state = input.state();
61 state.update(cx, |state, cx| {
62 state.set_disabled(item.is_disabled() || input.is_disabled(), cx);
63 });
64
65 let value = state.read(cx).value();
66 initial_input_value = Some(value.clone());
67 input_focus_handle = Some(state.focus_handle(cx));
68 if !value.trim().is_empty() && !input.is_disabled() {
69 if !item.is_multiple() {
70 answer = QuestionnaireAnswer::new();
71 }
72 answer.freeform = Some(value);
73 }
74
75 subscriptions.push(cx.subscribe(state, |this, input, event: &InputEvent, cx| {
76 if matches!(event, InputEvent::Change) {
77 this.on_input_change(&input, cx);
78 }
79 }));
80 }
81
82 runtime.push(ItemRuntime {
83 disabled: item.is_disabled(),
84 choice_disabled: item
85 .choices()
86 .iter()
87 .map(QuestionnaireChoiceDefinition::is_disabled)
88 .collect(),
89 input_disabled: item
90 .input()
91 .is_none_or(QuestionnaireInputDefinition::is_disabled),
92 initial_answer: answer.clone(),
93 initial_input_value,
94 answer,
95 skipped: false,
96 validation_attempted: false,
97 internal_error: None,
98 external_error: None,
99 focus_handle: cx.focus_handle(),
100 choice_focus_handles: item.choices().iter().map(|_| cx.focus_handle()).collect(),
101 input_focus_handle,
102 });
103 }
104
105 let current = runtime.iter().position(|item| !item.disabled);
106 let initial_current = current.map(|ix| items[ix].name().clone());
107
108 Ok(Self {
109 items,
110 runtime,
111 current,
112 initial_current,
113 shortcut_mode: None,
114 complete: false,
115 focus_handle: cx.focus_handle(),
116 _subscriptions: subscriptions,
117 })
118 }
119
120 fn validate_schema(
121 items: &[QuestionnaireItemDefinition],
122 ) -> Result<(), QuestionnaireSchemaError> {
123 let mut item_names = HashSet::new();
124 for item in items {
125 if !item_names.insert(item.name().to_string()) {
126 return Err(QuestionnaireSchemaError::DuplicateItem(item.name().clone()));
127 }
128
129 let mut choices = HashSet::new();
130 let mut defaults = 0;
131 for choice in item.choices() {
132 if !choices.insert(choice.value().to_string()) {
133 return Err(QuestionnaireSchemaError::DuplicateChoice {
134 item: item.name().clone(),
135 choice: choice.value().clone(),
136 });
137 }
138 defaults += usize::from(choice.is_default_selected());
139 }
140 if !item.is_multiple() && defaults > 1 {
141 return Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem(
142 item.name().clone(),
143 ));
144 }
145 }
146 Ok(())
147 }
148
149 pub fn with_current_item(
150 mut self,
151 name: impl Into<SharedString>,
152 ) -> Result<Self, QuestionnaireSchemaError> {
153 let name = name.into();
154 let ix = self.item_ix(&name)?;
155 if !self.runtime[ix].disabled {
156 self.current = Some(ix);
157 self.initial_current = Some(name);
158 }
159 Ok(self)
160 }
161
162 pub fn with_shortcuts(mut self, mode: QuestionnaireShortcutMode) -> Self {
163 self.shortcut_mode = Some(mode);
164 self
165 }
166
167 pub fn current_item(&self) -> Option<&SharedString> {
168 self.current.map(|ix| self.items[ix].name())
169 }
170
171 pub fn current_ix(&self) -> Option<usize> {
172 let current = self.current?;
173 self.enabled_indices().position(|ix| ix == current)
174 }
175
176 pub fn total(&self) -> usize {
177 self.enabled_indices().count()
178 }
179
180 pub fn progress(&self) -> QuestionnaireProgressState {
181 QuestionnaireProgressState::new(self.current_ix().map_or(0, |ix| ix + 1), self.total())
182 }
183
184 pub fn item_definition(&self, name: &str) -> Option<&QuestionnaireItemDefinition> {
185 self.items.iter().find(|item| item.name().as_ref() == name)
186 }
187
188 pub fn choice_definition(
189 &self,
190 item: &str,
191 value: &str,
192 ) -> Option<&QuestionnaireChoiceDefinition> {
193 self.item_definition(item)?
194 .choices()
195 .iter()
196 .find(|choice| choice.value().as_ref() == value)
197 }
198
199 pub fn item_state(&self, name: &str) -> Option<QuestionnaireItemState> {
200 let ix = self.item_ix_opt(name)?;
201 let definition = &self.items[ix];
202 Some(QuestionnaireItemState::new(
203 definition.name().clone(),
204 self.status(ix),
205 definition.is_required(),
206 definition.is_multiple(),
207 self.runtime[ix].disabled,
208 self.error_at(ix).is_some(),
209 definition.input().is_some(),
210 ))
211 }
212
213 pub fn choice_state(&self, item: &str, value: &str) -> Option<QuestionnaireChoiceState> {
214 let item_ix = self.item_ix_opt(item)?;
215 let choice_ix = self.choice_ix_opt(item_ix, value)?;
216 let runtime = &self.runtime[item_ix];
217 let definition = &self.items[item_ix].choices()[choice_ix];
218 Some(QuestionnaireChoiceState::new(
219 definition.value().clone(),
220 runtime.answer.choices.contains(definition.value()),
221 runtime.disabled || runtime.choice_disabled[choice_ix],
222 self.error_at(item_ix).is_some(),
223 self.shortcut_for_choice(item, value),
224 ))
225 }
226
227 pub fn choice_position(&self, item: &str, value: &str) -> Option<(usize, usize)> {
230 let definition = self.item_definition(item)?;
231 let enabled: Vec<_> = definition
232 .choices()
233 .iter()
234 .filter(|choice| {
235 self.choice_state(item, choice.value())
236 .is_some_and(|choice| !choice.is_disabled())
237 })
238 .collect();
239 enabled
240 .iter()
241 .position(|choice| choice.value().as_ref() == value)
242 .map(|position| (position + 1, enabled.len()))
243 }
244
245 pub fn navigation_state(&self) -> QuestionnaireNavigationState {
246 let Some(ix) = self.current_ix() else {
247 return QuestionnaireNavigationState::default();
248 };
249 let total = self.total();
250 let item_ix = self
251 .current
252 .expect("current item exists when enabled index exists");
253 QuestionnaireNavigationState::new(
254 ix > 0,
255 ix + 1 < total,
256 !self.items[item_ix].is_required(),
257 ix + 1 == total,
258 self.status(item_ix) != QuestionnaireItemStatus::Unanswered,
259 )
260 }
261
262 pub fn answer(&self, name: &str) -> Option<QuestionnaireAnswer> {
263 let ix = self.item_ix_opt(name)?;
264 Some(self.effective_answer(ix))
265 }
266
267 pub fn answers(&self) -> QuestionnaireAnswers {
268 QuestionnaireAnswers::from_entries(
269 self.enabled_indices()
270 .map(|ix| (self.items[ix].name().clone(), self.effective_answer(ix)))
271 .collect(),
272 )
273 }
274
275 pub fn error(&self, name: &str) -> Option<&QuestionnaireValidationError> {
278 self.item_ix_opt(name).and_then(|ix| self.error_at(ix))
279 }
280
281 pub fn is_complete(&self) -> bool {
282 self.complete
283 }
284
285 pub fn input_state(&self, name: &str) -> Option<Entity<InputState>> {
286 self.item_definition(name)?
287 .input()
288 .map(|input| input.state().clone())
289 }
290
291 pub fn focus_handle(&self) -> &FocusHandle {
292 &self.focus_handle
293 }
294
295 pub fn item_focus_handle(&self, name: &str) -> Option<&FocusHandle> {
296 self.item_ix_opt(name)
297 .map(|ix| &self.runtime[ix].focus_handle)
298 }
299
300 pub fn choice_focus_handle(&self, item: &str, value: &str) -> Option<&FocusHandle> {
301 let item_ix = self.item_ix_opt(item)?;
302 let choice_ix = self.choice_ix_opt(item_ix, value)?;
303 Some(&self.runtime[item_ix].choice_focus_handles[choice_ix])
304 }
305
306 pub fn is_current_input_focused(&self, window: &Window) -> bool {
307 let Some(ix) = self.current else { return false };
308 self.runtime[ix]
309 .input_focus_handle
310 .as_ref()
311 .is_some_and(|handle| handle.is_focused(window))
312 }
313
314 pub fn current_input_has_text(&self, cx: &App) -> bool {
317 let Some(item_ix) = self.current else {
318 return false;
319 };
320 self.items[item_ix]
321 .input()
322 .is_some_and(|input| !input.state().read(cx).value().trim().is_empty())
323 }
324
325 pub fn focused_current_choice(&self, window: &Window) -> Option<&SharedString> {
326 let ix = self.current?;
327 self.runtime[ix]
328 .choice_focus_handles
329 .iter()
330 .position(|handle| handle.is_focused(window))
331 .map(|choice_ix| self.items[ix].choices()[choice_ix].value())
332 }
333
334 pub fn shortcut_mode(&self) -> Option<QuestionnaireShortcutMode> {
335 self.shortcut_mode
336 }
337
338 pub fn shortcut_for_choice(&self, item: &str, value: &str) -> Option<SharedString> {
339 let mode = self.shortcut_mode?;
340 let item_ix = self.item_ix_opt(item)?;
341 let choice_ix = self.choice_ix_opt(item_ix, value)?;
342 if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] {
343 return None;
344 }
345 let enabled_position = (0..=choice_ix)
346 .filter(|ix| !self.runtime[item_ix].choice_disabled[*ix])
347 .count()
348 .checked_sub(1)?;
349 match mode {
350 QuestionnaireShortcutMode::Letters if enabled_position < 26 => {
351 Some(char::from(b'A' + enabled_position as u8).to_string().into())
352 }
353 QuestionnaireShortcutMode::Numbers if enabled_position < 9 => {
354 Some((enabled_position + 1).to_string().into())
355 }
356 _ => None,
357 }
358 }
359
360 pub fn choice_for_shortcut(&self, item: &str, key: &str) -> Option<&SharedString> {
361 let item_ix = self.item_ix_opt(item)?;
362 self.items[item_ix].choices().iter().find_map(|choice| {
363 self.shortcut_for_choice(item, choice.value())
364 .is_some_and(|shortcut| shortcut.as_ref().eq_ignore_ascii_case(key))
365 .then_some(choice.value())
366 })
367 }
368
369 pub fn activate_shortcut(
370 &mut self,
371 key: &str,
372 window: &mut Window,
373 cx: &mut Context<Self>,
374 ) -> bool {
375 let Some(item_ix) = self.current else {
376 return false;
377 };
378 let item = self.items[item_ix].name().clone();
379 let Some(choice) = self.choice_for_shortcut(&item, key).cloned() else {
380 return false;
381 };
382 if self.activate_choice(&item, &choice, cx).is_err() {
383 return false;
384 }
385 self.focus_choice(&item, &choice, window, cx)
386 }
387
388 pub fn set_current_item(
389 &mut self,
390 name: &str,
391 window: &mut Window,
392 cx: &mut Context<Self>,
393 ) -> Result<(), QuestionnaireSchemaError> {
394 let ix = self.item_ix(name)?;
395 if !self.runtime[ix].disabled {
396 self.current = Some(ix);
397 self.focus_current_item(window, cx);
398 cx.notify();
399 }
400 Ok(())
401 }
402
403 pub fn set_answer(
404 &mut self,
405 item: &str,
406 mut answer: QuestionnaireAnswer,
407 window: &mut Window,
408 cx: &mut Context<Self>,
409 ) -> Result<(), QuestionnaireSchemaError> {
410 let item_ix = self.item_ix(item)?;
411 let before = self.effective_answer(item_ix);
412 let before_status = self.status(item_ix);
413 if answer
414 .freeform
415 .as_ref()
416 .is_some_and(|value| value.trim().is_empty())
417 {
418 answer.freeform = None;
419 }
420 self.check_answer(item_ix, &answer)?;
421 answer.choices = self.items[item_ix]
422 .choices()
423 .iter()
424 .filter(|choice| answer.choices.contains(choice.value()))
425 .map(|choice| choice.value().clone())
426 .collect();
427 self.runtime[item_ix].answer = answer.clone();
428 self.runtime[item_ix].skipped = false;
429 if let (Some(input), Some(value)) =
430 (self.items[item_ix].input(), answer.freeform().cloned())
431 {
432 input
433 .state()
434 .update(cx, |input, cx| input.set_value(value, window, cx));
435 }
436 if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
437 self.answer_did_change(item_ix, false, cx);
438 }
439 Ok(())
440 }
441
442 pub fn set_input_value(
443 &mut self,
444 item: &str,
445 value: impl Into<SharedString>,
446 window: &mut Window,
447 cx: &mut Context<Self>,
448 ) -> Result<(), QuestionnaireSchemaError> {
449 let item_ix = self.item_ix(item)?;
450 let Some(input) = self.items[item_ix]
451 .input()
452 .map(|input| input.state().clone())
453 else {
454 return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
455 self.items[item_ix].name().clone(),
456 ));
457 };
458 let value: SharedString = value.into();
459 input.update(cx, |input, cx| input.set_value(value, window, cx));
460 self.sync_input_answer(item_ix, false, cx);
461 Ok(())
462 }
463
464 pub fn set_item_disabled(
465 &mut self,
466 name: &str,
467 disabled: bool,
468 window: &mut Window,
469 cx: &mut Context<Self>,
470 ) -> Result<(), QuestionnaireSchemaError> {
471 let ix = self.item_ix(name)?;
472 if self.runtime[ix].disabled == disabled {
473 return Ok(());
474 }
475 self.runtime[ix].disabled = disabled;
476 if let Some(input) = self.items[ix].input() {
477 let input_disabled = disabled || self.runtime[ix].input_disabled;
478 input
479 .state()
480 .update(cx, |input, cx| input.set_disabled(input_disabled, cx));
481 }
482 self.complete = false;
483 if self.current == Some(ix) && disabled {
484 let next = self
485 .enabled_indices()
486 .find(|candidate| *candidate > ix)
487 .or_else(|| {
488 self.enabled_indices()
489 .rev()
490 .find(|candidate| *candidate < ix)
491 });
492 self.current = next;
493 self.focus_current_item(window, cx);
494 } else if self.current.is_none() && !disabled {
495 self.current = Some(ix);
496 self.focus_current_item(window, cx);
497 }
498 cx.notify();
499 Ok(())
500 }
501
502 pub fn set_choice_disabled(
503 &mut self,
504 item: &str,
505 value: &str,
506 disabled: bool,
507 cx: &mut Context<Self>,
508 ) -> Result<(), QuestionnaireSchemaError> {
509 let item_ix = self.item_ix(item)?;
510 let choice_ix = self.choice_ix(item_ix, value)?;
511 if self.runtime[item_ix].choice_disabled[choice_ix] == disabled {
512 return Ok(());
513 }
514 self.runtime[item_ix].choice_disabled[choice_ix] = disabled;
515 self.answer_did_change(item_ix, false, cx);
516 Ok(())
517 }
518
519 pub fn set_external_error(
520 &mut self,
521 item: &str,
522 error: impl Into<SharedString>,
523 cx: &mut Context<Self>,
524 ) -> Result<(), QuestionnaireSchemaError> {
525 let ix = self.item_ix(item)?;
526 self.runtime[ix].external_error = Some(QuestionnaireValidationError::Message(error.into()));
527 self.complete = false;
528 cx.notify();
529 Ok(())
530 }
531
532 pub fn clear_external_error(
533 &mut self,
534 item: &str,
535 cx: &mut Context<Self>,
536 ) -> Result<(), QuestionnaireSchemaError> {
537 let ix = self.item_ix(item)?;
538 self.runtime[ix].external_error = None;
539 cx.notify();
540 Ok(())
541 }
542
543 pub fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
544 for ix in 0..self.items.len() {
545 let initial = self.runtime[ix].initial_answer.clone();
546 self.runtime[ix].answer = initial;
547 self.runtime[ix].skipped = false;
548 self.runtime[ix].validation_attempted = false;
549 self.runtime[ix].internal_error = None;
550 if let Some(input) = self.items[ix].input() {
551 let value = self.runtime[ix]
552 .initial_input_value
553 .as_ref()
554 .cloned()
555 .unwrap_or_default();
556 input
557 .state()
558 .update(cx, |input, cx| input.set_value(value, window, cx));
559 }
560 }
561 self.complete = false;
562 self.current = self
563 .initial_current
564 .as_ref()
565 .and_then(|name| self.item_ix_opt(name));
566 if self.current.is_some_and(|ix| self.runtime[ix].disabled) {
567 let next = self.enabled_indices().next();
568 self.current = next;
569 }
570 self.focus_current_item(window, cx);
571 cx.notify();
572 }
573
574 pub fn activate_choice(
575 &mut self,
576 item: &str,
577 value: &str,
578 cx: &mut Context<Self>,
579 ) -> Result<(), QuestionnaireSchemaError> {
580 let item_ix = self.item_ix(item)?;
581 let choice_ix = self.choice_ix(item_ix, value)?;
582 if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] {
583 return Ok(());
584 }
585 let before = self.effective_answer(item_ix);
586 let before_status = self.status(item_ix);
587
588 if self.items[item_ix].is_multiple() {
589 let selected = self.runtime[item_ix]
590 .answer
591 .choices
592 .iter()
593 .position(|choice| choice.as_ref() == value);
594 if let Some(ix) = selected {
595 self.runtime[item_ix].answer.choices.remove(ix);
596 } else {
597 self.runtime[item_ix].answer.choices.push(value.into());
598 }
599 } else {
600 self.runtime[item_ix].answer.choices.clear();
601 self.runtime[item_ix].answer.choices.push(value.into());
602 self.runtime[item_ix].answer.freeform = None;
603 }
604 self.runtime[item_ix].skipped = false;
605 if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
606 self.answer_did_change(item_ix, true, cx);
607 }
608 Ok(())
609 }
610
611 pub fn confirm_current(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
612 let Some(current_ix) = self.current_ix() else {
613 return false;
614 };
615 if current_ix + 1 == self.total() {
616 self.submit(window, cx)
617 } else {
618 self.go_next(window, cx)
619 }
620 }
621
622 pub fn go_previous(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
623 let Some(ix) = self.current_ix() else {
624 return false;
625 };
626 let enabled: Vec<_> = self.enabled_indices().collect();
627 if ix == 0 {
628 return false;
629 }
630 self.change_current(Some(enabled[ix - 1]), true, window, cx);
631 true
632 }
633
634 pub fn go_next(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
635 let Some(current) = self.current else {
636 return false;
637 };
638 if !self.validate_item(current) {
639 self.focus_invalid_item(self.items[current].name(), window, cx);
640 cx.notify();
641 return false;
642 }
643 let Some(ix) = self.current_ix() else {
644 return false;
645 };
646 let enabled: Vec<_> = self.enabled_indices().collect();
647 if ix + 1 >= enabled.len() {
648 return false;
649 }
650 self.change_current(Some(enabled[ix + 1]), true, window, cx);
651 true
652 }
653
654 pub fn skip_current(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
655 let Some(ix) = self.current else { return false };
656 if self.items[ix].is_required() {
657 return false;
658 }
659 self.runtime[ix].answer = QuestionnaireAnswer::new();
660 self.runtime[ix].skipped = true;
661 self.complete = false;
662 self.emit_answer_changed(ix, cx);
663 if self
664 .current_ix()
665 .is_some_and(|current| current + 1 == self.total())
666 {
667 self.submit(window, cx)
668 } else {
669 self.go_next(window, cx)
670 }
671 }
672
673 pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
674 let enabled: Vec<_> = self.enabled_indices().collect();
675 let mut first_invalid = None;
676 for ix in enabled {
677 if !self.validate_item(ix) && first_invalid.is_none() {
678 first_invalid = Some(ix);
679 }
680 }
681 if let Some(ix) = first_invalid {
682 self.change_current(Some(ix), true, window, cx);
683 self.focus_invalid_item(self.items[ix].name(), window, cx);
684 cx.notify();
685 return false;
686 }
687
688 let submission = self.submission();
689 if !self.complete {
690 self.complete = true;
691 cx.emit(QuestionnaireEvent::Completed(submission.clone()));
692 }
693 cx.emit(QuestionnaireEvent::Submit(submission));
694 cx.notify();
695 true
696 }
697
698 pub fn focus_current_item(&self, window: &mut Window, cx: &mut App) -> bool {
699 let Some(ix) = self.current else { return false };
700 self.runtime[ix].focus_handle.focus(window, cx);
701 true
702 }
703
704 pub fn focus_invalid_item(&self, item: &str, window: &mut Window, cx: &mut App) -> bool {
705 let Some(item_ix) = self.item_ix_opt(item) else {
706 return false;
707 };
708 if self.runtime[item_ix].answer.freeform().is_some()
709 && !self.runtime[item_ix].input_disabled
710 && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle
711 {
712 focus_handle.focus(window, cx);
713 return true;
714 }
715 for (choice_ix, choice) in self.items[item_ix].choices().iter().enumerate() {
716 if self.runtime[item_ix]
717 .answer
718 .choices
719 .contains(choice.value())
720 && !self.runtime[item_ix].choice_disabled[choice_ix]
721 {
722 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
723 return true;
724 }
725 }
726 if let Some(choice_ix) = self.runtime[item_ix]
727 .choice_disabled
728 .iter()
729 .position(|disabled| !disabled)
730 {
731 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
732 return true;
733 }
734 if !self.runtime[item_ix].input_disabled
735 && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle
736 {
737 focus_handle.focus(window, cx);
738 return true;
739 }
740 self.runtime[item_ix].focus_handle.focus(window, cx);
741 true
742 }
743
744 pub fn focus_choice(&self, item: &str, value: &str, window: &mut Window, cx: &mut App) -> bool {
745 let Some(item_ix) = self.item_ix_opt(item) else {
746 return false;
747 };
748 let Some(choice_ix) = self.choice_ix_opt(item_ix, value) else {
749 return false;
750 };
751 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
752 true
753 }
754
755 pub fn focus_input(&self, item: &str, window: &mut Window, cx: &mut App) -> bool {
756 let Some(item_ix) = self.item_ix_opt(item) else {
757 return false;
758 };
759 let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle else {
760 return false;
761 };
762 focus_handle.focus(window, cx);
763 true
764 }
765
766 pub fn focus_previous_answer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
767 self.focus_adjacent_answer(-1, window, cx)
768 }
769
770 pub fn focus_next_answer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
771 self.focus_adjacent_answer(1, window, cx)
772 }
773
774 pub fn move_current_radio(
775 &mut self,
776 direction: isize,
777 window: &mut Window,
778 cx: &mut Context<Self>,
779 ) -> bool {
780 let Some(item_ix) = self.current else {
781 return false;
782 };
783 if self.items[item_ix].is_multiple() || self.is_current_input_focused(window) {
784 return false;
785 }
786 let enabled: Vec<_> = self.runtime[item_ix]
787 .choice_disabled
788 .iter()
789 .enumerate()
790 .filter_map(|(ix, disabled)| (!disabled).then_some(ix))
791 .collect();
792 if enabled.is_empty() {
793 return false;
794 }
795 let current = enabled
796 .iter()
797 .position(|ix| self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window))
798 .or_else(|| {
799 enabled.iter().position(|ix| {
800 self.runtime[item_ix]
801 .answer
802 .choices
803 .contains(self.items[item_ix].choices()[*ix].value())
804 })
805 });
806 let target = match (current, direction.is_negative()) {
807 (Some(ix), true) => ix.checked_sub(1).unwrap_or(enabled.len() - 1),
808 (Some(ix), false) => (ix + 1) % enabled.len(),
809 (None, true) => enabled.len() - 1,
810 (None, false) => 0,
811 };
812 let choice_ix = enabled[target];
813 let item = self.items[item_ix].name().clone();
814 let choice = self.items[item_ix].choices()[choice_ix].value().clone();
815 let _ = self.activate_choice(&item, &choice, cx);
816 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
817 true
818 }
819
820 fn focus_adjacent_answer(
821 &mut self,
822 direction: isize,
823 window: &mut Window,
824 cx: &mut Context<Self>,
825 ) -> bool {
826 let Some(item_ix) = self.current else {
827 return false;
828 };
829 if self.is_current_input_focused(window) && self.current_input_has_text(cx) {
830 return false;
831 }
832
833 #[derive(Clone, Copy)]
834 enum Target {
835 Choice(usize),
836 Input,
837 }
838 let mut targets = Vec::new();
839 for choice_ix in 0..self.items[item_ix].choices().len() {
840 if !self.runtime[item_ix].choice_disabled[choice_ix] {
841 targets.push(Target::Choice(choice_ix));
842 }
843 }
844 if self.items[item_ix].input().is_some() && !self.runtime[item_ix].input_disabled {
845 targets.push(Target::Input);
846 }
847 if targets.is_empty() {
848 return false;
849 }
850
851 let focused = targets.iter().position(|target| match target {
852 Target::Choice(ix) => {
853 self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window)
854 }
855 Target::Input => self.is_current_input_focused(window),
856 });
857
858 if focused.is_none() && self.runtime[item_ix].focus_handle.is_focused(window) {
859 let filled: Vec<_> = targets
860 .iter()
861 .enumerate()
862 .filter_map(|(ix, target)| match target {
863 Target::Choice(choice_ix)
864 if self.runtime[item_ix]
865 .answer
866 .choices
867 .contains(self.items[item_ix].choices()[*choice_ix].value()) =>
868 {
869 Some(ix)
870 }
871 Target::Input if self.runtime[item_ix].answer.freeform().is_some() => Some(ix),
872 _ => None,
873 })
874 .collect();
875 let filled_ix = if direction.is_negative() {
876 filled.last().copied()
877 } else {
878 filled.first().copied()
879 };
880 if let Some(filled_ix) = filled_ix {
881 match targets[filled_ix] {
882 Target::Choice(choice_ix) => {
883 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
884 }
885 Target::Input => {
886 if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle {
887 focus_handle.focus(window, cx);
888 }
889 }
890 }
891 return true;
892 }
893 }
894
895 let target_ix = match (focused, direction.is_negative()) {
896 (Some(ix), true) => ix.checked_sub(1).unwrap_or(targets.len() - 1),
897 (Some(ix), false) => (ix + 1) % targets.len(),
898 (None, true) => targets.len() - 1,
899 (None, false) => 0,
900 };
901
902 if !self.items[item_ix].is_multiple()
907 && focused.is_some_and(|focused_ix| {
908 matches!(targets[focused_ix], Target::Choice(_))
909 && matches!(targets[target_ix], Target::Choice(_))
910 })
911 {
912 return false;
913 }
914
915 match targets[target_ix] {
916 Target::Choice(choice_ix) => {
917 if !self.items[item_ix].is_multiple() {
918 let item = self.items[item_ix].name().clone();
919 let choice = self.items[item_ix].choices()[choice_ix].value().clone();
920 let _ = self.activate_choice(&item, &choice, cx);
921 }
922 self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
923 }
924 Target::Input => {
925 if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle {
926 focus_handle.focus(window, cx);
927 }
928 }
929 }
930 true
931 }
932
933 fn on_input_change(&mut self, input: &Entity<InputState>, cx: &mut Context<Self>) {
934 if let Some(ix) = self.input_item_ix(input) {
935 self.sync_input_answer(ix, true, cx);
936 }
937 }
938
939 fn sync_input_answer(&mut self, item_ix: usize, emit: bool, cx: &mut Context<Self>) {
940 if self.runtime[item_ix].disabled || self.runtime[item_ix].input_disabled {
941 return;
942 }
943 let before = self.effective_answer(item_ix);
944 let before_status = self.status(item_ix);
945 let Some(input) = self.items[item_ix].input() else {
946 return;
947 };
948 let value = input.state().read(cx).value();
949 if value.trim().is_empty() {
950 self.runtime[item_ix].answer.freeform = None;
951 } else {
952 if !self.items[item_ix].is_multiple() {
953 self.runtime[item_ix].answer.choices.clear();
954 }
955 self.runtime[item_ix].answer.freeform = Some(value);
956 self.runtime[item_ix].skipped = false;
957 }
958 if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
959 self.answer_did_change(item_ix, emit, cx);
960 }
961 }
962
963 fn answer_did_change(&mut self, item_ix: usize, emit: bool, cx: &mut Context<Self>) {
964 if self.runtime[item_ix].validation_attempted {
965 self.validate_item(item_ix);
966 } else {
967 self.runtime[item_ix].internal_error = None;
968 }
969 self.complete = false;
970 if emit {
971 self.emit_answer_changed(item_ix, cx);
972 }
973 cx.notify();
974 }
975
976 fn emit_answer_changed(&self, item_ix: usize, cx: &mut Context<Self>) {
977 cx.emit(QuestionnaireEvent::AnswerChanged(
978 QuestionnaireAnswerChange::new(
979 self.items[item_ix].name().clone(),
980 self.effective_answer(item_ix),
981 self.status(item_ix),
982 ),
983 ));
984 }
985
986 fn validate_item(&mut self, item_ix: usize) -> bool {
987 if self.runtime[item_ix].disabled || self.runtime[item_ix].skipped {
988 return true;
989 }
990 self.runtime[item_ix].validation_attempted = true;
991 let answer = self.effective_answer(item_ix);
992 let error = if answer.is_empty() {
993 Some(if self.items[item_ix].is_required() {
994 QuestionnaireValidationError::Required
995 } else {
996 QuestionnaireValidationError::Unanswered
997 })
998 } else if let Some(validator) = self.items[item_ix].validator().cloned() {
999 validator(&QuestionnaireValidationContext::new(
1000 self.items[item_ix].name().clone(),
1001 answer,
1002 self.answers(),
1003 ))
1004 .err()
1005 .map(QuestionnaireValidationError::Message)
1006 } else {
1007 None
1008 };
1009 self.runtime[item_ix].internal_error = error;
1010 self.error_at(item_ix).is_none()
1011 }
1012
1013 fn submission(&self) -> QuestionnaireSubmission {
1014 QuestionnaireSubmission::new(
1015 self.enabled_indices()
1016 .map(|ix| {
1017 QuestionnaireSubmissionItem::new(
1018 self.items[ix].name().clone(),
1019 self.status(ix),
1020 self.effective_answer(ix),
1021 )
1022 })
1023 .collect(),
1024 )
1025 }
1026
1027 fn status(&self, item_ix: usize) -> QuestionnaireItemStatus {
1028 if self.runtime[item_ix].skipped {
1029 QuestionnaireItemStatus::Skipped
1030 } else if self.effective_answer(item_ix).is_empty() {
1031 QuestionnaireItemStatus::Unanswered
1032 } else {
1033 QuestionnaireItemStatus::Answered
1034 }
1035 }
1036
1037 fn effective_answer(&self, item_ix: usize) -> QuestionnaireAnswer {
1038 if self.runtime[item_ix].disabled {
1039 return QuestionnaireAnswer::new();
1040 }
1041 let runtime = &self.runtime[item_ix];
1042 QuestionnaireAnswer {
1043 choices: self.items[item_ix]
1044 .choices()
1045 .iter()
1046 .filter(|choice| {
1047 runtime.answer.choices.contains(choice.value())
1048 && self
1049 .choice_ix_opt(item_ix, choice.value())
1050 .is_some_and(|ix| !runtime.choice_disabled[ix])
1051 })
1052 .map(|choice| choice.value().clone())
1053 .collect(),
1054 freeform: (!runtime.input_disabled)
1055 .then(|| runtime.answer.freeform.clone())
1056 .flatten(),
1057 }
1058 }
1059
1060 fn error_at(&self, item_ix: usize) -> Option<&QuestionnaireValidationError> {
1061 if self.runtime[item_ix].skipped || self.runtime[item_ix].disabled {
1062 return None;
1063 }
1064 self.runtime[item_ix].external_error.as_ref().or_else(|| {
1065 self.runtime[item_ix]
1066 .validation_attempted
1067 .then_some(self.runtime[item_ix].internal_error.as_ref())
1068 .flatten()
1069 })
1070 }
1071
1072 fn check_answer(
1073 &self,
1074 item_ix: usize,
1075 answer: &QuestionnaireAnswer,
1076 ) -> Result<(), QuestionnaireSchemaError> {
1077 let item = &self.items[item_ix];
1078 let sources = answer.choices.len() + usize::from(answer.freeform.is_some());
1079 if (!item.is_multiple() && sources > 1)
1080 || (answer.freeform.is_some() && item.input().is_none())
1081 {
1082 return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
1083 item.name().clone(),
1084 ));
1085 }
1086 for choice in &answer.choices {
1087 let choice_ix = self.choice_ix(item_ix, choice)?;
1088 if self.runtime[item_ix].choice_disabled[choice_ix] {
1089 return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
1090 item.name().clone(),
1091 ));
1092 }
1093 }
1094 Ok(())
1095 }
1096
1097 fn change_current(
1098 &mut self,
1099 next: Option<usize>,
1100 emit: bool,
1101 window: &mut Window,
1102 cx: &mut Context<Self>,
1103 ) {
1104 if self.current == next {
1105 return;
1106 }
1107 let previous = self.current.map(|ix| self.items[ix].name().clone());
1108 self.current = next;
1109 self.focus_current_item(window, cx);
1110 if emit {
1111 cx.emit(QuestionnaireEvent::CurrentItemChanged {
1112 previous,
1113 current: next.map(|ix| self.items[ix].name().clone()),
1114 });
1115 }
1116 cx.notify();
1117 }
1118
1119 fn enabled_indices(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
1120 self.runtime
1121 .iter()
1122 .enumerate()
1123 .filter_map(|(ix, runtime)| (!runtime.disabled).then_some(ix))
1124 }
1125
1126 fn item_ix(&self, name: &str) -> Result<usize, QuestionnaireSchemaError> {
1127 self.item_ix_opt(name)
1128 .ok_or_else(|| QuestionnaireSchemaError::UnknownItem(name.into()))
1129 }
1130
1131 fn item_ix_opt(&self, name: &str) -> Option<usize> {
1132 self.items
1133 .iter()
1134 .position(|item| item.name().as_ref() == name)
1135 }
1136
1137 fn choice_ix(&self, item_ix: usize, value: &str) -> Result<usize, QuestionnaireSchemaError> {
1138 self.choice_ix_opt(item_ix, value)
1139 .ok_or_else(|| QuestionnaireSchemaError::UnknownChoice {
1140 item: self.items[item_ix].name().clone(),
1141 choice: value.into(),
1142 })
1143 }
1144
1145 fn choice_ix_opt(&self, item_ix: usize, value: &str) -> Option<usize> {
1146 self.items[item_ix]
1147 .choices()
1148 .iter()
1149 .position(|choice| choice.value().as_ref() == value)
1150 }
1151
1152 fn input_item_ix(&self, input: &Entity<InputState>) -> Option<usize> {
1153 self.items.iter().position(|item| {
1154 item.input()
1155 .is_some_and(|definition| definition.state().entity_id() == input.entity_id())
1156 })
1157 }
1158}
1159
1160impl EventEmitter<QuestionnaireEvent> for QuestionnaireState {}
1161
1162#[cfg(test)]
1163mod tests {
1164 use gpui::{
1165 AppContext as _, Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext,
1166 div,
1167 };
1168
1169 use super::*;
1170
1171 struct Harness {
1172 state: Entity<QuestionnaireState>,
1173 first_input: Entity<InputState>,
1174 second_input: Entity<InputState>,
1175 events: Vec<&'static str>,
1176 _subscription: Subscription,
1177 }
1178
1179 impl Harness {
1180 fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
1181 let first_input = cx.new(|cx| InputState::new(window, cx));
1182 let second_input =
1183 cx.new(|cx| InputState::new(window, cx).default_value("initial draft"));
1184 let items = vec![
1185 QuestionnaireItemDefinition::new("first", "First question")
1186 .with_required(true)
1187 .with_choices([
1188 QuestionnaireChoiceDefinition::new("a", "A"),
1189 QuestionnaireChoiceDefinition::new("b", "B"),
1190 ])
1191 .with_input(QuestionnaireInputDefinition::new(
1192 first_input.clone(),
1193 "Another answer",
1194 )),
1195 QuestionnaireItemDefinition::new("second", "Second question")
1196 .with_multiple(true)
1197 .with_choices([
1198 QuestionnaireChoiceDefinition::new("x", "X"),
1199 QuestionnaireChoiceDefinition::new("y", "Y"),
1200 ])
1201 .with_input(QuestionnaireInputDefinition::new(
1202 second_input.clone(),
1203 "Another answer",
1204 ))
1205 .with_validator(|context| {
1206 (context.answer().freeform().map(SharedString::as_ref) == Some("valid"))
1207 .then_some(())
1208 .ok_or_else(|| SharedString::from("Use the valid answer"))
1209 }),
1210 QuestionnaireItemDefinition::new("disabled", "Disabled").with_disabled(true),
1211 ];
1212 let state = cx.new(|cx| {
1213 QuestionnaireState::new(items, cx)
1214 .unwrap()
1215 .with_shortcuts(QuestionnaireShortcutMode::Letters)
1216 });
1217 let subscription = cx.subscribe(&state, |this, _, event, _| {
1218 this.events.push(match event {
1219 QuestionnaireEvent::CurrentItemChanged { .. } => "current",
1220 QuestionnaireEvent::AnswerChanged(_) => "answer",
1221 QuestionnaireEvent::Completed(_) => "completed",
1222 QuestionnaireEvent::Submit(_) => "submit",
1223 });
1224 });
1225 Self {
1226 state,
1227 first_input,
1228 second_input,
1229 events: Vec::new(),
1230 _subscription: subscription,
1231 }
1232 }
1233 }
1234
1235 impl Render for Harness {
1236 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1237 div()
1238 }
1239 }
1240
1241 fn harness(
1242 cx: &mut TestAppContext,
1243 ) -> (
1244 Entity<Harness>,
1245 Entity<QuestionnaireState>,
1246 Entity<InputState>,
1247 Entity<InputState>,
1248 &mut VisualTestContext,
1249 ) {
1250 cx.update(crate::init);
1251 let (harness, cx) = cx.add_window_view(Harness::new);
1252 let (state, first_input, second_input) = harness.read_with(cx, |harness, _| {
1253 (
1254 harness.state.clone(),
1255 harness.first_input.clone(),
1256 harness.second_input.clone(),
1257 )
1258 });
1259 (harness, state, first_input, second_input, cx)
1260 }
1261
1262 #[test]
1263 fn schema_rejects_duplicate_names_and_invalid_single_defaults() {
1264 let duplicate_items = vec![
1265 QuestionnaireItemDefinition::new("same", "One"),
1266 QuestionnaireItemDefinition::new("same", "Two"),
1267 ];
1268 assert_eq!(
1269 QuestionnaireState::validate_schema(&duplicate_items),
1270 Err(QuestionnaireSchemaError::DuplicateItem("same".into()))
1271 );
1272
1273 let invalid_default = vec![
1274 QuestionnaireItemDefinition::new("single", "Single").with_choices([
1275 QuestionnaireChoiceDefinition::new("a", "A").with_default_selected(true),
1276 QuestionnaireChoiceDefinition::new("b", "B").with_default_selected(true),
1277 ]),
1278 ];
1279 assert_eq!(
1280 QuestionnaireState::validate_schema(&invalid_default),
1281 Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem(
1282 "single".into()
1283 ))
1284 );
1285
1286 let duplicate_choice = vec![
1287 QuestionnaireItemDefinition::new("item", "Item").with_choices([
1288 QuestionnaireChoiceDefinition::new("same", "One"),
1289 QuestionnaireChoiceDefinition::new("same", "Two"),
1290 ]),
1291 ];
1292 assert_eq!(
1293 QuestionnaireState::validate_schema(&duplicate_choice),
1294 Err(QuestionnaireSchemaError::DuplicateChoice {
1295 item: "item".into(),
1296 choice: "same".into(),
1297 })
1298 );
1299 }
1300
1301 #[gpui::test]
1302 fn validates_navigates_skips_and_emits_completion_before_submit(cx: &mut TestAppContext) {
1303 let (harness, state, _, _, cx) = harness(cx);
1304
1305 assert_eq!(cx.read(|cx| state.read(cx).progress().current()), 1);
1306 assert_eq!(cx.read(|cx| state.read(cx).progress().total()), 2);
1307 assert_eq!(
1308 cx.read(|cx| state.read(cx).item_state("first").unwrap().status()),
1309 QuestionnaireItemStatus::Unanswered
1310 );
1311 assert!(!cx.update(|window, cx| state.update(cx, |state, cx| state.submit(window, cx))));
1312 assert!(cx.read(|cx| state.read(cx).error("first").is_some()));
1313 assert_eq!(
1314 cx.read(|cx| state.read(cx).error("second").unwrap().clone()),
1315 QuestionnaireValidationError::Message("Use the valid answer".into())
1316 );
1317
1318 cx.update(|window, cx| {
1319 state.update(cx, |state, cx| {
1320 state.set_input_value("first", "draft", window, cx).unwrap();
1321 });
1322 });
1323 assert!(cx.read(|cx| state.read(cx).error("first").is_none()));
1324 cx.update(|window, cx| {
1325 state.update(cx, |state, cx| {
1326 state.set_input_value("first", "", window, cx).unwrap();
1327 });
1328 });
1329 assert!(
1330 cx.read(|cx| state.read(cx).error("first").is_some()),
1331 "once validation has been attempted, clearing an answer updates the error live"
1332 );
1333
1334 state.update(cx, |state, cx| {
1335 state.activate_choice("first", "a", cx).unwrap()
1336 });
1337 assert!(
1338 cx.update(|window, cx| { state.update(cx, |state, cx| state.go_next(window, cx)) })
1339 );
1340 assert_eq!(
1341 cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1342 "second"
1343 );
1344 assert!(
1345 cx.update(|window, cx| {
1346 state.update(cx, |state, cx| state.skip_current(window, cx))
1347 })
1348 );
1349
1350 assert!(cx.read(|cx| state.read(cx).is_complete()));
1351 assert_eq!(
1352 cx.read(|cx| state.read(cx).item_state("second").unwrap().status()),
1353 QuestionnaireItemStatus::Skipped
1354 );
1355 assert_eq!(
1356 cx.read(|cx| harness.read(cx).events.clone()),
1357 vec!["answer", "current", "answer", "completed", "submit"]
1358 );
1359 }
1360
1361 #[gpui::test]
1362 fn keeps_input_draft_separate_and_synchronizes_silent_setters_and_reset(
1363 cx: &mut TestAppContext,
1364 ) {
1365 let (harness, state, first_input, second_input, cx) = harness(cx);
1366 let initial_events = cx.read(|cx| harness.read(cx).events.len());
1367
1368 cx.update(|window, cx| {
1369 state.update(cx, |state, cx| {
1370 state
1371 .set_answer(
1372 "first",
1373 QuestionnaireAnswer::new().with_freeform(" custom "),
1374 window,
1375 cx,
1376 )
1377 .unwrap();
1378 });
1379 });
1380 assert_eq!(
1381 cx.read(|cx| harness.read(cx).events.len()),
1382 initial_events,
1383 "programmatic setters are silent"
1384 );
1385 assert_eq!(cx.read(|cx| first_input.read(cx).value()), " custom ");
1386 assert_eq!(
1387 cx.read(|cx| {
1388 state
1389 .read(cx)
1390 .answer("first")
1391 .unwrap()
1392 .freeform()
1393 .unwrap()
1394 .clone()
1395 }),
1396 " custom "
1397 );
1398
1399 cx.update(|window, cx| {
1400 state.update(cx, |state, cx| {
1401 state
1402 .set_answer(
1403 "first",
1404 QuestionnaireAnswer::new().with_choices(["a"]),
1405 window,
1406 cx,
1407 )
1408 .unwrap();
1409 });
1410 });
1411 assert_eq!(cx.read(|cx| first_input.read(cx).value()), " custom ");
1412 assert!(cx.read(|cx| { state.read(cx).answer("first").unwrap().freeform().is_none() }));
1413 assert_eq!(
1414 cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1415 "a"
1416 );
1417
1418 cx.update(|window, cx| {
1419 first_input.update(cx, |input, cx| input.replace_all("", window, cx));
1420 });
1421 cx.run_until_parked();
1422 assert_eq!(
1423 cx.read(|cx| harness.read(cx).events.len()),
1424 initial_events,
1425 "editing an unselected draft does not change the semantic answer"
1426 );
1427 assert_eq!(
1428 cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1429 "a"
1430 );
1431
1432 state.update(cx, |state, cx| {
1433 state.activate_choice("first", "b", cx).unwrap()
1434 });
1435
1436 cx.update(|window, cx| {
1437 state.update(cx, |state, cx| state.reset(window, cx));
1438 });
1439 assert_eq!(cx.read(|cx| first_input.read(cx).value()), "");
1440 assert_eq!(cx.read(|cx| second_input.read(cx).value()), "initial draft");
1441 assert!(cx.read(|cx| state.read(cx).answer("first").unwrap().is_empty()));
1442 assert_eq!(
1443 cx.read(|cx| {
1444 state
1445 .read(cx)
1446 .answer("second")
1447 .unwrap()
1448 .freeform()
1449 .unwrap()
1450 .clone()
1451 }),
1452 "initial draft"
1453 );
1454 assert_eq!(
1455 cx.read(|cx| harness.read(cx).events.len()),
1456 initial_events + 1
1457 );
1458 }
1459
1460 #[gpui::test]
1461 fn validates_all_items_and_returns_to_the_first_invalid_item(cx: &mut TestAppContext) {
1462 let (_, state, _, _, cx) = harness(cx);
1463 state.update(cx, |state, cx| {
1464 state.activate_choice("first", "a", cx).unwrap()
1465 });
1466
1467 assert!(
1468 !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })
1469 );
1470 assert_eq!(
1471 cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1472 "second"
1473 );
1474 assert_eq!(
1475 cx.read(|cx| state.read(cx).error("second").unwrap().clone()),
1476 QuestionnaireValidationError::Message("Use the valid answer".into())
1477 );
1478
1479 state.update(cx, |state, cx| {
1480 state
1481 .set_external_error("first", "Server rejected it", cx)
1482 .unwrap()
1483 });
1484 assert!(
1485 !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })
1486 );
1487 assert_eq!(
1488 cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1489 "first"
1490 );
1491
1492 cx.update(|window, cx| {
1493 state.update(cx, |state, cx| {
1494 state.clear_external_error("first", cx).unwrap();
1495 state
1496 .set_input_value("second", "valid", window, cx)
1497 .unwrap();
1498 });
1499 });
1500 assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }));
1501 }
1502
1503 #[gpui::test]
1504 fn preserves_schema_order_and_temporarily_excludes_disabled_answers(cx: &mut TestAppContext) {
1505 let (_, state, _, _, cx) = harness(cx);
1506
1507 cx.update(|window, cx| {
1508 state.update(cx, |state, cx| {
1509 state
1510 .set_answer(
1511 "second",
1512 QuestionnaireAnswer::new()
1513 .with_choices(["y", "x", "y"])
1514 .with_freeform("valid"),
1515 window,
1516 cx,
1517 )
1518 .unwrap();
1519 });
1520 });
1521 assert_eq!(
1522 cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1523 vec![SharedString::from("x"), SharedString::from("y")]
1524 );
1525 cx.update(|window, cx| {
1526 state.update(cx, |state, cx| {
1527 state.set_current_item("second", window, cx).unwrap();
1528 state.focus_current_item(window, cx);
1529 assert!(state.focus_next_answer(window, cx));
1530 });
1531 });
1532 assert_eq!(
1533 cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()),
1534 Some("x".into()),
1535 "filled multiple choices are focused in schema order"
1536 );
1537 assert_eq!(
1538 cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1539 vec![SharedString::from("x"), SharedString::from("y")],
1540 "focusing a filled choice does not toggle it"
1541 );
1542
1543 state.update(cx, |state, cx| {
1544 state.set_choice_disabled("second", "x", true, cx).unwrap()
1545 });
1546 assert_eq!(
1547 cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1548 vec![SharedString::from("y")]
1549 );
1550 state.update(cx, |state, cx| {
1551 state.set_choice_disabled("second", "x", false, cx).unwrap()
1552 });
1553 assert_eq!(
1554 cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1555 vec![SharedString::from("x"), SharedString::from("y")]
1556 );
1557
1558 let error = cx.update(|window, cx| {
1559 state.update(cx, |state, cx| {
1560 state.set_answer(
1561 "second",
1562 QuestionnaireAnswer::new().with_choices(["unknown"]),
1563 window,
1564 cx,
1565 )
1566 })
1567 });
1568 assert_eq!(
1569 error,
1570 Err(QuestionnaireSchemaError::UnknownChoice {
1571 item: "second".into(),
1572 choice: "unknown".into(),
1573 })
1574 );
1575 }
1576
1577 #[gpui::test]
1578 fn shortcuts_disabled_current_fallback_and_recompletion_are_deterministic(
1579 cx: &mut TestAppContext,
1580 ) {
1581 let (harness, state, first_input, _, cx) = harness(cx);
1582 cx.update(|window, cx| {
1583 state.update(cx, |state, cx| {
1584 state
1585 .set_answer(
1586 "first",
1587 QuestionnaireAnswer::new().with_choices(["b"]),
1588 window,
1589 cx,
1590 )
1591 .unwrap();
1592 state.focus_current_item(window, cx);
1593 assert!(state.focus_next_answer(window, cx));
1594 });
1595 });
1596 assert_eq!(
1597 cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()),
1598 Some("b".into()),
1599 "the first move from the item group focuses the existing answer"
1600 );
1601 assert_eq!(
1602 cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1603 "b",
1604 "focusing the filled radio does not replace the answer"
1605 );
1606 cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1607
1608 cx.update(|window, cx| {
1609 state.update(cx, |state, cx| {
1610 state.focus_input("first", window, cx);
1611 assert!(!state.current_input_has_text(cx));
1612 assert!(state.focus_next_answer(window, cx));
1613 });
1614 });
1615 assert_eq!(
1616 cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1617 "a",
1618 "an empty focused input may move to and activate a radio"
1619 );
1620 cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1621
1622 cx.update(|window, cx| {
1623 state.update(cx, |state, cx| {
1624 state.set_input_value("first", "draft", window, cx).unwrap();
1625 state.focus_input("first", window, cx);
1626 assert!(state.current_input_has_text(cx));
1627 assert!(!state.focus_next_answer(window, cx));
1628 });
1629 });
1630 assert!(cx.update(|window, cx| first_input.focus_handle(cx).is_focused(window)));
1631 cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1632
1633 assert!(cx.update(|window, cx| {
1634 state.update(cx, |state, cx| {
1635 state.focus_current_item(window, cx);
1636 state.focus_next_answer(window, cx)
1637 })
1638 }));
1639 assert_eq!(
1640 cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1641 "a",
1642 "moving from the item group to a radio activates it"
1643 );
1644 cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1645
1646 assert_eq!(
1647 cx.read(|cx| state.read(cx).shortcut_for_choice("first", "a")),
1648 Some("A".into())
1649 );
1650 state.update(cx, |state, cx| {
1651 state.set_choice_disabled("first", "a", true, cx).unwrap()
1652 });
1653 assert_eq!(
1654 cx.read(|cx| state.read(cx).shortcut_for_choice("first", "b")),
1655 Some("A".into())
1656 );
1657 state.update(cx, |state, cx| {
1658 state.set_choice_disabled("first", "a", false, cx).unwrap()
1659 });
1660 cx.update(|window, cx| {
1661 state.update(cx, |state, cx| {
1662 assert!(state.activate_shortcut("a", window, cx));
1663 state.focus_choice("first", "a", window, cx);
1664 assert!(state.move_current_radio(1, window, cx));
1665 assert!(state.go_next(window, cx));
1666 assert!(state.skip_current(window, cx));
1667 });
1668 });
1669 assert!(cx.read(|cx| state.read(cx).is_complete()));
1670
1671 cx.update(|window, cx| {
1672 state.update(cx, |state, cx| {
1673 state
1674 .set_input_value("second", "valid", window, cx)
1675 .unwrap();
1676 state.activate_choice("second", "x", cx).unwrap();
1677 });
1678 });
1679 assert!(!cx.read(|cx| state.read(cx).is_complete()));
1680 assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }));
1681 let events = cx.read(|cx| harness.read(cx).events.clone());
1682 assert_eq!(
1683 events.iter().filter(|event| **event == "completed").count(),
1684 2
1685 );
1686
1687 let before_disable = events.len();
1688 cx.update(|window, cx| {
1689 state.update(cx, |state, cx| {
1690 state.set_item_disabled("second", true, window, cx).unwrap();
1691 });
1692 });
1693 assert_eq!(
1694 cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1695 "first"
1696 );
1697 assert_eq!(
1698 cx.read(|cx| harness.read(cx).events.len()),
1699 before_disable,
1700 "programmatic disable and fallback are silent"
1701 );
1702 }
1703}