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::{lenses::ResolvedLens, tree::Mode, validation::Rules};
24use crate::{
25 form::{FieldError, FormScalar},
26 naming::capitalize,
27};
28
29pub struct Field {
83 name: String,
84 label: String,
85 required: bool,
86 nullable: bool,
89 rules: Rules,
91 control: ControlKind,
92 misdeclared: Option<crate::DeclarationErrorKind>,
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)
119 .field("required", &self.required)
120 .field("control", &control)
121 .finish()
122 }
123}
124
125impl Field {
126 fn bound<M, T>(lens: ResolvedLens<M, T>, rules: Rules, control: ControlKind) -> Self {
127 Self {
128 name: lens.name,
129 label: lens.label,
130 required: !lens.nullable,
131 nullable: lens.nullable,
132 rules,
133 control,
134 misdeclared: lens.misdeclared,
135 }
136 }
137
138 pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
141 where
142 M: toasty::schema::Model,
143 T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
144 {
145 let lens = ResolvedLens::of(lens);
146 let control = TextControl::new::<M, T>(lens.path.clone(), lens.unique);
147 TextField(Self::bound(
148 lens,
149 Rules::new().scalar::<T>(),
150 ControlKind::Text(control),
151 ))
152 }
153
154 #[doc(hidden)]
156 pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
157 where
158 M: toasty::schema::Model,
159 T: FormScalar,
160 {
161 let lens = ResolvedLens::of(path);
162 TextField(Self::bound(
163 lens,
164 Rules::new().scalar::<T>(),
165 ControlKind::Text(TextControl::leaf::<T>()),
166 ))
167 }
168
169 pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
172 where
173 M: toasty::schema::Model,
174 {
175 ChoiceField(Self::bound(
176 ResolvedLens::of(lens),
177 Rules::new(),
178 ControlKind::Choice(ChoiceControl::default()),
179 ))
180 }
181
182 pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
185 where
186 M: toasty::schema::Model,
187 {
188 FileField(Self::bound(
189 ResolvedLens::of(lens),
190 Rules::new(),
191 ControlKind::File,
192 ))
193 }
194
195 pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
197 where
198 M: toasty::schema::Model,
199 {
200 Self::custom(lens, Toggle).optional()
201 }
202
203 pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
205 where
206 M: toasty::schema::Model,
207 T: FormScalar,
208 {
209 CustomField(Self::bound(
210 ResolvedLens::of(lens),
211 Rules::new().scalar::<T>(),
212 ControlKind::Custom(Arc::new(control)),
213 ))
214 }
215
216 pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
219 Self {
220 label: capitalize(&name),
221 name,
222 required: false,
223 nullable: true,
224 rules: Rules::new(),
225 control: ControlKind::Choice(ChoiceControl {
226 discriminant: true,
227 options: variants,
228 ..ChoiceControl::default()
229 }),
230 misdeclared: None,
231 }
232 }
233
234 pub(crate) fn misdeclared(&self) -> Option<&crate::DeclarationErrorKind> {
235 self.misdeclared.as_ref()
236 }
237
238 pub fn name(&self) -> &str {
239 &self.name
240 }
241
242 pub(crate) fn label_str(&self) -> &str {
243 &self.label
244 }
245
246 pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
247 match &self.control {
248 ControlKind::Choice(choice) => Some(choice),
249 _ => None,
250 }
251 }
252
253 pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
255 match &self.control {
256 ControlKind::Text(text) => text.eq_filter(value),
257 _ => None,
258 }
259 }
260
261 pub(crate) fn is_file(&self) -> bool {
262 matches!(self.control, ControlKind::File)
263 }
264
265 pub(crate) fn is_unique(&self) -> bool {
266 matches!(&self.control, ControlKind::Text(text) if text.unique)
267 }
268
269 pub(crate) fn is_required(&self) -> bool {
271 self.required || (self.is_unique() && !self.nullable)
272 }
273
274 pub(crate) fn validate(&self, value: &str) -> Vec<FieldError> {
276 self.rules
277 .validate(&self.name, &self.label, self.is_required(), value)
278 }
279
280 pub(crate) fn normalize(&self, value: &str) -> Result<String, String> {
282 self.rules.normalize(value)
283 }
284
285 pub(crate) async fn validate_exists(&self, cx: &Cx, value: &str) -> Vec<String> {
287 match &self.control {
288 ControlKind::Choice(choice) => choice.validate_exists(cx, &self.label, value).await,
289 _ => Vec::new(),
290 }
291 }
292
293 pub(crate) async fn recheck(
295 &self,
296 cx: &Cx,
297 value: &str,
298 ex: &mut dyn toasty::Executor,
299 ) -> Vec<String> {
300 match &self.control {
301 ControlKind::Choice(choice) => choice.recheck(cx, &self.label, value, ex).await,
302 _ => Vec::new(),
303 }
304 }
305
306 pub(crate) async fn render<'a>(
308 &self,
309 cx: &'a Cx,
310 value: Option<&str>,
311 error: Option<&str>,
312 mode: Mode,
313 ) -> Result<BoxView<'a>> {
314 if mode == Mode::View && value.is_none() {
315 debug_assert!(
316 false,
317 "view field `{}` has no value: neither `view_values` nor the record form's \
318 `hydrate` supplies its key",
319 self.name
320 );
321 return render_value(cx, &self.label, Some("(missing)"), ValueKind::Prose);
322 }
323 match &self.control {
324 ControlKind::Text(text) => self.render_text(text, cx, value, error, mode),
325 ControlKind::Choice(choice) => {
326 Box::pin(self.render_choice(choice, cx, value, error, mode)).await
327 }
328 ControlKind::File => self.render_file(cx, value, error, mode),
329 ControlKind::Custom(control) => {
330 self.render_custom(control.as_ref(), cx, value, error, mode)
331 }
332 }
333 }
334
335 fn render_custom<'a>(
337 &self,
338 control: &dyn Control,
339 cx: &'a Cx,
340 value: Option<&str>,
341 error: Option<&str>,
342 mode: Mode,
343 ) -> Result<BoxView<'a>> {
344 if mode == Mode::View {
345 let shown = control.display(cx, value.unwrap_or_default());
346 return render_value_view(cx, &self.label, shown);
347 }
348 let required = self.is_required();
349 let chrome = FieldChrome::new(&self.name, error, None);
350 let input = ControlInput::new(
351 &self.name,
352 value,
353 required,
354 chrome.aria_invalid() == "true",
355 chrome.described_by(),
356 );
357 let rendered = control.render(cx, input);
358 render_field(cx, &chrome, &self.label, required, attributes! {}, rendered)
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub(crate) enum ValueKind {
365 Prose,
367 Machine,
369}
370
371fn render_value<'a>(
373 cx: &'a Cx,
374 label: &str,
375 value: Option<&str>,
376 kind: ValueKind,
377) -> Result<BoxView<'a>> {
378 let text = value.unwrap_or_default().to_string();
379 let value_class = match kind {
380 ValueKind::Prose => "text-sm text-foreground wrap-anywhere whitespace-pre-wrap",
381 ValueKind::Machine => "text-sm text-foreground font-mono break-all whitespace-pre-wrap",
382 };
383 let value = view! { cx => <div class=(value_class)>(text)</div> }.boxed();
384 render_value_view(cx, label, value)
385}
386
387fn render_value_view<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> Result<BoxView<'a>> {
389 let label = label.to_string();
390 Ok(view! {
391 cx =>
392 ui_field(
393 attrs: attributes! { class="ac-field" },
394 ui_field_content(
395 ui_field_title((label))
396 (value)
397 )
398 )
399 }
400 .boxed())
401}
402
403pub(crate) struct FieldChrome {
405 name: String,
406 error_id: String,
407 error_text: String,
408 has_error: bool,
409}
410
411impl FieldChrome {
412 pub(crate) fn new(name: &str, error: Option<&str>, fallback: Option<String>) -> Self {
413 let has_error = error.is_some() || fallback.is_some();
414 let error_text = match error {
415 Some(message) if !message.is_empty() => message.to_string(),
416 _ => fallback.unwrap_or_default(),
417 };
418 Self {
419 name: name.to_string(),
420 error_id: format!("{name}-error"),
421 error_text,
422 has_error,
423 }
424 }
425
426 pub(crate) fn aria_invalid(&self) -> &'static str {
427 if self.has_error { "true" } else { "false" }
428 }
429
430 pub(crate) fn described_by(&self) -> Option<String> {
431 self.has_error.then(|| self.error_id.clone())
432 }
433}
434
435pub(crate) fn render_field<'a>(
437 cx: &'a Cx,
438 chrome: &FieldChrome,
439 label: &str,
440 required: bool,
441 attributes: Attributes,
442 control: BoxView<'a>,
443) -> Result<BoxView<'a>> {
444 let name = chrome.name.clone();
445 let label_text = label.to_string();
446 let has_error = chrome.has_error;
447 let error_id = chrome.error_id.clone();
448 let error_text = chrome.error_text.clone();
449 let field_class = if has_error {
450 "ac-field ac-field--error"
451 } else {
452 "ac-field"
453 };
454 Ok(view! {
455 cx =>
456 ui_field(
457 attrs: attributes! {
458 class=(field_class)
459 data-invalid=(has_error.then_some("true"))
460 (attributes)
461 },
462 ui_field_label(
463 attrs: attributes! { for=(name) },
464 (label_text)
465 if required {
466 <span class="text-destructive" aria-hidden="true">"*"</span>
467 }
468 )
469 (control)
470 if has_error {
471 ui_field_error(
472 attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
473 (error_text)
474 )
475 }
476 )
477 }
478 .boxed())
479}
480
481#[cfg(test)]
482mod test_support {
483 pub(super) use crate::test_support::{DummyUser, cx};
484
485 pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
487 let at = html
488 .find(needle)
489 .unwrap_or_else(|| panic!("no {needle} in {html}"));
490 opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
491 }
492
493 pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
495 let mut quoted = false;
496 for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
497 match byte {
498 b'"' => quoted = !quoted,
499 b'>' if !quoted => return &html[start..start + offset],
500 _ => {}
501 }
502 }
503 panic!("unterminated tag at byte {start} in {html}");
504 }
505
506 pub(super) fn attributes_of(html: &str, needle: &str) -> Vec<String> {
508 let mut quoted = false;
509 let mut attrs: Vec<String> = Vec::new();
510 let mut current = String::new();
511 for ch in tag_with(html, needle).chars() {
512 match ch {
513 '"' => {
514 quoted = !quoted;
515 current.push(ch);
516 }
517 ch if ch.is_whitespace() && !quoted => {
518 if !current.is_empty() {
519 attrs.push(std::mem::take(&mut current));
520 }
521 }
522 ch => current.push(ch),
523 }
524 }
525 if !current.is_empty() {
526 attrs.push(current);
527 }
528 attrs.remove(0); attrs.sort();
530 attrs
531 }
532
533 #[derive(Debug, toasty::Model)]
535 pub(super) struct NullableRef {
536 #[key]
537 #[auto]
538 id: uuid::Uuid,
539 parent_id: Option<uuid::Uuid>,
540 }
541
542 #[derive(Debug, toasty::Model)]
544 pub(super) struct FkRef {
545 #[key]
546 #[auto]
547 id: uuid::Uuid,
548 author_id: uuid::Uuid,
549 }
550}
551
552#[cfg(test)]
553mod tests;