1mod builders;
5mod choice;
6pub(crate) mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{
13 ChoiceField, CustomField, FileField, IntoFormValue, IntoOptions, RepeaterField, TextField,
14};
15pub(crate) use choice::{ChoiceControl, Relationship, option_view};
16pub use custom::Toggle;
17pub(crate) use custom::{Control, ControlInput};
18pub(crate) use file::stored_upload;
19use tablo_ui::{
20 field as ui_field, field_content as ui_field_content,
21 field_description as ui_field_description, field_error as ui_field_error,
22 field_label as ui_field_label, field_title as ui_field_title,
23};
24pub(crate) use text::TextControl;
25use toasty::stmt::{List, Path};
26use topcoat::{Result, context::Cx, view::*};
27
28use super::{
29 condition::Condition,
30 lenses::{Binding, FieldResolver},
31 repeater::{RepeaterControl, RepeaterItem},
32 validation::is_email,
33};
34use crate::{
35 form::{FieldError, FieldErrorKind, FormScalar, FormValue},
36 naming::capitalize,
37};
38
39pub struct Field {
89 binding: Binding,
90 label: Option<String>,
92 required: bool,
95 control: ControlKind,
96 condition: Option<Condition>,
98 checkbox: bool,
100 help: Option<String>,
102 disabled: Disabled,
104 default: Option<FormValue>,
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
111pub(crate) enum Disabled {
112 #[default]
113 Never,
114 Always,
115 OnEdit,
117}
118
119pub(crate) enum ControlKind {
121 Text(TextControl),
123 Choice(ChoiceControl),
125 File,
127 Custom(Arc<dyn Control>),
129 Repeater(RepeaterControl),
131}
132
133impl std::fmt::Debug for Field {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 let control = match &self.control {
136 ControlKind::Text(_) => "text",
137 ControlKind::Choice(_) => "choice",
138 ControlKind::File => "file",
139 ControlKind::Custom(_) => "custom",
140 ControlKind::Repeater(_) => "repeater",
141 };
142 f.debug_struct("Field")
143 .field("name", &self.name())
144 .field("label", &self.label_str())
145 .field("required", &self.required)
146 .field("control", &control)
147 .field("condition", &self.condition)
148 .field("help", &self.help)
149 .field("disabled", &self.disabled)
150 .field("default", &self.default)
151 .finish()
152 }
153}
154
155impl Field {
156 fn bound(binding: Binding, control: ControlKind) -> Self {
157 Self {
158 binding,
159 label: None,
160 required: false,
161 control,
162 condition: None,
163 checkbox: false,
164 help: None,
165 disabled: Disabled::Never,
166 default: None,
167 }
168 }
169
170 pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
173 where
174 M: toasty::schema::Model,
175 T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
176 {
177 let path: Path<M, T> = lens.into();
178 let binding = Binding::of(&path);
179 let control = TextControl::new::<M, T>(path, binding.unique());
180 TextField::new(Self::bound(binding, ControlKind::Text(control)))
181 }
182
183 #[doc(hidden)]
185 pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
186 where
187 M: toasty::schema::Model,
188 T: FormScalar,
189 {
190 TextField::new(Self::bound(
191 Binding::of(&path),
192 ControlKind::Text(TextControl::leaf::<T>()),
193 ))
194 }
195
196 pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
199 where
200 M: toasty::schema::Model,
201 {
202 ChoiceField::new(Self::bound(
203 Binding::of::<M, T>(&lens.into()),
204 ControlKind::Choice(ChoiceControl::default()),
205 ))
206 }
207
208 pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
211 where
212 M: toasty::schema::Model,
213 {
214 FileField::new(Self::bound(
215 Binding::of::<M, String>(&lens.into()),
216 ControlKind::File,
217 ))
218 }
219
220 pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
222 where
223 M: toasty::schema::Model,
224 {
225 let mut field = Self::custom(lens, Toggle);
226 field.0.checkbox = true;
227 field
228 }
229
230 pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
232 where
233 M: toasty::schema::Model,
234 T: FormScalar,
235 {
236 CustomField::new(Self::bound(
237 Binding::of::<M, T>(&lens.into()),
238 ControlKind::Custom(Arc::new(control)),
239 ))
240 }
241
242 pub fn repeater<M, T>(lens: impl Into<Path<M, List<T>>>) -> RepeaterField
250 where
251 M: toasty::schema::Model,
252 T: RepeaterItem,
253 {
254 RepeaterField::new(Self::bound(
255 Binding::of::<M, List<T>>(&lens.into()),
256 ControlKind::Repeater(RepeaterControl::new::<T>()),
257 ))
258 }
259
260 pub fn text_input<T: FormScalar>(name: impl Into<String>) -> TextField {
263 TextField::new(Self::bound(
264 Self::named(name.into()),
265 ControlKind::Text(TextControl::leaf::<T>()),
266 ))
267 }
268
269 pub fn choice_input(name: impl Into<String>) -> ChoiceField {
272 ChoiceField::new(Self::bound(
273 Self::named(name.into()),
274 ControlKind::Choice(ChoiceControl::default()),
275 ))
276 }
277
278 pub fn toggle_input(name: impl Into<String>) -> CustomField {
280 let mut field = Self::bound(
281 Self::named(name.into()),
282 ControlKind::Custom(Arc::new(Toggle)),
283 );
284 field.checkbox = true;
285 CustomField::new(field)
286 }
287
288 fn named(name: String) -> Binding {
290 let label = capitalize(&name);
291 Binding::named(name, label)
292 }
293
294 pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
297 Self {
298 binding: Binding::named(name.clone(), capitalize(&name)),
299 label: None,
300 required: false,
301 control: ControlKind::Choice(ChoiceControl {
302 discriminant: true,
303 options: variants,
304 ..ChoiceControl::default()
305 }),
306 condition: None,
307 checkbox: false,
308 help: None,
309 disabled: Disabled::Never,
310 default: None,
311 }
312 }
313
314 pub(crate) fn bind(&self, resolver: &FieldResolver) {
316 self.binding.bind(resolver);
317 }
318
319 pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
320 self.binding.misdeclared()
321 }
322
323 pub fn name(&self) -> &str {
325 self.binding.name()
326 }
327
328 pub(crate) fn label_str(&self) -> &str {
329 self.label.as_deref().unwrap_or(self.binding.label())
330 }
331
332 pub(crate) fn offers_nothing(&self) -> bool {
334 self.as_choice().is_some_and(ChoiceControl::offers_nothing)
335 }
336
337 pub(crate) fn as_repeater(&self) -> Option<&RepeaterControl> {
338 match &self.control {
339 ControlKind::Repeater(repeater) => Some(repeater),
340 _ => None,
341 }
342 }
343
344 pub(crate) fn prefix(&mut self, prefix: &str) {
346 let label = self.label_str().to_string();
347 self.binding = Binding::named(format!("{prefix}{}", self.name()), label);
348 }
349
350 pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
351 match &self.control {
352 ControlKind::Choice(choice) => Some(choice),
353 _ => None,
354 }
355 }
356
357 pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
359 match &self.control {
360 ControlKind::Text(text) => text.eq_filter(value),
361 _ => None,
362 }
363 }
364
365 pub(crate) fn is_file(&self) -> bool {
366 matches!(self.control, ControlKind::File)
367 }
368
369 pub(crate) fn is_unique(&self) -> bool {
370 matches!(&self.control, ControlKind::Text(text) if text.unique)
371 }
372
373 pub(crate) fn condition(&self) -> Option<&Condition> {
375 self.condition.as_ref()
376 }
377
378 pub(crate) fn set_condition(&mut self, condition: Condition) {
380 self.condition = Some(condition);
381 }
382
383 pub(crate) fn is_checkbox(&self) -> bool {
385 self.checkbox
386 }
387
388 pub(crate) fn can_post(&self, value: &str) -> Option<bool> {
392 if self.checkbox {
393 return Some(value == "true" || value == "false");
394 }
395 match &self.control {
396 ControlKind::Choice(choice) if choice.is_multiple() => Some(false),
398 ControlKind::Choice(choice) if !choice.is_relationship() => {
399 Some(value.is_empty() || choice.label_of(value).is_some())
400 }
401 _ => None,
402 }
403 }
404
405 pub(crate) fn refuses(&self, value: &FormValue) -> bool {
409 match (&self.control, value) {
410 (ControlKind::Choice(choice), FormValue::List(items)) if choice.is_multiple() => {
411 !choice.is_relationship()
412 && items.iter().any(|item| choice.label_of(item).is_none())
413 }
414 (ControlKind::Choice(choice), _) if choice.is_multiple() => true,
415 (ControlKind::Text(text), FormValue::Text(value)) => {
416 !value.trim().is_empty() && !text.parses(value)
417 }
418 (_, FormValue::Text(value)) => self.can_post(value) == Some(false),
419 _ => true,
420 }
421 }
422
423 pub(crate) fn is_required(&self) -> bool {
425 self.required
426 }
427
428 pub(crate) fn is_disabled(&self, editing: bool) -> bool {
430 match self.disabled {
431 Disabled::Never => false,
432 Disabled::Always => true,
433 Disabled::OnEdit => editing,
434 }
435 }
436
437 pub(crate) fn is_always_disabled(&self) -> bool {
439 self.disabled == Disabled::Always
440 }
441
442 pub(crate) fn default_value(&self) -> Option<&FormValue> {
444 self.default.as_ref()
445 }
446
447 fn chrome(
449 &self,
450 id: String,
451 error: Option<&FieldError>,
452 fallback: Option<String>,
453 ) -> FieldChrome {
454 let mut chrome = FieldChrome::new(id, error, self.label_str(), fallback);
455 chrome.help = self.help.clone();
456 chrome
457 }
458
459 pub(crate) fn set_required(&mut self, required: bool) {
461 self.required = required;
462 }
463
464 pub(crate) fn is_nullable(&self) -> bool {
466 self.binding.nullable()
467 }
468
469 pub(crate) fn check(&self, value: &FormValue) -> Option<FieldError> {
471 let value = value.as_text()?.trim();
472 match &self.control {
473 ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
474 Some(FieldError::invalid(
475 self.name(),
476 format!("{} must be a valid email", self.label_str()),
477 ))
478 }
479 _ => None,
480 }
481 }
482
483 pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
485 match &self.control {
486 ControlKind::Text(text) => text.same_value(a, b),
487 _ => a.trim() == b.trim(),
488 }
489 }
490
491 pub(crate) fn parent_key(&self) -> Option<&str> {
493 self.as_choice().and_then(ChoiceControl::parent_key)
494 }
495
496 pub(crate) async fn validate_exists(
499 &self,
500 cx: &Cx,
501 value: &FormValue,
502 parent: Option<&str>,
503 stored: Option<&FormValue>,
504 ) -> Vec<String> {
505 match &self.control {
506 ControlKind::Choice(choice) => {
507 choice
508 .validate_exists(cx, self.label_str(), value, parent, stored)
509 .await
510 }
511 _ => Vec::new(),
512 }
513 }
514
515 pub(crate) async fn recheck(
518 &self,
519 cx: &Cx,
520 value: &FormValue,
521 parent: Option<&str>,
522 stored: Option<&FormValue>,
523 ex: &mut dyn toasty::Executor,
524 ) -> Vec<String> {
525 match &self.control {
526 ControlKind::Choice(choice) => {
527 choice
528 .recheck(cx, self.label_str(), value, parent, stored, ex)
529 .await
530 }
531 _ => Vec::new(),
532 }
533 }
534
535 pub(crate) async fn keep_unseen(
538 &self,
539 cx: &Cx,
540 value: &FormValue,
541 stored: &FormValue,
542 ex: &mut dyn toasty::Executor,
543 ) -> Option<FormValue> {
544 match &self.control {
545 ControlKind::Choice(choice) => choice.keep_unseen(cx, value, stored, ex).await,
546 _ => None,
547 }
548 }
549
550 #[cfg(test)]
553 pub(crate) async fn render<'a>(
554 &self,
555 cx: &'a Cx,
556 value: Option<&str>,
557 error: Option<&FieldError>,
558 ) -> Result<BoxView<'a>> {
559 let value = value.map(FormValue::from);
560 self.render_under(cx, value.as_ref(), error, Placement::default())
561 .await
562 }
563
564 pub(crate) async fn render_under<'a>(
566 &self,
567 cx: &'a Cx,
568 value: Option<&FormValue>,
569 error: Option<&FieldError>,
570 placement: Placement<'_>,
571 ) -> Result<BoxView<'a>> {
572 let id = placement.id(self.name());
573 let disabled = self.is_disabled(placement.editing);
574 let text = value.and_then(FormValue::as_text);
575 match &self.control {
576 ControlKind::Text(control) => self.render_text(control, cx, text, error, id, disabled),
577 ControlKind::Choice(choice) => {
578 Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
579 }
580 ControlKind::File => self.render_file(cx, text, error, id, disabled),
581 ControlKind::Custom(control) => {
582 self.render_custom(control.as_ref(), cx, text, error, id, disabled)
583 }
584 ControlKind::Repeater(_) => Ok(().boxed()),
586 }
587 }
588
589 pub(crate) fn read(&self, value: &str) -> Option<String> {
592 match &self.control {
593 ControlKind::Choice(choice) => {
594 let stored = value.trim();
595 match choice.label_of(stored) {
596 Some(label) => Some(label.to_string()),
597 None if choice.discriminant => None,
598 None => Some(stored.to_string()),
599 }
600 }
601 _ => Some(value.to_string()),
602 }
603 }
604
605 pub(crate) fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
608 match self.read(value) {
609 Some(text) => read_only(cx, self.label_str(), value_cell(cx, &text)),
610 None => ().boxed(),
611 }
612 }
613
614 fn render_custom<'a>(
616 &self,
617 control: &dyn Control,
618 cx: &'a Cx,
619 value: Option<&str>,
620 error: Option<&FieldError>,
621 id: String,
622 disabled: bool,
623 ) -> Result<BoxView<'a>> {
624 let required = self.is_required() && !disabled;
625 let chrome = self.chrome(id, error, None);
626 let input = ControlInput::new(
627 self.name(),
628 &chrome.id,
629 value,
630 required,
631 chrome.aria_invalid() == "true",
632 chrome.described_by(),
633 )
634 .disabled(disabled);
635 let rendered = control.render(cx, input);
636 render_field(
637 cx,
638 &chrome,
639 self.label_str(),
640 required,
641 attributes! {},
642 rendered,
643 )
644 }
645}
646
647pub(crate) fn read_only<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> BoxView<'a> {
650 let label = label.to_string();
651 view! {
652 cx =>
653 ui_field(
654 attrs: attributes! { class="ac-field" },
655 ui_field_content(
656 ui_field_title((label))
657 <div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
658 (value)
659 </div>
660 )
661 )
662 }
663 .boxed()
664}
665
666pub(crate) fn value_cell<'a>(cx: &'a Cx, text: &str) -> BoxView<'a> {
668 if text.trim().is_empty() {
669 return view! { cx => <span class="text-muted-foreground">"—"</span> }.boxed();
670 }
671 let text = text.to_string();
672 view! { cx => (text) }.boxed()
673}
674
675#[derive(Debug, Clone, Copy, Default)]
677pub(crate) struct Placement<'p> {
678 pub(crate) parent: Option<&'p str>,
680 pub(crate) scope: &'p str,
683 pub(crate) options: Option<&'p str>,
686 pub(crate) editing: bool,
688}
689
690impl Placement<'_> {
691 pub(crate) fn id(&self, name: &str) -> String {
693 if self.scope.is_empty() {
694 name.to_string()
695 } else {
696 format!("{}-{name}", self.scope)
697 }
698 }
699}
700
701pub(crate) struct FieldChrome {
703 pub(crate) id: String,
705 error_id: String,
706 error_text: String,
707 error_kind: Option<&'static str>,
709 help: Option<String>,
711}
712
713pub(crate) fn error_kind(error: &FieldError) -> &'static str {
716 match error.kind {
717 FieldErrorKind::Required => "required",
718 FieldErrorKind::Invalid(_) => "invalid",
719 }
720}
721
722pub(crate) const UNAVAILABLE: &str = "unavailable";
724
725impl FieldChrome {
726 pub(crate) fn new(
730 id: String,
731 error: Option<&FieldError>,
732 label: &str,
733 fallback: Option<String>,
734 ) -> Self {
735 let message = error.map(|error| error.message(label));
736 let (error_text, error_kind) = match (message, fallback) {
737 (Some(message), Some(_)) if !message.is_empty() => (message, Some(UNAVAILABLE)),
738 (Some(message), None) => (message, error.map(error_kind)),
739 (_, Some(fallback)) => (fallback, Some(UNAVAILABLE)),
740 (None, None) => (String::new(), None),
741 };
742 Self {
743 error_id: format!("{id}-error"),
744 id,
745 error_text,
746 error_kind,
747 help: None,
748 }
749 }
750
751 fn has_error(&self) -> bool {
752 self.error_kind.is_some()
753 }
754
755 fn help_id(&self) -> String {
756 format!("{}-description", self.id)
757 }
758
759 pub(crate) fn aria_invalid(&self) -> &'static str {
760 if self.has_error() { "true" } else { "false" }
761 }
762
763 pub(crate) fn described_by(&self) -> Option<String> {
765 let ids: Vec<String> = [
766 self.help.is_some().then(|| self.help_id()),
767 self.has_error().then(|| self.error_id.clone()),
768 ]
769 .into_iter()
770 .flatten()
771 .collect();
772 (!ids.is_empty()).then(|| ids.join(" "))
773 }
774}
775
776pub(crate) fn render_field<'a>(
778 cx: &'a Cx,
779 chrome: &FieldChrome,
780 label: &str,
781 required: bool,
782 attributes: Attributes,
783 control: BoxView<'a>,
784) -> Result<BoxView<'a>> {
785 let id = chrome.id.clone();
786 let label_text = label.to_string();
787 let has_error = chrome.has_error();
788 let error_kind = chrome.error_kind;
789 let error_id = chrome.error_id.clone();
790 let error_text = chrome.error_text.clone();
791 let help = chrome.help.clone();
792 let help_id = chrome.help_id();
793 let field_class = if has_error {
794 "ac-field ac-field--error"
795 } else {
796 "ac-field"
797 };
798 Ok(view! {
799 cx =>
800 ui_field(
801 attrs: attributes! {
802 class=(field_class)
803 data-invalid=(has_error.then_some("true"))
804 (attributes)
805 },
806 ui_field_label(
807 attrs: attributes! { for=(id) },
808 (label_text)
809 if required {
810 <span class="text-destructive" aria-hidden="true">"*"</span>
811 }
812 )
813 (control)
814 if let Some(help) = help {
815 ui_field_description(attrs: attributes! { id=(help_id) }, (help))
816 }
817 if has_error {
818 ui_field_error(
819 attrs: attributes! {
820 id=(error_id)
821 class="ac-error"
822 aria-live="polite"
823 data-error=(error_kind)
824 },
825 (error_text)
826 )
827 }
828 )
829 }
830 .boxed())
831}
832
833#[cfg(test)]
834mod test_support {
835 pub(super) use crate::test_support::{DummyUser, cx};
836
837 pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
839 let at = html
840 .find(needle)
841 .unwrap_or_else(|| panic!("no {needle} in {html}"));
842 opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
843 }
844
845 pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
847 let mut quoted = false;
848 for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
849 match byte {
850 b'"' => quoted = !quoted,
851 b'>' if !quoted => return &html[start..start + offset],
852 _ => {}
853 }
854 }
855 panic!("unterminated tag at byte {start} in {html}");
856 }
857}
858
859#[cfg(test)]
860mod tests;