1mod builders;
5mod choice;
6pub(crate) mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{ChoiceField, CustomField, FileField, IntoOptions, RepeaterField, TextField};
13pub(crate) use choice::{ChoiceControl, option_view};
14pub use custom::Toggle;
15pub(crate) use custom::{Control, ControlInput};
16pub(crate) use file::stored_upload;
17use tablo_ui::{
18 field as ui_field, field_content as ui_field_content, field_error as ui_field_error,
19 field_label as ui_field_label, field_title as ui_field_title,
20};
21pub(crate) use text::TextControl;
22use toasty::stmt::{List, Path};
23use topcoat::{Result, context::Cx, view::*};
24
25use super::{
26 condition::Condition,
27 lenses::{Binding, FieldResolver},
28 repeater::{RepeaterControl, RepeaterItem},
29 validation::is_email,
30};
31use crate::{
32 form::{FieldError, FormScalar},
33 naming::capitalize,
34};
35
36pub struct Field {
92 binding: Binding,
93 label: Option<String>,
95 required: bool,
98 control: ControlKind,
99 condition: Option<Condition>,
101 checkbox: bool,
103}
104
105pub(crate) enum ControlKind {
107 Text(TextControl),
109 Choice(ChoiceControl),
111 File,
113 Custom(Arc<dyn Control>),
115 Repeater(RepeaterControl),
117}
118
119impl std::fmt::Debug for Field {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 let control = match &self.control {
122 ControlKind::Text(_) => "text",
123 ControlKind::Choice(_) => "choice",
124 ControlKind::File => "file",
125 ControlKind::Custom(_) => "custom",
126 ControlKind::Repeater(_) => "repeater",
127 };
128 f.debug_struct("Field")
129 .field("name", &self.name())
130 .field("label", &self.label_str())
131 .field("required", &self.required)
132 .field("control", &control)
133 .field("condition", &self.condition)
134 .finish()
135 }
136}
137
138impl Field {
139 fn bound(binding: Binding, control: ControlKind) -> Self {
140 Self {
141 binding,
142 label: None,
143 required: false,
144 control,
145 condition: None,
146 checkbox: false,
147 }
148 }
149
150 pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
153 where
154 M: toasty::schema::Model,
155 T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
156 {
157 let path: Path<M, T> = lens.into();
158 let binding = Binding::of(&path);
159 let control = TextControl::new::<M, T>(path, binding.unique());
160 TextField::new(Self::bound(binding, ControlKind::Text(control)))
161 }
162
163 #[doc(hidden)]
165 pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
166 where
167 M: toasty::schema::Model,
168 T: FormScalar,
169 {
170 TextField::new(Self::bound(
171 Binding::of(&path),
172 ControlKind::Text(TextControl::leaf::<T>()),
173 ))
174 }
175
176 pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
179 where
180 M: toasty::schema::Model,
181 {
182 ChoiceField::new(Self::bound(
183 Binding::of::<M, T>(&lens.into()),
184 ControlKind::Choice(ChoiceControl::default()),
185 ))
186 }
187
188 pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
191 where
192 M: toasty::schema::Model,
193 {
194 FileField::new(Self::bound(
195 Binding::of::<M, String>(&lens.into()),
196 ControlKind::File,
197 ))
198 }
199
200 pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
202 where
203 M: toasty::schema::Model,
204 {
205 let mut field = Self::custom(lens, Toggle);
206 field.0.checkbox = true;
207 field
208 }
209
210 pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
212 where
213 M: toasty::schema::Model,
214 T: FormScalar,
215 {
216 CustomField::new(Self::bound(
217 Binding::of::<M, T>(&lens.into()),
218 ControlKind::Custom(Arc::new(control)),
219 ))
220 }
221
222 pub fn repeater<M, T>(lens: impl Into<Path<M, List<T>>>) -> RepeaterField
230 where
231 M: toasty::schema::Model,
232 T: RepeaterItem,
233 {
234 RepeaterField::new(Self::bound(
235 Binding::of::<M, List<T>>(&lens.into()),
236 ControlKind::Repeater(RepeaterControl::new::<T>()),
237 ))
238 }
239
240 pub fn text_input<T: FormScalar>(name: impl Into<String>) -> TextField {
243 TextField::new(Self::bound(
244 Self::named(name.into()),
245 ControlKind::Text(TextControl::leaf::<T>()),
246 ))
247 }
248
249 pub fn choice_input(name: impl Into<String>) -> ChoiceField {
252 ChoiceField::new(Self::bound(
253 Self::named(name.into()),
254 ControlKind::Choice(ChoiceControl::default()),
255 ))
256 }
257
258 pub fn toggle_input(name: impl Into<String>) -> CustomField {
260 let mut field = Self::bound(
261 Self::named(name.into()),
262 ControlKind::Custom(Arc::new(Toggle)),
263 );
264 field.checkbox = true;
265 CustomField::new(field)
266 }
267
268 fn named(name: String) -> Binding {
270 let label = capitalize(&name);
271 Binding::named(name, label)
272 }
273
274 pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
277 Self {
278 binding: Binding::named(name.clone(), capitalize(&name)),
279 label: None,
280 required: false,
281 control: ControlKind::Choice(ChoiceControl {
282 discriminant: true,
283 options: variants,
284 ..ChoiceControl::default()
285 }),
286 condition: None,
287 checkbox: false,
288 }
289 }
290
291 pub(crate) fn bind(&self, resolver: &FieldResolver) {
293 self.binding.bind(resolver);
294 }
295
296 pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
297 self.binding.misdeclared()
298 }
299
300 pub fn name(&self) -> &str {
302 self.binding.name()
303 }
304
305 pub(crate) fn label_str(&self) -> &str {
306 self.label.as_deref().unwrap_or(self.binding.label())
307 }
308
309 pub(crate) fn offers_nothing(&self) -> bool {
311 self.as_choice().is_some_and(ChoiceControl::offers_nothing)
312 }
313
314 pub(crate) fn as_repeater(&self) -> Option<&RepeaterControl> {
315 match &self.control {
316 ControlKind::Repeater(repeater) => Some(repeater),
317 _ => None,
318 }
319 }
320
321 pub(crate) fn prefix(&mut self, prefix: &str) {
323 let label = self.label_str().to_string();
324 self.binding = Binding::named(format!("{prefix}{}", self.name()), label);
325 }
326
327 pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
328 match &self.control {
329 ControlKind::Choice(choice) => Some(choice),
330 _ => None,
331 }
332 }
333
334 pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
336 match &self.control {
337 ControlKind::Text(text) => text.eq_filter(value),
338 _ => None,
339 }
340 }
341
342 pub(crate) fn is_file(&self) -> bool {
343 matches!(self.control, ControlKind::File)
344 }
345
346 pub(crate) fn is_unique(&self) -> bool {
347 matches!(&self.control, ControlKind::Text(text) if text.unique)
348 }
349
350 pub(crate) fn condition(&self) -> Option<&Condition> {
352 self.condition.as_ref()
353 }
354
355 pub(crate) fn is_checkbox(&self) -> bool {
357 self.checkbox
358 }
359
360 pub(crate) fn can_post(&self, value: &str) -> Option<bool> {
364 if self.checkbox {
365 return Some(value == "true" || value == "false");
366 }
367 match &self.control {
368 ControlKind::Choice(choice) if choice.is_multiple() => Some(false),
370 ControlKind::Choice(choice) if !choice.is_relationship() => {
371 Some(value.is_empty() || choice.label_of(value).is_some())
372 }
373 _ => None,
374 }
375 }
376
377 pub(crate) fn is_required(&self) -> bool {
379 self.required
380 }
381
382 pub(crate) fn set_required(&mut self, required: bool) {
384 self.required = required;
385 }
386
387 pub(crate) fn is_nullable(&self) -> bool {
389 self.binding.nullable()
390 }
391
392 pub(crate) fn check(&self, value: &str) -> Option<FieldError> {
394 let value = value.trim();
395 match &self.control {
396 ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
397 Some(FieldError::invalid(
398 self.name(),
399 format!("{} must be a valid email", self.label_str()),
400 ))
401 }
402 _ => None,
403 }
404 }
405
406 pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
408 match &self.control {
409 ControlKind::Text(text) => text.same_value(a, b),
410 _ => a.trim() == b.trim(),
411 }
412 }
413
414 pub(crate) fn parent_key(&self) -> Option<&str> {
416 self.as_choice().and_then(ChoiceControl::parent_key)
417 }
418
419 pub(crate) async fn validate_exists(
422 &self,
423 cx: &Cx,
424 value: &str,
425 parent: Option<&str>,
426 stored: Option<&str>,
427 ) -> Vec<String> {
428 match &self.control {
429 ControlKind::Choice(choice) => {
430 choice
431 .validate_exists(cx, self.label_str(), value, parent, stored)
432 .await
433 }
434 _ => Vec::new(),
435 }
436 }
437
438 pub(crate) async fn recheck(
441 &self,
442 cx: &Cx,
443 value: &str,
444 parent: Option<&str>,
445 stored: Option<&str>,
446 ex: &mut dyn toasty::Executor,
447 ) -> Vec<String> {
448 match &self.control {
449 ControlKind::Choice(choice) => {
450 choice
451 .recheck(cx, self.label_str(), value, parent, stored, ex)
452 .await
453 }
454 _ => Vec::new(),
455 }
456 }
457
458 pub(crate) async fn keep_unseen(
461 &self,
462 cx: &Cx,
463 value: &str,
464 stored: &str,
465 ex: &mut dyn toasty::Executor,
466 ) -> Option<String> {
467 match &self.control {
468 ControlKind::Choice(choice) => choice.keep_unseen(cx, value, stored, ex).await,
469 _ => None,
470 }
471 }
472
473 #[cfg(test)]
475 pub(crate) async fn render<'a>(
476 &self,
477 cx: &'a Cx,
478 value: Option<&str>,
479 error: Option<&str>,
480 ) -> Result<BoxView<'a>> {
481 self.render_under(cx, value, error, Placement::default())
482 .await
483 }
484
485 pub(crate) async fn render_under<'a>(
487 &self,
488 cx: &'a Cx,
489 value: Option<&str>,
490 error: Option<&str>,
491 placement: Placement<'_>,
492 ) -> Result<BoxView<'a>> {
493 let id = placement.id(self.name());
494 match &self.control {
495 ControlKind::Text(text) => self.render_text(text, cx, value, error, id),
496 ControlKind::Choice(choice) => {
497 Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
498 }
499 ControlKind::File => self.render_file(cx, value, error, id),
500 ControlKind::Custom(control) => {
501 self.render_custom(control.as_ref(), cx, value, error, id)
502 }
503 ControlKind::Repeater(_) => Ok(().boxed()),
505 }
506 }
507
508 pub(crate) fn read(&self, value: &str) -> Option<String> {
511 match &self.control {
512 ControlKind::Choice(choice) => {
513 let stored = value.trim();
514 match choice.label_of(stored) {
515 Some(label) => Some(label.to_string()),
516 None if choice.discriminant => None,
517 None => Some(stored.to_string()),
518 }
519 }
520 _ => Some(value.to_string()),
521 }
522 }
523
524 pub(crate) fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
527 match self.read(value) {
528 Some(text) => read_only(cx, self.label_str(), value_cell(cx, &text)),
529 None => ().boxed(),
530 }
531 }
532
533 fn render_custom<'a>(
535 &self,
536 control: &dyn Control,
537 cx: &'a Cx,
538 value: Option<&str>,
539 error: Option<&str>,
540 id: String,
541 ) -> Result<BoxView<'a>> {
542 let required = self.is_required();
543 let chrome = FieldChrome::new(id, error, None);
544 let input = ControlInput::new(
545 self.name(),
546 &chrome.id,
547 value,
548 required,
549 chrome.aria_invalid() == "true",
550 chrome.described_by(),
551 );
552 let rendered = control.render(cx, input);
553 render_field(
554 cx,
555 &chrome,
556 self.label_str(),
557 required,
558 attributes! {},
559 rendered,
560 )
561 }
562}
563
564pub(crate) fn read_only<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> BoxView<'a> {
567 let label = label.to_string();
568 view! {
569 cx =>
570 ui_field(
571 attrs: attributes! { class="ac-field" },
572 ui_field_content(
573 ui_field_title((label))
574 <div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
575 (value)
576 </div>
577 )
578 )
579 }
580 .boxed()
581}
582
583pub(crate) fn value_cell<'a>(cx: &'a Cx, text: &str) -> BoxView<'a> {
585 if text.trim().is_empty() {
586 return view! { cx => <span class="text-muted-foreground">"—"</span> }.boxed();
587 }
588 let text = text.to_string();
589 view! { cx => (text) }.boxed()
590}
591
592#[derive(Debug, Clone, Copy, Default)]
594pub(crate) struct Placement<'p> {
595 pub(crate) parent: Option<&'p str>,
597 pub(crate) scope: &'p str,
600 pub(crate) options: Option<&'p str>,
603}
604
605impl Placement<'_> {
606 pub(crate) fn id(&self, name: &str) -> String {
608 if self.scope.is_empty() {
609 name.to_string()
610 } else {
611 format!("{}-{name}", self.scope)
612 }
613 }
614}
615
616pub(crate) struct FieldChrome {
618 pub(crate) id: String,
620 error_id: String,
621 error_text: String,
622 has_error: bool,
623}
624
625impl FieldChrome {
626 pub(crate) fn new(id: String, error: Option<&str>, fallback: Option<String>) -> Self {
627 let has_error = error.is_some() || fallback.is_some();
628 let error_text = match error {
629 Some(message) if !message.is_empty() => message.to_string(),
630 _ => fallback.unwrap_or_default(),
631 };
632 Self {
633 error_id: format!("{id}-error"),
634 id,
635 error_text,
636 has_error,
637 }
638 }
639
640 pub(crate) fn aria_invalid(&self) -> &'static str {
641 if self.has_error { "true" } else { "false" }
642 }
643
644 pub(crate) fn described_by(&self) -> Option<String> {
645 self.has_error.then(|| self.error_id.clone())
646 }
647}
648
649pub(crate) fn render_field<'a>(
651 cx: &'a Cx,
652 chrome: &FieldChrome,
653 label: &str,
654 required: bool,
655 attributes: Attributes,
656 control: BoxView<'a>,
657) -> Result<BoxView<'a>> {
658 let id = chrome.id.clone();
659 let label_text = label.to_string();
660 let has_error = chrome.has_error;
661 let error_id = chrome.error_id.clone();
662 let error_text = chrome.error_text.clone();
663 let field_class = if has_error {
664 "ac-field ac-field--error"
665 } else {
666 "ac-field"
667 };
668 Ok(view! {
669 cx =>
670 ui_field(
671 attrs: attributes! {
672 class=(field_class)
673 data-invalid=(has_error.then_some("true"))
674 (attributes)
675 },
676 ui_field_label(
677 attrs: attributes! { for=(id) },
678 (label_text)
679 if required {
680 <span class="text-destructive" aria-hidden="true">"*"</span>
681 }
682 )
683 (control)
684 if has_error {
685 ui_field_error(
686 attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
687 (error_text)
688 )
689 }
690 )
691 }
692 .boxed())
693}
694
695#[cfg(test)]
696mod test_support {
697 pub(super) use crate::test_support::{DummyUser, cx};
698
699 pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
701 let at = html
702 .find(needle)
703 .unwrap_or_else(|| panic!("no {needle} in {html}"));
704 opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
705 }
706
707 pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
709 let mut quoted = false;
710 for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
711 match byte {
712 b'"' => quoted = !quoted,
713 b'>' if !quoted => return &html[start..start + offset],
714 _ => {}
715 }
716 }
717 panic!("unterminated tag at byte {start} in {html}");
718 }
719}
720
721#[cfg(test)]
722mod tests;