1mod builders;
5mod choice;
6mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{ChoiceField, CustomField, FileField, IntoOptions, TextField};
13pub(crate) use choice::{ChoiceControl, option_view};
14pub use custom::{Control, ControlInput, Toggle};
15use tablo_ui::{
16 field as ui_field, field_content as ui_field_content, field_error as ui_field_error,
17 field_label as ui_field_label, field_title as ui_field_title,
18};
19pub(crate) use text::TextControl;
20use toasty::stmt::Path;
21use topcoat::{Result, context::Cx, view::*};
22
23use super::{
24 lenses::{Binding, FieldResolver},
25 tree::Mode,
26 validation::is_email,
27};
28use crate::{
29 form::{FieldError, FormScalar},
30 naming::capitalize,
31};
32
33pub struct Field {
87 binding: Binding,
88 label: Option<String>,
90 required: bool,
93 control: ControlKind,
94}
95
96pub(crate) enum ControlKind {
98 Text(TextControl),
100 Choice(ChoiceControl),
102 File,
104 Custom(Arc<dyn Control>),
106}
107
108impl std::fmt::Debug for Field {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 let control = match &self.control {
111 ControlKind::Text(_) => "text",
112 ControlKind::Choice(_) => "choice",
113 ControlKind::File => "file",
114 ControlKind::Custom(_) => "custom",
115 };
116 f.debug_struct("Field")
117 .field("name", &self.name())
118 .field("label", &self.label_str())
119 .field("required", &self.required)
120 .field("control", &control)
121 .finish()
122 }
123}
124
125impl Field {
126 fn bound(binding: Binding, control: ControlKind) -> Self {
127 Self {
128 binding,
129 label: None,
130 required: false,
131 control,
132 }
133 }
134
135 pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
138 where
139 M: toasty::schema::Model,
140 T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
141 {
142 let path: Path<M, T> = lens.into();
143 let binding = Binding::of(&path);
144 let control = TextControl::new::<M, T>(path, binding.unique());
145 TextField(Self::bound(binding, ControlKind::Text(control)))
146 }
147
148 #[doc(hidden)]
150 pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
151 where
152 M: toasty::schema::Model,
153 T: FormScalar,
154 {
155 TextField(Self::bound(
156 Binding::of(&path),
157 ControlKind::Text(TextControl::leaf::<T>()),
158 ))
159 }
160
161 pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
164 where
165 M: toasty::schema::Model,
166 {
167 ChoiceField(Self::bound(
168 Binding::of::<M, T>(&lens.into()),
169 ControlKind::Choice(ChoiceControl::default()),
170 ))
171 }
172
173 pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
176 where
177 M: toasty::schema::Model,
178 {
179 FileField(Self::bound(
180 Binding::of::<M, String>(&lens.into()),
181 ControlKind::File,
182 ))
183 }
184
185 pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
187 where
188 M: toasty::schema::Model,
189 {
190 Self::custom(lens, Toggle)
191 }
192
193 pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
195 where
196 M: toasty::schema::Model,
197 T: FormScalar,
198 {
199 CustomField(Self::bound(
200 Binding::of::<M, T>(&lens.into()),
201 ControlKind::Custom(Arc::new(control)),
202 ))
203 }
204
205 pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
208 Self {
209 binding: Binding::named(name.clone(), capitalize(&name)),
210 label: None,
211 required: false,
212 control: ControlKind::Choice(ChoiceControl {
213 discriminant: true,
214 options: variants,
215 ..ChoiceControl::default()
216 }),
217 }
218 }
219
220 pub(crate) fn bind(&self, resolver: &FieldResolver) {
222 self.binding.bind(resolver);
223 }
224
225 pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
226 self.binding.misdeclared()
227 }
228
229 pub fn name(&self) -> &str {
231 self.binding.name()
232 }
233
234 pub(crate) fn label_str(&self) -> &str {
235 self.label.as_deref().unwrap_or(self.binding.label())
236 }
237
238 pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
239 match &self.control {
240 ControlKind::Choice(choice) => Some(choice),
241 _ => None,
242 }
243 }
244
245 pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
247 match &self.control {
248 ControlKind::Text(text) => text.eq_filter(value),
249 _ => None,
250 }
251 }
252
253 pub(crate) fn is_file(&self) -> bool {
254 matches!(self.control, ControlKind::File)
255 }
256
257 pub(crate) fn is_unique(&self) -> bool {
258 matches!(&self.control, ControlKind::Text(text) if text.unique)
259 }
260
261 pub(crate) fn is_required(&self) -> bool {
263 self.required
264 }
265
266 pub(crate) fn set_required(&mut self, required: bool) {
268 self.required = required;
269 }
270
271 pub(crate) fn is_nullable(&self) -> bool {
273 self.binding.nullable()
274 }
275
276 pub(crate) fn check(&self, value: &str) -> Option<FieldError> {
278 let value = value.trim();
279 match &self.control {
280 ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
281 Some(FieldError::invalid(
282 self.name(),
283 format!("{} must be a valid email", self.label_str()),
284 ))
285 }
286 _ => None,
287 }
288 }
289
290 pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
292 match &self.control {
293 ControlKind::Text(text) => text.same_value(a, b),
294 _ => a.trim() == b.trim(),
295 }
296 }
297
298 pub(crate) async fn validate_exists(&self, cx: &Cx, value: &str) -> Vec<String> {
300 match &self.control {
301 ControlKind::Choice(choice) => {
302 choice.validate_exists(cx, self.label_str(), value).await
303 }
304 _ => Vec::new(),
305 }
306 }
307
308 pub(crate) async fn recheck(
310 &self,
311 cx: &Cx,
312 value: &str,
313 ex: &mut dyn toasty::Executor,
314 ) -> Vec<String> {
315 match &self.control {
316 ControlKind::Choice(choice) => choice.recheck(cx, self.label_str(), value, ex).await,
317 _ => Vec::new(),
318 }
319 }
320
321 pub(crate) async fn render<'a>(
323 &self,
324 cx: &'a Cx,
325 value: Option<&str>,
326 error: Option<&str>,
327 mode: Mode,
328 ) -> Result<BoxView<'a>> {
329 if mode == Mode::View && value.is_none() {
330 debug_assert!(
331 false,
332 "view field `{}` has no value: neither `view_values` nor the record form's \
333 `hydrate` supplies its key",
334 self.name()
335 );
336 return render_value(cx, self.label_str(), Some("(missing)"), ValueKind::Prose);
337 }
338 match &self.control {
339 ControlKind::Text(text) => self.render_text(text, cx, value, error, mode),
340 ControlKind::Choice(choice) => {
341 Box::pin(self.render_choice(choice, cx, value, error, mode)).await
342 }
343 ControlKind::File => self.render_file(cx, value, error, mode),
344 ControlKind::Custom(control) => {
345 self.render_custom(control.as_ref(), cx, value, error, mode)
346 }
347 }
348 }
349
350 fn render_custom<'a>(
352 &self,
353 control: &dyn Control,
354 cx: &'a Cx,
355 value: Option<&str>,
356 error: Option<&str>,
357 mode: Mode,
358 ) -> Result<BoxView<'a>> {
359 if mode == Mode::View {
360 let shown = control.display(cx, value.unwrap_or_default());
361 return render_value_view(cx, self.label_str(), shown);
362 }
363 let required = self.is_required();
364 let chrome = FieldChrome::new(self.name(), error, None);
365 let input = ControlInput::new(
366 self.name(),
367 value,
368 required,
369 chrome.aria_invalid() == "true",
370 chrome.described_by(),
371 );
372 let rendered = control.render(cx, input);
373 render_field(
374 cx,
375 &chrome,
376 self.label_str(),
377 required,
378 attributes! {},
379 rendered,
380 )
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub(crate) enum ValueKind {
387 Prose,
389 Machine,
391}
392
393fn render_value<'a>(
395 cx: &'a Cx,
396 label: &str,
397 value: Option<&str>,
398 kind: ValueKind,
399) -> Result<BoxView<'a>> {
400 let text = value.unwrap_or_default().to_string();
401 let value_class = match kind {
402 ValueKind::Prose => "text-sm text-foreground wrap-anywhere whitespace-pre-wrap",
403 ValueKind::Machine => "text-sm text-foreground font-mono break-all whitespace-pre-wrap",
404 };
405 let value = view! { cx => <div class=(value_class)>(text)</div> }.boxed();
406 render_value_view(cx, label, value)
407}
408
409fn render_value_view<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> Result<BoxView<'a>> {
411 let label = label.to_string();
412 Ok(view! {
413 cx =>
414 ui_field(
415 attrs: attributes! { class="ac-field" },
416 ui_field_content(
417 ui_field_title((label))
418 (value)
419 )
420 )
421 }
422 .boxed())
423}
424
425pub(crate) struct FieldChrome {
427 name: String,
428 error_id: String,
429 error_text: String,
430 has_error: bool,
431}
432
433impl FieldChrome {
434 pub(crate) fn new(name: &str, error: Option<&str>, fallback: Option<String>) -> Self {
435 let has_error = error.is_some() || fallback.is_some();
436 let error_text = match error {
437 Some(message) if !message.is_empty() => message.to_string(),
438 _ => fallback.unwrap_or_default(),
439 };
440 Self {
441 name: name.to_string(),
442 error_id: format!("{name}-error"),
443 error_text,
444 has_error,
445 }
446 }
447
448 pub(crate) fn aria_invalid(&self) -> &'static str {
449 if self.has_error { "true" } else { "false" }
450 }
451
452 pub(crate) fn described_by(&self) -> Option<String> {
453 self.has_error.then(|| self.error_id.clone())
454 }
455}
456
457pub(crate) fn render_field<'a>(
459 cx: &'a Cx,
460 chrome: &FieldChrome,
461 label: &str,
462 required: bool,
463 attributes: Attributes,
464 control: BoxView<'a>,
465) -> Result<BoxView<'a>> {
466 let name = chrome.name.clone();
467 let label_text = label.to_string();
468 let has_error = chrome.has_error;
469 let error_id = chrome.error_id.clone();
470 let error_text = chrome.error_text.clone();
471 let field_class = if has_error {
472 "ac-field ac-field--error"
473 } else {
474 "ac-field"
475 };
476 Ok(view! {
477 cx =>
478 ui_field(
479 attrs: attributes! {
480 class=(field_class)
481 data-invalid=(has_error.then_some("true"))
482 (attributes)
483 },
484 ui_field_label(
485 attrs: attributes! { for=(name) },
486 (label_text)
487 if required {
488 <span class="text-destructive" aria-hidden="true">"*"</span>
489 }
490 )
491 (control)
492 if has_error {
493 ui_field_error(
494 attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
495 (error_text)
496 )
497 }
498 )
499 }
500 .boxed())
501}
502
503#[cfg(test)]
504mod test_support {
505 pub(super) use crate::test_support::{DummyUser, cx};
506
507 pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
509 let at = html
510 .find(needle)
511 .unwrap_or_else(|| panic!("no {needle} in {html}"));
512 opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
513 }
514
515 pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
517 let mut quoted = false;
518 for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
519 match byte {
520 b'"' => quoted = !quoted,
521 b'>' if !quoted => return &html[start..start + offset],
522 _ => {}
523 }
524 }
525 panic!("unterminated tag at byte {start} in {html}");
526 }
527
528 pub(super) fn attributes_of(html: &str, needle: &str) -> Vec<String> {
530 let mut quoted = false;
531 let mut attrs: Vec<String> = Vec::new();
532 let mut current = String::new();
533 for ch in tag_with(html, needle).chars() {
534 match ch {
535 '"' => {
536 quoted = !quoted;
537 current.push(ch);
538 }
539 ch if ch.is_whitespace() && !quoted => {
540 if !current.is_empty() {
541 attrs.push(std::mem::take(&mut current));
542 }
543 }
544 ch => current.push(ch),
545 }
546 }
547 if !current.is_empty() {
548 attrs.push(current);
549 }
550 attrs.remove(0); attrs.sort();
552 attrs
553 }
554}
555
556#[cfg(test)]
557mod tests;