Skip to main content

ravenrs_app_ui/
editors.rs

1//! A control for each kind of field.
2
3use 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/// What a control is being asked for.
25#[derive(Clone, Debug)]
26#[non_exhaustive]
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
28pub struct EditorRequest {
29    /// The field it edits.
30    pub field: FieldInfo,
31    /// The value, one state per field, so typing does not touch the whole record.
32    pub value: State<Value>,
33    /// What people read as its name.
34    pub caption: String,
35    /// It shows but does not change.
36    pub read_only: bool,
37    /// The application it lives in.
38    pub session: Session,
39}
40
41type EditorFactory = Rc<dyn Fn(&EditorRequest) -> AnyView>;
42
43/// Which control edits which kind of field, by name. The model's `Editor` setting
44/// picks one, so a field can change its control without touching code.
45#[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    /// Registers a control by name, replacing one of the same name. The name of a
58    /// kind of field, such as `Text`, replaces the built-in for it.
59    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    /// The control for a request, by the name given or else by the kind of its field.
68    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
126/// Which option a field holds, by its place in the list.
127fn 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
142/// What names a record, for lists to pick from.
143fn 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
203/// A value as a literal of the expression language.
204pub(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
215/// What the records of a reference have to meet, given the record being edited.
216fn 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
221/// An expression where `?field` takes the value of that field of a record.
222pub(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
231/// A reference picked from a list of all its choices, for the few records of a register.
232fn 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    // What it held is no longer among the choices, as when a filter changed with another field.
257    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
272/// How far along something is, from 0 to 100, which only shows.
273fn 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
282/// An expression over the fields of an entity, put together by picking.
283fn 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
303/// Several options at once, kept as their codes separated by commas.
304fn 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
324/// Text picked from values ready for it. What it held before stays among them.
325fn 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
346/// Text typed through a mask.
347fn 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
369/// What shows a value with no control of its own: its text, which cannot be changed.
370fn read_only_text(request: &EditorRequest) -> AnyView {
371    let text = display(&request.field, &request.value.get());
372    let decimal = request.session.words().decimal;
373    // Only numbers with a format of their own are written here, with a point.
374    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
382/// A picture or a file on a form: taking one, and taking it out again.
383struct 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
455/// What tells a session the keyboard left a field, when its form checks on leaving.
456fn 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
466/// A number field that says when it is left.
467fn 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}