Skip to main content

ravenrs_app_ui/
detail_view.rs

1//! One record: a form laid out as the model says.
2
3use std::rc::Rc;
4
5use ravenrs_app::{
6    ActionCategory, AppearanceTarget, ModelNode, Object, RuleKind, Severity, ViewInfo, Violation,
7};
8use ravenrs_app_a11y::AccessibleText;
9use ravenrs_layout::Align;
10use ravenrs_widgets::{
11    AccessibleControl, Alert, AnyView, BuildCx, FormField, FormGroup, FormLayout, NodeId, Text, Tone, VStack,
12    View,
13};
14
15use crate::editors::EditorRequest;
16use crate::session::{FormState, Session};
17use crate::values::semantic_of;
18
19/// The form of the record being edited. What each field uses comes from the model's `Editor`.
20#[derive(Debug)]
21#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
22pub struct DetailView {
23    session: Session,
24    view: ViewInfo,
25}
26
27#[cfg_attr(feature = "live", ravenrs_live::expose)]
28impl DetailView {
29    /// The form of this view in the model.
30    pub fn new(session: Session, view: ViewInfo) -> Self {
31        Self { session, view }
32    }
33
34    fn editor(
35        &self,
36        form: &Rc<FormState>,
37        node: &ModelNode,
38        field: &str,
39        state: &FormLook,
40        named_around: bool,
41    ) -> Option<AnyView> {
42        let info = form.entity.field_named(field)?;
43        let item = node.at(&["Items", field]);
44        let caption = item.and_then(|n| n.text("Caption")).unwrap_or(&info.caption).to_owned();
45        let caption =
46            self.session.form_caption().and_then(|words| words(field, &state.record)).unwrap_or(caption);
47        let look = form.entity.appearance_of(AppearanceTarget::Item, field, &self.view, Some(&state.record));
48        let around =
49            form.entity.appearance_of(AppearanceTarget::Layout, field, &self.view, Some(&state.record));
50        if look.visible == Some(false) || around.visible == Some(false) {
51            return None;
52        }
53        if let ravenrs_app::FieldKind::Collection(target) = &info.kind {
54            if let Some(source) = self.session.collection_source(&form.entity.name, field) {
55                let record = form.current();
56                let list = crate::nested::NestedList::given(
57                    self.session.clone(),
58                    target,
59                    Rc::new(move || source(&record)),
60                );
61                return Some(if named_around {
62                    Box::new(list) as AnyView
63                } else {
64                    Box::new(FormField::new(caption, list))
65                });
66            }
67            let key = form.entity.key_field().map(|key| form.original.get(&key.name));
68            let list = crate::nested::NestedList::new(
69                self.session.clone(),
70                &form.entity,
71                (!form.is_new).then_some(key).flatten(),
72                target,
73                info.back_reference.as_deref(),
74                info.lookup_filter.as_deref(),
75            );
76            return Some(if named_around {
77                Box::new(list) as AnyView
78            } else {
79                Box::new(FormField::new(caption, list))
80            });
81        }
82        let mut read_only = item.and_then(|n| n.flag("ReadOnly")).unwrap_or(info.read_only);
83        if let Some(security) = self.session.frame().security() {
84            let (name, record) = (&form.entity.name, Some(&state.record));
85            let (can_read, can_write) = security.with(|p| {
86                (
87                    p.can_field(ravenrs_app::Operation::Read, name, field, record),
88                    p.can_field(ravenrs_app::Operation::Write, name, field, record),
89                )
90            });
91            if !can_read && !(form.is_new && can_write) {
92                let shown = Text::new(self.session.words().protected_value.clone());
93                return Some(Box::new(FormField::new(caption, shown)));
94            }
95            read_only |= !can_write;
96        }
97        let value = match form.fields.get(field) {
98            Some(value) => value.clone(),
99            None => {
100                // A calculated field follows the others.
101                let mut current = state.record.clone();
102                form.entity.compute(&mut current, &ravenrs_app::environment());
103                ravenrs_reactive::State::new(current.get(field))
104            }
105        };
106        let request = EditorRequest {
107            field: info.clone(),
108            value,
109            caption: caption.clone(),
110            read_only: read_only || (info.is_key && !form.is_new) || look.enabled == Some(false),
111            session: self.session.clone(),
112        };
113        let name = item.and_then(|n| n.text("Editor")).unwrap_or("");
114        let editor = self.session.editors().editor(name, &request);
115        let spoken = AccessibleText::of_field(info, item, &caption);
116        // A check box says its own name.
117        if matches!(info.kind, ravenrs_app::FieldKind::Boolean) {
118            let (error, note) = (state.error_of(field), state.note_of(field));
119            if error.is_none() && note.is_none() {
120                if !spoken.says_more() {
121                    return Some(editor);
122                }
123                let mut named = AccessibleControl::new(editor).name(spoken.name);
124                if let Some(description) = spoken.description {
125                    named = named.description(description);
126                }
127                return Some(Box::new(named));
128            }
129            let (note, tone) = note.map_or((None, Tone::Neutral), |(note, tone)| (Some(note), tone));
130            let wrapped = FormField::new(caption, editor).error_text(error).note_text(note, tone);
131            return Some(Box::new(Self::spoken(wrapped, spoken)));
132        }
133        let required = form
134            .entity
135            .rules
136            .iter()
137            .any(|rule| rule.field.as_deref() == Some(field) && matches!(rule.kind, RuleKind::Required));
138        let mut wrapped = FormField::new(caption, editor).error_text(state.error_of(field));
139        if let Some((note, tone)) = state.note_of(field) {
140            wrapped = wrapped.note_text(Some(note), tone);
141        }
142        if let Some(hint) = &info.hint {
143            wrapped = wrapped.hint(hint.clone());
144        }
145        if required {
146            wrapped = wrapped.required();
147        }
148        if let Some(tint) = look.font.or(around.font) {
149            wrapped = wrapped.label_ink(semantic_of(tint));
150        }
151        if let Some(tint) = look.back.or(around.back) {
152            wrapped = wrapped.tint(semantic_of(tint));
153        }
154        Some(Box::new(Self::spoken(wrapped, spoken)))
155    }
156
157    /// A field as screen readers are to hear it, when the model says more than its label.
158    fn spoken(field: FormField<AnyView>, spoken: AccessibleText) -> FormField<AnyView> {
159        let field = if spoken.renamed { field.accessible_name(spoken.name) } else { field };
160        match spoken.description {
161            Some(description) => field.accessible_description(description),
162            None => field,
163        }
164    }
165
166    fn group(
167        &self,
168        form: &Rc<FormState>,
169        node: &ModelNode,
170        source: &ModelNode,
171        state: &FormLook,
172        in_tab: bool,
173    ) -> FormGroup {
174        let columns = source.integer("Columns").unwrap_or(1).max(1) as usize;
175        // The tab already says it.
176        let title = source.text("Caption").filter(|_| !in_tab);
177        let mut group = match title {
178            Some(title) => FormGroup::titled(title),
179            None => FormGroup::new(),
180        }
181        .columns(columns);
182        for item in source.in_order() {
183            if item.text("Kind") == Some("ActionContainer") {
184                let name = item.text("Category").unwrap_or(&item.id).to_owned();
185                group = group
186                    .wide(crate::ActionContainer::new(self.session.clone(), [ActionCategory::InForm(name)]));
187                continue;
188            }
189            let field = item.text("Field").unwrap_or(&item.id);
190            let tall = item.flag("Wide") == Some(true)
191                || node.at(&["Items", field]).and_then(|n| n.flag("Wide")) == Some(true)
192                || form.entity.field_named(field).is_some_and(|f| {
193                    matches!(
194                        f.kind,
195                        ravenrs_app::FieldKind::LongText
196                            | ravenrs_app::FieldKind::Image
197                            | ravenrs_app::FieldKind::Collection(_)
198                    )
199                });
200            let named_around =
201                in_tab || form.entity.field_named(field).is_some_and(|f| title == Some(f.caption.as_str()));
202            if let Some(editor) = self.editor(form, node, field, state, named_around) {
203                group = if tall { group.wide(editor) } else { group.field(editor) };
204            }
205        }
206        group
207    }
208
209    fn shows(&self, form: &FormState, part: &ModelNode, state: &FormLook) -> bool {
210        form.entity.appearance_of(AppearanceTarget::Layout, &part.id, &self.view, Some(&state.record)).visible
211            != Some(false)
212    }
213}
214
215fn tone_of(severity: Severity) -> Tone {
216    if severity == Severity::Warning { Tone::Warning } else { Tone::Accent }
217}
218
219/// What the rules see in this build of a form.
220struct FormLook {
221    record: Object,
222    violations: Vec<Violation>,
223}
224
225#[cfg_attr(feature = "live", ravenrs_live::expose)]
226impl FormLook {
227    fn error_of(&self, field: &str) -> Option<String> {
228        self.violations
229            .iter()
230            .find(|v| v.is_error() && v.field.as_deref() == Some(field))
231            .map(|v| v.message.clone())
232    }
233
234    /// What the rules only want known about a field.
235    fn note_of(&self, field: &str) -> Option<(String, Tone)> {
236        self.violations
237            .iter()
238            .find(|v| !v.is_error() && v.field.as_deref() == Some(field))
239            .map(|v| (v.message.clone(), tone_of(v.severity)))
240    }
241}
242
243impl View for DetailView {
244    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
245        let session = &self.session;
246        // The model can change under a form, as with another language.
247        session.revision();
248        let model = session.model();
249        let Some(node) = model.at(&["Views", &self.view.id]) else {
250            return cx.child(&Text::new(self.view.id.clone()));
251        };
252        let Some(form) = session.form() else {
253            return cx.empty();
254        };
255        let state = FormLook { record: form.watched(), violations: session.form_violations() };
256        let lists_all = session.lists_field_errors();
257        let messages: Vec<String> = state
258            .violations
259            .iter()
260            .filter(|v| v.is_error() && (lists_all || v.field.is_none()))
261            .map(|v| v.message.clone())
262            .collect();
263        let notes: Vec<&Violation> =
264            state.violations.iter().filter(|v| !v.is_error() && v.field.is_none()).collect();
265        let note = (!notes.is_empty()).then(|| {
266            let text: Vec<&str> = notes.iter().map(|v| v.message.as_str()).collect();
267            let warns = notes.iter().any(|v| v.severity == Severity::Warning);
268            Alert::message(text.join("\n")).tone(if warns { Tone::Warning } else { Tone::Accent })
269        });
270        let mut layout = FormLayout::new().errors(messages);
271        if let Some(arrangement) = node.get("Layout") {
272            for part in arrangement.in_order() {
273                if !self.shows(&form, part, &state) {
274                    continue;
275                }
276                if part.text("Kind") == Some("Tabs") {
277                    for tab in part.in_order().into_iter().filter(|tab| self.shows(&form, tab, &state)) {
278                        let title = tab.text("Caption").unwrap_or(&tab.id).to_owned();
279                        layout = layout
280                            .tab(title, FormLayout::new().group(self.group(&form, node, tab, &state, true)));
281                    }
282                } else {
283                    layout = layout.group(self.group(&form, node, part, &state, false));
284                }
285            }
286        }
287        match note {
288            Some(note) => cx.child(&VStack::new((note, layout)).spacing(12.0).align(Align::Stretch)),
289            None => cx.child(&layout),
290        }
291    }
292}