1use std::collections::BTreeMap;
4use std::rc::Rc;
5
6use ravenrs_app::{EntityInfo, FieldInfo, FieldKind, Object, Query, Value};
7use ravenrs_core::Color;
8use ravenrs_criteria::Criteria;
9use ravenrs_layout::Align;
10use ravenrs_reactive::{Binding, State};
11use ravenrs_widgets::{
12 AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Checkbox, ColorPicker, Combobox, Date, DatePicker,
13 DateTimePicker, FileUpload, FilterBuilder, FilterField, FilterFieldKind, LookupField, LookupItem,
14 MaskedField, NodeId, NumberField, PickedFile, ProgressBar, SegmentedControl, Select, TagBox, Text,
15 TextArea, TextField, VStack, View,
16};
17
18use crate::session::Session;
19use crate::values::{
20 date_of, date_time_of, display, file_name, file_of, key_from_text, key_text, value_of_date,
21 value_of_date_time, value_of_file,
22};
23
24#[derive(Clone, Debug)]
26#[non_exhaustive]
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
28pub struct EditorRequest {
29 pub field: FieldInfo,
31 pub value: State<Value>,
33 pub caption: String,
35 pub read_only: bool,
37 pub session: Session,
39}
40
41type EditorFactory = Rc<dyn Fn(&EditorRequest) -> AnyView>;
42
43#[derive(Clone, Default)]
46#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
47pub struct EditorRegistry(BTreeMap<String, EditorFactory>);
48
49impl std::fmt::Debug for EditorRegistry {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_list().entries(self.0.keys()).finish()
52 }
53}
54
55#[cfg_attr(feature = "live", ravenrs_live::expose)]
56impl EditorRegistry {
57 pub fn register(
60 &mut self,
61 name: impl Into<String>,
62 factory: impl Fn(&EditorRequest) -> AnyView + 'static,
63 ) {
64 self.0.insert(name.into(), Rc::new(factory));
65 }
66
67 pub(crate) fn editor(&self, name: &str, request: &EditorRequest) -> AnyView {
69 match (self.0.get(name), name, &request.field.kind) {
70 (Some(factory), ..) => factory(request),
71 (None, "Segmented", FieldKind::Enum(options)) => segmented_editor(request, options),
72 (None, "Select", FieldKind::Reference(target)) => reference_select(request, target),
73 (None, "Progress", FieldKind::Integer | FieldKind::Decimal(_)) => progress_editor(request),
74 (None, "Text", FieldKind::Criteria) => text_editor(request),
75 _ => built_in(request),
76 }
77 }
78}
79
80fn number_binding(value: &State<Value>) -> Binding<Option<f64>> {
81 let (read, write) = (value.clone(), value.clone());
82 Binding::new(
83 move || if let Value::Number(number) = read.get() { Some(number) } else { None },
84 move |number: Option<f64>| write.set(number.map_or(Value::Null, Value::Number)),
85 )
86}
87
88fn cents_binding(value: &State<Value>) -> Binding<Option<i64>> {
89 let (read, write) = (value.clone(), value.clone());
90 Binding::new(
91 move || if let Value::Number(cents) = read.get() { Some(cents.round() as i64) } else { None },
92 move |cents: Option<i64>| write.set(cents.map_or(Value::Null, |c| Value::Number(c as f64))),
93 )
94}
95
96fn text_binding(value: &State<Value>) -> Binding<String> {
97 let (read, write) = (value.clone(), value.clone());
98 Binding::new(
99 move || if let Value::Text(text) = read.get() { text } else { String::new() },
100 move |text: String| write.set(if text.is_empty() { Value::Null } else { Value::Text(text) }),
101 )
102}
103
104fn today(session: &Session) -> Date {
105 session.today()
106}
107
108fn segmented_editor(request: &EditorRequest, options: &[ravenrs_app::EnumOption]) -> AnyView {
109 let captions: Vec<String> = options.iter().map(|o| o.caption.clone()).collect();
110 if request.read_only {
111 return enum_editor(request, options);
112 }
113 Box::new(SegmentedControl::new(option_index(request, options), captions).label(request.caption.clone()))
114}
115
116fn enum_editor(request: &EditorRequest, options: &[ravenrs_app::EnumOption]) -> AnyView {
117 let captions: Vec<String> = options.iter().map(|o| o.caption.clone()).collect();
118 Box::new(
119 Select::new(option_index(request, options), captions)
120 .fill()
121 .label(request.caption.clone())
122 .disabled(request.read_only),
123 )
124}
125
126fn option_index(request: &EditorRequest, options: &[ravenrs_app::EnumOption]) -> Binding<usize> {
128 let codes: Vec<String> = options.iter().map(|o| o.code.clone()).collect();
129 let (read, write) = (request.value.clone(), request.value.clone());
130 Binding::new(
131 {
132 let codes = codes.clone();
133 move || match read.get() {
134 Value::Text(code) => codes.iter().position(|c| *c == code).unwrap_or(0),
135 _ => 0,
136 }
137 },
138 move |at: usize| write.set(codes.get(at).map_or(Value::Null, |code| Value::Text(code.clone()))),
139 )
140}
141
142fn caption_of(entity: &EntityInfo, object: &Object) -> String {
144 match entity.default_property() {
145 Some(field) => display(field, &object.get(&field.name)),
146 None => entity.key_field().map_or_else(String::new, |field| display(field, &object.get(&field.name))),
147 }
148}
149
150fn reference_editor(request: &EditorRequest, target: &str) -> AnyView {
151 let session = request.session.clone();
152 let Some(entity) = session.entity(target) else {
153 return text_editor(request);
154 };
155 let (value, write) = (request.value.clone(), request.value.clone());
156 let (reader, known, key_entity) = (session.clone(), entity.clone(), entity.clone());
157 let selected = Binding::new(
158 move || {
159 let key = value.get();
160 if matches!(key, Value::Null) {
161 return None;
162 }
163 let found = reader.frame().space().borrow().find(&known.name, &key).ok().flatten();
164 let label = found.map_or_else(|| key_text(&known, &key), |object| caption_of(&known, &object));
165 Some(LookupItem::new(key_text(&known, &key), label))
166 },
167 move |item: Option<LookupItem>| {
168 write.set(item.map_or(Value::Null, |item| key_from_text(&key_entity, &item.key)));
169 },
170 );
171 let (loader, source) = (session, entity);
172 Box::new(
173 LookupField::remote(selected, move |asked, answer| {
174 let mut query = Query::all().page(0, asked.limit);
175 if let Some(field) = source.default_property() {
176 query = query.sort(field.name.clone(), true);
177 let typed = asked.query.trim().replace('\'', "''");
178 if !typed.is_empty()
179 && let Ok(filter) =
180 Criteria::parse(&format!("Contains(Upper([{}]), Upper('{typed}'))", field.name))
181 {
182 query = query.filter(filter);
183 }
184 }
185 match loader.frame().space().borrow().query(&source.name, &query) {
186 Ok(found) => answer.respond(
187 found
188 .iter()
189 .map(|object| {
190 let key = source.key_field().map_or(Value::Null, |f| object.get(&f.name));
191 LookupItem::new(key_text(&source, &key), caption_of(&source, object))
192 })
193 .collect(),
194 ),
195 Err(error) => answer.fail(error.to_string()),
196 }
197 })
198 .label(request.caption.clone())
199 .read_only(request.read_only),
200 )
201}
202
203pub(crate) fn literal_of(value: &Value) -> String {
205 match value {
206 Value::Null => "Null".to_owned(),
207 Value::Bool(on) => on.to_string(),
208 Value::Number(number) => number.to_string(),
209 Value::Text(text) => format!("'{}'", text.replace('\'', "''")),
210 Value::Date(moment) => format!("#{moment}#"),
211 other => format!("'{other:?}'"),
212 }
213}
214
215fn lookup_filter(request: &EditorRequest) -> Option<Criteria> {
217 let text = request.field.lookup_filter.as_ref()?;
218 filter_for(text, request.session.form_record().as_ref())
219}
220
221pub(crate) fn filter_for(text: &str, record: Option<&Object>) -> Option<Criteria> {
223 let parsed = Criteria::parse(text).ok()?;
224 let filled = parsed.parameters().iter().fold(text.to_owned(), |filled, name| {
225 let value = record.map_or(Value::Null, |record| record.get(name));
226 filled.replace(&format!("?{name}"), &literal_of(&value))
227 });
228 Criteria::parse(&filled).ok()
229}
230
231fn reference_select(request: &EditorRequest, target: &str) -> AnyView {
233 let session = &request.session;
234 let Some(entity) = session.entity(target) else {
235 return text_editor(request);
236 };
237 let mut query = Query::all();
238 if let Some(field) = entity.default_property() {
239 query = query.sort(field.name.clone(), true);
240 }
241 if let Some(filter) = lookup_filter(request) {
242 query = query.filter(filter);
243 }
244 let found = session.frame().space().borrow().query(&entity.name, &query).unwrap_or_default();
245 let key = entity.key_field().map(|field| field.name.clone()).unwrap_or_default();
246 let mut keys: Vec<Value> = Vec::new();
247 let mut captions: Vec<String> = Vec::new();
248 if let Some(none) = &request.field.none_caption {
249 keys.push(Value::Null);
250 captions.push(none.clone());
251 }
252 for object in &found {
253 keys.push(object.get(&key));
254 captions.push(caption_of(&entity, object));
255 }
256 if !keys.contains(&*request.value.peek()) {
258 request.value.set(keys.first().cloned().unwrap_or(Value::Null));
259 }
260 let (read, write) = (request.value.clone(), request.value.clone());
261 let known = keys.clone();
262 let at = Binding::new(
263 move || {
264 let held = read.get();
265 known.iter().position(|key| *key == held).unwrap_or(0)
266 },
267 move |at: usize| write.set(keys.get(at).cloned().unwrap_or(Value::Null)),
268 );
269 Box::new(Select::new(at, captions).fill().label(request.caption.clone()).disabled(request.read_only))
270}
271
272fn progress_editor(request: &EditorRequest) -> AnyView {
274 let held = if let Value::Number(number) = request.value.get() { number } else { 0.0 };
275 Box::new(
276 VStack::new((Text::new(format!("{held:.0}%")), ProgressBar::new((held / 100.0) as f32)))
277 .spacing(4.0)
278 .align(Align::Stretch),
279 )
280}
281
282fn criteria_editor(request: &EditorRequest) -> AnyView {
284 let entity = request.field.criteria_of.as_deref().and_then(|name| request.session.entity(name));
285 let Some(entity) = entity.filter(|_| !request.read_only) else {
286 return text_editor(request);
287 };
288 let fields = entity.fields.iter().filter_map(|field| {
289 let kind = match &field.kind {
290 FieldKind::Integer | FieldKind::Decimal(_) | FieldKind::Money => FilterFieldKind::Number,
291 FieldKind::Boolean => FilterFieldKind::Boolean,
292 FieldKind::Date | FieldKind::DateTime => FilterFieldKind::Date,
293 FieldKind::Text | FieldKind::LongText | FieldKind::Enum(_) | FieldKind::Tags(_) => {
294 FilterFieldKind::Text
295 }
296 _ => return None,
297 };
298 Some(FilterField::new(field.name.clone(), field.caption.clone(), kind))
299 });
300 Box::new(FilterBuilder::new(text_binding(&request.value), fields.collect::<Vec<_>>()))
301}
302
303fn tags_editor(request: &EditorRequest, options: &[ravenrs_app::EnumOption]) -> AnyView {
305 let (read, write) = (request.value.clone(), request.value.clone());
306 let chosen = Binding::new(
307 move || match read.get() {
308 Value::Text(codes) if !codes.is_empty() => codes.split(',').map(str::to_owned).collect(),
309 _ => Vec::new(),
310 },
311 move |codes: Vec<String>| {
312 write.set(if codes.is_empty() { Value::Null } else { Value::Text(codes.join(",")) });
313 },
314 );
315 let items = options.iter().map(|option| LookupItem::new(option.code.clone(), option.caption.clone()));
316 Box::new(
317 TagBox::new(chosen, items.collect())
318 .fill()
319 .label(request.caption.clone())
320 .read_only(request.read_only),
321 )
322}
323
324fn predefined_editor(request: &EditorRequest) -> AnyView {
326 let mut values = request.field.predefined.clone();
327 if let Value::Text(held) = &*request.value.peek()
328 && !values.contains(held)
329 {
330 values.push(held.clone());
331 }
332 let (read, write) = (request.value.clone(), request.value.clone());
333 let known = values.clone();
334 let at = Binding::new(
335 move || if let Value::Text(held) = read.get() { known.iter().position(|v| *v == held) } else { None },
336 {
337 let values = values.clone();
338 move |at: Option<usize>| {
339 write.set(at.and_then(|at| values.get(at)).map_or(Value::Null, |v| Value::Text(v.clone())));
340 }
341 },
342 );
343 Box::new(Combobox::new(at, values).fill().label(request.caption.clone()).read_only(request.read_only))
344}
345
346fn masked_editor(request: &EditorRequest, mask: &str) -> AnyView {
348 Box::new(
349 MaskedField::new(text_binding(&request.value), mask.to_owned())
350 .keep_literals()
351 .label(request.caption.clone())
352 .read_only(request.read_only),
353 )
354}
355
356fn text_editor(request: &EditorRequest) -> AnyView {
357 let field = TextField::new(text_binding(&request.value)).read_only(request.read_only);
358 let field = if request.field.autofocus { field.autofocus() } else { field };
359 let field = match on_leave(request) {
360 Some(left) => field.on_leave(left),
361 None => field,
362 };
363 match &request.field.placeholder {
364 Some(text) => Box::new(field.placeholder(text.clone())),
365 None => Box::new(field),
366 }
367}
368
369fn read_only_text(request: &EditorRequest) -> AnyView {
371 let text = display(&request.field, &request.value.get());
372 let decimal = request.session.words().decimal;
373 let text = if request.field.display_format.is_some() && decimal != '.' {
375 text.replace('.', &decimal.to_string())
376 } else {
377 text
378 };
379 Box::new(TextField::new(Binding::constant(text)).read_only(true))
380}
381
382struct FileEditor(EditorRequest);
384
385#[cfg_attr(feature = "live", ravenrs_live::expose)]
386impl FileEditor {
387 fn control(&self) -> AnyView {
388 let request = &self.0;
389 let session = request.session.clone();
390 let store = session.attachment_store();
391 let keep = {
392 let (write, store) = (request.value.clone(), store.clone());
393 let failed = session.clone();
394 move |file: Option<&PickedFile>| match value_of_file(file, store.as_deref()) {
395 Ok(value) => write.set(value),
396 Err(error) => failed.set_error(Some(error)),
397 }
398 };
399 let holding = {
400 let (read, store) = (request.value.clone(), store);
401 move || file_of(&read.get(), store.as_deref())
402 };
403 #[cfg(feature = "images")]
404 if matches!(request.field.kind, FieldKind::Image) {
405 let (read, write) = (holding, keep);
406 let file = Binding::new(read, move |file: Option<PickedFile>| write(file.as_ref()));
407 let mut field = ravenrs_widgets::ImageField::new(file)
408 .label(request.caption.clone())
409 .read_only(request.read_only);
410 if let Some(bytes) = request.field.most_bytes {
411 field = field.most_bytes(bytes);
412 }
413 return Box::new(field);
414 }
415 let (read, write) = (holding, keep);
416 let files = Binding::new(
417 move || read().into_iter().collect(),
418 move |files: Vec<PickedFile>| write(files.first()),
419 );
420 let mut field =
421 FileUpload::new(files).most_files(1).label(request.caption.clone()).read_only(request.read_only);
422 if let Some(bytes) = request.field.most_bytes {
423 field = field.most_bytes(bytes);
424 }
425 if !request.field.accepted.is_empty() {
426 field = field.accept(request.field.accepted.clone());
427 }
428 Box::new(field)
429 }
430}
431
432impl View for FileEditor {
433 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
434 let request = &self.0;
435 let session = request.session.clone();
436 let held = request.value.get();
437 let name = file_name(&held);
438 let picker = cx.file_picker();
439 let saving = name.map(|name| {
440 let store = session.attachment_store();
441 let words = session.words().save_file.clone();
442 let session = session.clone();
443 Button::new(format!("{words} {name}")).style(ButtonStyle::Ghost).size(ButtonSize::Small).on_click(
444 move || {
445 if let Some(file) = file_of(&held, store.as_deref()) {
446 session.hand_file(file.name.clone(), file.bytes.to_vec(), &picker);
447 }
448 },
449 )
450 });
451 cx.child(&VStack::new((self.control(), saving)).spacing(8.0).align(Align::Start))
452 }
453}
454
455fn on_leave(request: &EditorRequest) -> Option<impl Fn() + 'static> {
457 let checks = request.session.form_record().is_some_and(|record| {
458 request.session.entity(record.entity()).is_some_and(|entity| entity.checks_on_leave)
459 });
460 checks.then(|| {
461 let (session, field) = (request.session.clone(), request.field.name.clone());
462 move || session.left_field(&field)
463 })
464}
465
466fn leaving_number(field: NumberField, request: &EditorRequest) -> NumberField {
468 match on_leave(request) {
469 Some(left) => field.on_leave(left),
470 None => field,
471 }
472}
473
474fn built_in(request: &EditorRequest) -> AnyView {
475 let (value, read_only) = (&request.value, request.read_only);
476 if request.field.calculated.is_some() {
477 return read_only_text(request);
478 }
479 if request.read_only && request.field.display_format.is_some() {
480 return read_only_text(request);
481 }
482 match &request.field.kind {
483 FieldKind::Text if !request.field.predefined.is_empty() => predefined_editor(request),
484 FieldKind::Text if request.field.edit_mask.is_some() => {
485 masked_editor(request, request.field.edit_mask.as_deref().unwrap_or_default())
486 }
487 FieldKind::Text | FieldKind::Guid => text_editor(request),
488 FieldKind::Criteria => criteria_editor(request),
489 FieldKind::Tags(options) => tags_editor(request, options),
490 FieldKind::LongText => Box::new(TextArea::new(text_binding(value)).read_only(read_only)),
491 FieldKind::Integer => Box::new(leaving_number(
492 NumberField::new(number_binding(value)).label(request.caption.clone()).read_only(read_only),
493 request,
494 )),
495 FieldKind::Decimal(places) => Box::new(leaving_number(
496 NumberField::new(number_binding(value))
497 .decimals(usize::from(*places))
498 .label(request.caption.clone())
499 .read_only(read_only),
500 request,
501 )),
502 FieldKind::Money => Box::new(leaving_number(
503 NumberField::money(cents_binding(value), request.session.words().money_prefix.clone())
504 .placeholder(request.field.placeholder.clone().unwrap_or_default())
505 .label(request.caption.clone())
506 .read_only(read_only),
507 request,
508 )),
509 FieldKind::Date => {
510 let (read, write) = (value.clone(), value.clone());
511 let date = Binding::new(move || date_of(&read.get()), move |d| write.set(value_of_date(d)));
512 Box::new(
513 DatePicker::new(date, today(&request.session))
514 .fill()
515 .label(request.caption.clone())
516 .read_only(read_only),
517 )
518 }
519 FieldKind::DateTime => {
520 let (read, write) = (value.clone(), value.clone());
521 let moment =
522 Binding::new(move || date_time_of(&read.get()), move |m| write.set(value_of_date_time(m)));
523 Box::new(
524 DateTimePicker::new(moment, today(&request.session))
525 .label(request.caption.clone())
526 .read_only(read_only),
527 )
528 }
529 FieldKind::Boolean => {
530 let (read, write) = (value.clone(), value.clone());
531 let on = Binding::new(
532 move || matches!(read.get(), Value::Bool(true)),
533 move |on: bool| write.set(Value::Bool(on)),
534 );
535 Box::new(Checkbox::new(request.caption.clone(), on).read_only(read_only))
536 }
537 FieldKind::Enum(options) => enum_editor(request, options),
538 FieldKind::Reference(target) => reference_editor(request, target),
539 FieldKind::Color => {
540 let (read, write) = (value.clone(), value.clone());
541 let color = Binding::new(
542 move || if let Value::Text(hex) = read.get() { parse_color(&hex) } else { None },
543 move |color: Option<Color>| {
544 write.set(color.map_or(Value::Null, |c| Value::Text(color_text(c))));
545 },
546 );
547 Box::new(ColorPicker::new(color).label(request.caption.clone()).read_only(read_only))
548 }
549 FieldKind::Image | FieldKind::File => Box::new(FileEditor(request.clone())),
550 _ => read_only_text(request),
551 }
552}
553
554fn parse_color(hex: &str) -> Option<Color> {
555 let digits = hex.trim().trim_start_matches('#');
556 (digits.len() == 6).then(|| u32::from_str_radix(digits, 16).ok().map(Color::hex)).flatten()
557}
558
559fn color_text(color: Color) -> String {
560 let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8;
561 format!("#{:02X}{:02X}{:02X}", byte(color.r), byte(color.g), byte(color.b))
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn colors_are_kept_as_text() {
570 assert_eq!(parse_color("#2563EB").map(color_text).as_deref(), Some("#2563EB"));
571 assert_eq!(parse_color("nope"), None);
572 }
573}