Skip to main content

omp_tui/components/
form.rs

1use std::fmt::Write as _;
2
3use omp_core::Str;
4use serde_json::{Map, Value};
5use smallvec::SmallVec;
6
7use crate::{
8	component::{Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot},
9	context::{Theme, UiContext},
10	frame::{Rect, Style},
11	input::{Key, Mouse, sanitize_paste, word_rubout_start},
12	props::{Prop, PropValue, Props},
13	rich::cell_width,
14};
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17enum FieldKind {
18	Bool,
19	Enum,
20	Text,
21	Select,
22	Multi,
23	Number,
24}
25
26#[derive(Clone, Debug)]
27enum FieldValue {
28	Bool(bool),
29	Text(String),
30	Choice(Str),
31	Many(SmallVec<Str, 4>),
32	Number(i64),
33}
34
35/// Declarative input metadata backing the `<field>` markup tag.
36pub struct Field {
37	props:    Props,
38	label:    Str,
39	children: Vec<crate::component::Cached>,
40}
41
42impl Field {
43	/// Creates an empty field definition.
44	pub fn new() -> Self {
45		Self { props: Props::new(), label: Str::new(""), children: Vec::new() }
46	}
47
48	/// Sets one field property.
49	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
50		self.props.set(prop, value);
51		self
52	}
53
54	/// Sets one field property from a string.
55	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
56		self.props.set(prop, value);
57		self
58	}
59
60	/// Sets the field's visible label.
61	pub fn label(mut self, label: impl Into<Str>) -> Self {
62		self.label = label.into();
63		self
64	}
65
66	/// Appends field content used by richer controls.
67	pub fn child(mut self, children: impl IntoChildren) -> Self {
68		children.extend_children(&mut self.children);
69		self
70	}
71}
72
73impl Default for Field {
74	fn default() -> Self {
75		Self::new()
76	}
77}
78
79#[derive(Clone, Debug)]
80struct FieldData {
81	kind:     FieldKind,
82	id:       Str,
83	label:    Str,
84	desc:     Option<Str>,
85	options:  SmallVec<Str, 8>,
86	value:    FieldValue,
87	required: bool,
88	pattern:  Option<Str>,
89	min:      i64,
90	max:      i64,
91	step:     i64,
92}
93
94impl FieldData {
95	fn from_field(field: Field) -> Self {
96		let kind = match field.props.str_of(Prop::Kind).map(Str::as_str) {
97			Some("bool") => FieldKind::Bool,
98			Some("enum") => FieldKind::Enum,
99			Some("select") => FieldKind::Select,
100			Some("multi") => FieldKind::Multi,
101			Some("number") => FieldKind::Number,
102			_ => FieldKind::Text,
103		};
104		let options: SmallVec<Str, 8> = field
105			.props
106			.str_of(Prop::Options)
107			.map(|options| options.split_whitespace().map(Str::new).collect())
108			.unwrap_or_default();
109		let raw = field.props.str_of(Prop::Value);
110		let value = match kind {
111			FieldKind::Bool => FieldValue::Bool(raw.is_some_and(|value| value == "true")),
112			FieldKind::Enum | FieldKind::Select => FieldValue::Choice(
113				raw.filter(|value| options.iter().any(|option| option == *value))
114					.cloned()
115					.or_else(|| options.first().cloned())
116					.unwrap_or_default(),
117			),
118			FieldKind::Multi => FieldValue::Many(
119				raw.map(|value| {
120					options
121						.iter()
122						.filter(|option| value.split_whitespace().any(|part| *option == part))
123						.cloned()
124						.collect()
125				})
126				.unwrap_or_default(),
127			),
128			FieldKind::Number => {
129				FieldValue::Number(raw.and_then(|value| value.parse().ok()).unwrap_or(0))
130			},
131			FieldKind::Text => FieldValue::Text(raw.map(ToString::to_string).unwrap_or_default()),
132		};
133		let i64_prop = |prop| match field.props.get(prop) {
134			Some(PropValue::I64(value)) => Some(*value),
135			Some(PropValue::U16(value)) => Some(i64::from(*value)),
136			Some(PropValue::Str(value)) => value.parse().ok(),
137			_ => None,
138		};
139		let id = field.props.id().cloned().unwrap_or_default();
140		let label = if field.label.is_empty() {
141			field
142				.props
143				.str_of(Prop::Label)
144				.cloned()
145				.unwrap_or_else(|| id.clone())
146		} else {
147			field.label
148		};
149		Self {
150			kind,
151			id,
152			label,
153			desc: field.props.str_of(Prop::Desc).cloned(),
154			options,
155			value,
156			required: field.props.flag(Prop::Required),
157			pattern: field.props.str_of(Prop::Match).cloned(),
158			min: i64_prop(Prop::Min).unwrap_or(i64::MIN),
159			max: i64_prop(Prop::Max).unwrap_or(i64::MAX),
160			step: i64_prop(Prop::Step).unwrap_or(1),
161		}
162	}
163}
164
165/// An interactive collection of fields backing the `<form>` markup tag.
166pub struct Form {
167	props:      Props,
168	slot:       Slot,
169	fields:     Vec<FieldData>,
170	cursor:     u16,
171	editing:    bool,
172	open:       Option<u16>,
173	sub_cursor: u16,
174	scratch:    String,
175}
176
177impl Form {
178	/// Creates an empty form.
179	pub fn new() -> Self {
180		Self {
181			props:      Props::new(),
182			slot:       next_slot(),
183			fields:     Vec::new(),
184			cursor:     0,
185			editing:    false,
186			open:       None,
187			sub_cursor: 0,
188			scratch:    String::new(),
189		}
190	}
191
192	/// Sets one form property.
193	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
194		self.props.set(prop, value);
195		self
196	}
197
198	/// Sets one form property from a string.
199	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
200		self.props.set(prop, value);
201		self
202	}
203
204	/// Appends a field definition.
205	pub fn field(mut self, field: Field) -> Self {
206		self.fields.push(FieldData::from_field(field));
207		self
208	}
209
210	fn activate(&mut self) {
211		let cursor = self.cursor;
212		let Some(field) = self.fields.get_mut(usize::from(cursor)) else {
213			return;
214		};
215		match field.kind {
216			FieldKind::Bool => {
217				if let FieldValue::Bool(value) = &mut field.value {
218					*value = !*value;
219				}
220			},
221			FieldKind::Enum => cycle_choice(field, true),
222			FieldKind::Select | FieldKind::Multi => {
223				self.open = Some(cursor);
224				self.sub_cursor = match (&field.value, field.kind) {
225					(FieldValue::Choice(choice), FieldKind::Select) => field
226						.options
227						.iter()
228						.position(|option| option == choice)
229						.unwrap_or(0) as u16,
230					_ => 0,
231				};
232			},
233			FieldKind::Text => self.editing = true,
234			FieldKind::Number => {},
235		}
236	}
237
238	fn click_row(&mut self, index: u16) {
239		if usize::from(index) >= self.fields.len() {
240			return;
241		}
242		self.cursor = index;
243		if self.open.is_some() && self.open != Some(index) {
244			self.open = None;
245		}
246		self.activate();
247	}
248
249	fn click_sub(&mut self, index: u16) {
250		let Some(open) = self.open else { return };
251		self.sub_cursor = index;
252		let field = &mut self.fields[usize::from(open)];
253		if field.kind == FieldKind::Multi {
254			toggle_multi(field, index);
255		} else {
256			if let Some(option) = field.options.get(usize::from(index)) {
257				field.value = FieldValue::Choice(option.clone());
258			}
259			self.open = None;
260		}
261	}
262}
263
264impl Default for Form {
265	fn default() -> Self {
266		Self::new()
267	}
268}
269
270impl Component for Form {
271	fn props(&self) -> &Props {
272		&self.props
273	}
274
275	fn props_mut(&mut self) -> &mut Props {
276		&mut self.props
277	}
278
279	fn slot(&self) -> Slot {
280		self.slot
281	}
282
283	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
284		let natural = self
285			.fields
286			.iter()
287			.map(|field| cell_width(&field.label) + 24)
288			.max()
289			.unwrap_or(24);
290		(24, natural)
291	}
292
293	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
294		let mut height = self.fields.len() as u16;
295		if let Some(open) = self.open {
296			height += self.fields[usize::from(open)].options.len() as u16;
297		}
298		if self
299			.fields
300			.get(usize::from(self.cursor))
301			.is_some_and(|field| field.desc.is_some())
302		{
303			height += 1;
304		}
305		height
306	}
307
308	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
309		let focused = pc.focus == Some(self.slot);
310		let label_width = self
311			.fields
312			.iter()
313			.map(|field| cell_width(&field.label))
314			.max()
315			.unwrap_or(8)
316			+ 2;
317		let mut hit_y = rect.y;
318		for (index, field) in self.fields.iter().enumerate() {
319			pc.hits.push(Hit {
320				rect: Rect::new(rect.x, hit_y, rect.width, 1),
321				slot: self.slot,
322				tag:  HitTag::Row(index as u16),
323			});
324			hit_y = hit_y.saturating_add(1);
325			if self.open == Some(index as u16) {
326				for option_index in 0..field.options.len() as u16 {
327					pc.hits.push(Hit {
328						rect: Rect::new(rect.x, hit_y, rect.width, 1),
329						slot: self.slot,
330						tag:  HitTag::Sub(option_index),
331					});
332					hit_y = hit_y.saturating_add(1);
333				}
334			}
335		}
336		let mut y = rect.y;
337		for (index, field) in self.fields.iter().enumerate() {
338			if y >= pc.clip {
339				return;
340			}
341			let here = focused && index as u16 == self.cursor;
342			let hovered = matches!(pc.hover, Some((slot, HitTag::Row(row))) if slot == self.slot && row == index as u16);
343			if hovered {
344				pc.frame
345					.fill(Rect::new(rect.x, y, rect.width, 1), Style::new().bg(pc.ctx.theme.hover));
346			}
347			let tint = |style: Style| {
348				if hovered {
349					style.bg(pc.ctx.theme.hover)
350				} else {
351					style
352				}
353			};
354			let mut x = pc.frame.put(
355				rect.x,
356				y,
357				if here { pc.ctx.charset.cursor() } else { "  " },
358				tint(Style::new().fg(pc.ctx.theme.accent)),
359			);
360			let label_style = if here {
361				tint(Style::new().fg(pc.ctx.theme.accent).bold())
362			} else {
363				tint(base(&pc.ctx.theme))
364			};
365			x = pc.frame.put(x, y, &field.label, label_style);
366			for _ in cell_width(&field.label)..label_width {
367				x = pc.frame.put(x, y, " ", tint(base(&pc.ctx.theme)));
368			}
369			x = paint_field_value(pc.ctx, pc.frame, x, y, field, here, tint, &mut self.scratch);
370			if here && self.editing {
371				pc.frame
372					.put(x, y, pc.ctx.charset.beam(), tint(Style::new().fg(pc.ctx.theme.accent)));
373			}
374			y += 1;
375			if self.open == Some(index as u16) {
376				for (option_index, option) in field.options.iter().enumerate() {
377					if y >= pc.clip {
378						return;
379					}
380					let sub_here = option_index as u16 == self.sub_cursor;
381					let picked = match &field.value {
382						FieldValue::Choice(choice) => choice == option,
383						FieldValue::Many(values) => values.contains(option),
384						_ => false,
385					};
386					let mark = if field.kind == FieldKind::Multi {
387						pc.ctx.charset.checkbox(picked)
388					} else {
389						pc.ctx.charset.radio(picked)
390					};
391					let mut sx = pc.frame.put(
392						rect.x + 4,
393						y,
394						if sub_here {
395							pc.ctx.charset.cursor()
396						} else {
397							"  "
398						},
399						Style::new().fg(pc.ctx.theme.accent),
400					);
401					sx = pc.frame.put(
402						sx,
403						y,
404						mark,
405						Style::new().fg(if picked {
406							pc.ctx.theme.ok
407						} else {
408							pc.ctx.theme.muted
409						}),
410					);
411					sx = pc.frame.put(sx, y, " ", base(&pc.ctx.theme));
412					pc.frame.put(
413						sx,
414						y,
415						option,
416						if sub_here {
417							Style::new().fg(pc.ctx.theme.accent).bold()
418						} else {
419							base(&pc.ctx.theme)
420						},
421					);
422					y += 1;
423				}
424			}
425		}
426		if let Some(field) = self.fields.get(usize::from(self.cursor))
427			&& let Some(desc) = &field.desc
428			&& y < pc.clip
429		{
430			pc.frame.put(rect.x + 2, y, desc, dim(&pc.ctx.theme));
431		}
432	}
433
434	fn focusable(&self) -> bool {
435		true
436	}
437
438	fn enter(&mut self, forward: bool) {
439		self.cursor = if forward {
440			0
441		} else {
442			self.fields.len().saturating_sub(1) as u16
443		};
444	}
445
446	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
447		if self.fields.is_empty() {
448			return Flow::Skip;
449		}
450		if let Some(open) = self.open {
451			let field = &mut self.fields[usize::from(open)];
452			let len = field.options.len() as u16;
453			match key {
454				Key::Up if len > 0 => self.sub_cursor = (self.sub_cursor + len - 1) % len,
455				Key::Down if len > 0 => self.sub_cursor = (self.sub_cursor + 1) % len,
456				Key::Space if field.kind == FieldKind::Multi => toggle_multi(field, self.sub_cursor),
457				Key::Enter => {
458					if field.kind != FieldKind::Multi
459						&& let Some(option) = field.options.get(usize::from(self.sub_cursor))
460					{
461						field.value = FieldValue::Choice(option.clone());
462					}
463					self.open = None;
464				},
465				Key::Esc => self.open = None,
466				_ => {},
467			}
468			return Flow::Consumed;
469		}
470		let field_count = self.fields.len() as u16;
471		if self.editing {
472			let field = &mut self.fields[usize::from(self.cursor)];
473			let FieldValue::Text(text) = &mut field.value else {
474				self.editing = false;
475				return Flow::Consumed;
476			};
477			match key {
478				Key::Enter | Key::Esc => self.editing = false,
479				Key::Backspace => {
480					text.pop();
481				},
482				Key::Space => text.push(' '),
483				Key::Char(character) => text.push(character),
484				Key::Ctrl('u') => text.clear(),
485				Key::Ctrl('w') => text.truncate(word_rubout_start(text, text.len())),
486				_ => {},
487			}
488			return Flow::Consumed;
489		}
490		let kind = self.fields[usize::from(self.cursor)].kind;
491		match key {
492			Key::Left | Key::Right if kind == FieldKind::Enum => {
493				cycle_choice(&mut self.fields[usize::from(self.cursor)], key == Key::Right);
494				Flow::Consumed
495			},
496			Key::Left | Key::Right if kind == FieldKind::Number => {
497				let field = &mut self.fields[usize::from(self.cursor)];
498				if let FieldValue::Number(value) = &mut field.value {
499					let step = if key == Key::Right {
500						field.step
501					} else {
502						field.step.saturating_neg()
503					};
504					*value = value.saturating_add(step).clamp(field.min, field.max);
505				}
506				Flow::Consumed
507			},
508			Key::Up if self.cursor > 0 => {
509				self.cursor -= 1;
510				Flow::Consumed
511			},
512			Key::Down if self.cursor + 1 < field_count => {
513				self.cursor += 1;
514				Flow::Consumed
515			},
516			Key::Enter | Key::Space => {
517				self.activate();
518				Flow::Consumed
519			},
520			_ => Flow::Skip,
521		}
522	}
523
524	fn mouse(
525		&mut self,
526		_ec: &mut EventCtx<'_>,
527		tag: HitTag,
528		_at: (u16, u16),
529		_rect: Rect,
530		mouse: Mouse,
531	) -> Flow {
532		match mouse {
533			Mouse::Click => {
534				match tag {
535					HitTag::Row(index) => self.click_row(index),
536					HitTag::Sub(index) => self.click_sub(index),
537					_ => return Flow::Skip,
538				}
539				Flow::Consumed
540			},
541			Mouse::RightClick
542			| Mouse::MiddleClick
543			| Mouse::Move
544			| Mouse::Drag
545			| Mouse::Release
546			| Mouse::WheelUp
547			| Mouse::WheelDown
548			| Mouse::WheelLeft
549			| Mouse::WheelRight => Flow::Skip,
550		}
551	}
552
553	fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
554		if !self.editing {
555			return Flow::Skip;
556		}
557		let sanitized = sanitize_paste(text);
558		if sanitized.is_empty() {
559			return Flow::Skip;
560		}
561		let Some(FieldData { value: FieldValue::Text(value), .. }) =
562			self.fields.get_mut(usize::from(self.cursor))
563		else {
564			return Flow::Skip;
565		};
566		value.push_str(&sanitized.replace(['\n', '\t'], " "));
567		Flow::Consumed
568	}
569
570	fn validation_error(&self) -> Option<String> {
571		for field in &self.fields {
572			let value = field_value(field);
573			let text = super::wizard::display_value(&value);
574			if field.required && text.trim().is_empty() {
575				return Some(format!("{} is required", field.id));
576			}
577			if let Some(pattern) = &field.pattern
578				&& !text.trim().is_empty()
579				&& !super::wizard::match_simple(pattern, text.trim())
580			{
581				return Some(format!("{} must match {}", field.id, pattern));
582			}
583		}
584		None
585	}
586
587	fn value(&self, out: &mut Map<String, Value>) {
588		let Some(id) = self.props.id() else { return };
589		let mut object = Map::new();
590		for field in &self.fields {
591			if !field.id.is_empty() {
592				object.insert(field.id.to_string(), field_value(field));
593			}
594		}
595		out.insert(id.to_string(), Value::Object(object));
596	}
597}
598
599fn cycle_choice(field: &mut FieldData, forward: bool) {
600	if field.options.is_empty() {
601		return;
602	}
603	if let FieldValue::Choice(current) = &field.value {
604		let len = field.options.len();
605		let at = field
606			.options
607			.iter()
608			.position(|option| option == current)
609			.unwrap_or(0);
610		let next = if forward {
611			(at + 1) % len
612		} else {
613			(at + len - 1) % len
614		};
615		field.value = FieldValue::Choice(field.options[next].clone());
616	}
617}
618
619fn toggle_multi(field: &mut FieldData, index: u16) {
620	let Some(option) = field.options.get(usize::from(index)).cloned() else {
621		return;
622	};
623	if let FieldValue::Many(values) = &mut field.value {
624		if values.contains(&option) {
625			values.retain(|value| *value != option);
626		} else {
627			values.push(option);
628			values.sort_by_key(|value| field.options.iter().position(|option| option == value));
629		}
630	}
631}
632
633fn field_value(field: &FieldData) -> Value {
634	match &field.value {
635		FieldValue::Bool(value) => Value::Bool(*value),
636		FieldValue::Text(value) => Value::String(value.clone()),
637		FieldValue::Choice(value) => Value::String(value.to_string()),
638		FieldValue::Many(values) => Value::Array(
639			values
640				.iter()
641				.map(|value| Value::String(value.to_string()))
642				.collect(),
643		),
644		FieldValue::Number(value) => Value::Number((*value).into()),
645	}
646}
647
648const fn base(theme: &Theme) -> Style {
649	Style::new().fg(theme.fg)
650}
651const fn dim(theme: &Theme) -> Style {
652	Style::new().fg(theme.muted)
653}
654
655fn paint_field_value(
656	ctx: &UiContext,
657	frame: &mut crate::frame::Frame,
658	x: u16,
659	y: u16,
660	field: &FieldData,
661	here: bool,
662	tint: impl Fn(Style) -> Style,
663	scratch: &mut String,
664) -> u16 {
665	match (&field.value, field.kind) {
666		(FieldValue::Bool(value), _) => frame.put(
667			x,
668			y,
669			if *value { "true" } else { "false" },
670			tint(Style::new().fg(if *value {
671				ctx.theme.ok
672			} else {
673				ctx.theme.muted
674			})),
675		),
676		(FieldValue::Choice(choice), FieldKind::Enum) => {
677			let mut x = frame.put(x, y, choice, tint(Style::new().fg(ctx.theme.info)));
678			if here {
679				x = frame.put(x, y, "  ", tint(dim(&ctx.theme)));
680				x = frame.put(x, y, ctx.charset.arrows().0, tint(dim(&ctx.theme)));
681				x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
682				x = frame.put(x, y, ctx.charset.arrows().1, tint(dim(&ctx.theme)));
683			}
684			x
685		},
686		(FieldValue::Choice(choice), _) => {
687			let x = frame.put(x, y, choice, tint(Style::new().fg(ctx.theme.info)));
688			frame.put(x, y, ctx.charset.dropdown(), tint(dim(&ctx.theme)))
689		},
690		(FieldValue::Many(values), _) => {
691			scratch.clear();
692			if values.is_empty() {
693				scratch.push('—');
694			} else {
695				for (index, value) in values.iter().enumerate() {
696					if index > 0 {
697						scratch.push_str(", ");
698					}
699					scratch.push_str(value);
700				}
701			}
702			let x = frame.put(x, y, scratch, tint(Style::new().fg(ctx.theme.info)));
703			frame.put(x, y, ctx.charset.dropdown(), tint(dim(&ctx.theme)))
704		},
705		(FieldValue::Number(value), _) => {
706			let mut x = x;
707			if here {
708				x = frame.put(x, y, ctx.charset.arrows().0, tint(dim(&ctx.theme)));
709				x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
710			}
711			scratch.clear();
712			let _ = write!(scratch, "{value}");
713			x = frame.put(x, y, scratch, tint(Style::new().fg(ctx.theme.warn)));
714			if here {
715				x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
716				x = frame.put(x, y, ctx.charset.arrows().1, tint(dim(&ctx.theme)));
717			}
718			x
719		},
720		(FieldValue::Text(text), _) => frame.put(x, y, text, tint(base(&ctx.theme))),
721	}
722}
723
724#[cfg(test)]
725mod tests {
726	use super::*;
727
728	fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
729		EventCtx::new(ctx, 40, 10)
730	}
731
732	#[test]
733	fn navigation_edit_and_values_match_form_contract() {
734		let mut form = Form::new()
735			.with(Prop::Id, "settings")
736			.field(
737				Field::new()
738					.with(Prop::Id, "name")
739					.with(Prop::Kind, "text")
740					.with(Prop::Value, "omp"),
741			)
742			.field(
743				Field::new()
744					.with(Prop::Id, "theme")
745					.with(Prop::Kind, "select")
746					.with(Prop::Options, "dark light")
747					.with(Prop::Value, "dark"),
748			);
749		let ctx = UiContext::default();
750		let mut ec = event_ctx(&ctx);
751		assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
752		assert_eq!(form.key(&mut ec, Key::Char('!')), Flow::Consumed);
753		assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
754		assert_eq!(form.key(&mut ec, Key::Down), Flow::Consumed);
755		assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
756		assert_eq!(form.key(&mut ec, Key::Down), Flow::Consumed);
757		assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
758		let mut values = Map::new();
759		form.value(&mut values);
760		assert_eq!(values["settings"], serde_json::json!({ "name": "omp!", "theme": "light" }));
761	}
762
763	#[test]
764	fn validation_reports_the_first_invalid_field() {
765		let mut form = Form::new()
766			.field(
767				Field::new()
768					.with(Prop::Id, "name")
769					.with(Prop::Required, true),
770			)
771			.field(
772				Field::new()
773					.with(Prop::Id, "slug")
774					.with(Prop::Match, "[a-z-]+")
775					.with(Prop::Value, "Bad Slug"),
776			);
777
778		assert_eq!(form.validation_error().as_deref(), Some("name is required"));
779		form.fields[0].value = FieldValue::Text("OMP".into());
780		assert_eq!(form.validation_error().as_deref(), Some("slug must match [a-z-]+"));
781		form.fields[1].value = FieldValue::Text("valid-slug".into());
782		assert_eq!(form.validation_error(), None);
783	}
784}