1use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::Insets;
7use ravenrs_criteria::{BinaryOp, Criteria, Function, GroupOp, UnaryOp, Value as Literal};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10
11use crate::context::{BuildCx, NodeId, Paint};
12use crate::{
13 Button, ButtonSize, ButtonStyle, IconButton, IconName, LookupItem, Select, TagBox, TextField, View,
14};
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18#[non_exhaustive]
19#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
20pub enum FilterFieldKind {
21 #[default]
23 Text,
24 Number,
26 Boolean,
28 Date,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
35pub struct FilterField {
36 pub name: String,
38 pub caption: String,
40 pub kind: FilterFieldKind,
42 pub values: Vec<LookupItem>,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl FilterField {
49 pub fn new(name: impl Into<String>, caption: impl Into<String>, kind: FilterFieldKind) -> Self {
51 Self { name: name.into(), caption: caption.into(), kind, values: Vec::new() }
52 }
53
54 pub fn values(mut self, values: impl IntoIterator<Item = LookupItem>) -> Self {
56 self.values = values.into_iter().collect();
57 self
58 }
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62enum Op {
63 Equal,
64 NotEqual,
65 Greater,
66 GreaterOrEqual,
67 Less,
68 LessOrEqual,
69 Contains,
70 StartsWith,
71 AnyOf,
72 NoneOf,
73 IsEmpty,
74 IsNotEmpty,
75}
76
77const ALL_OPS: [Op; 12] = [
78 Op::Equal,
79 Op::NotEqual,
80 Op::Greater,
81 Op::GreaterOrEqual,
82 Op::Less,
83 Op::LessOrEqual,
84 Op::Contains,
85 Op::StartsWith,
86 Op::AnyOf,
87 Op::NoneOf,
88 Op::IsEmpty,
89 Op::IsNotEmpty,
90];
91
92#[cfg_attr(feature = "live", ravenrs_live::expose)]
93impl Op {
94 fn takes_value(self) -> bool {
95 !matches!(self, Op::IsEmpty | Op::IsNotEmpty)
96 }
97
98 fn takes_list(self) -> bool {
100 matches!(self, Op::AnyOf | Op::NoneOf)
101 }
102
103 fn fits(self, kind: FilterFieldKind) -> bool {
104 match kind {
105 FilterFieldKind::Text => true,
106 FilterFieldKind::Boolean => {
107 matches!(self, Op::Equal | Op::NotEqual | Op::IsEmpty | Op::IsNotEmpty)
108 }
109 _ => !matches!(self, Op::Contains | Op::StartsWith),
110 }
111 }
112
113 fn label(self, labels: &crate::Labels) -> &str {
114 match self {
115 Op::Equal => &labels.filter_equal,
116 Op::NotEqual => &labels.filter_not_equal,
117 Op::Greater => &labels.filter_greater,
118 Op::GreaterOrEqual => &labels.filter_greater_or_equal,
119 Op::Less => &labels.filter_less,
120 Op::LessOrEqual => &labels.filter_less_or_equal,
121 Op::Contains => &labels.filter_contains,
122 Op::StartsWith => &labels.filter_starts_with,
123 Op::AnyOf => &labels.filter_any_of,
124 Op::NoneOf => &labels.filter_none_of,
125 Op::IsEmpty => &labels.filter_is_empty,
126 Op::IsNotEmpty => &labels.filter_is_not_empty,
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq)]
135enum Node {
136 Condition { field: String, op: Op, value: String, values: Vec<String> },
137 Raw(String),
138 Group { all: bool, items: Vec<Node> },
139}
140
141fn literal_text(value: &Literal) -> Option<String> {
142 match value {
143 Literal::Text(text) => Some(text.clone()),
144 Literal::Number(n) => Some(n.to_string()),
145 Literal::Bool(b) => Some(b.to_string()),
146 Literal::Date(date) => Some(date.to_string()),
147 _ => None,
148 }
149}
150
151fn field_name(criteria: &Criteria) -> Option<String> {
152 if let Criteria::Property(path) = criteria { Some(path.join(".")) } else { None }
153}
154
155fn read_node(criteria: &Criteria) -> Node {
156 let condition = |field: &Criteria, op: Op, value: &Criteria| -> Option<Node> {
157 let Criteria::Value(value) = value else { return None };
158 Some(Node::Condition {
159 field: field_name(field)?,
160 op,
161 value: literal_text(value)?,
162 values: Vec::new(),
163 })
164 };
165 let list = |field: &Criteria, op: Op, items: &[Criteria]| -> Option<Node> {
166 let values = items
167 .iter()
168 .map(|item| if let Criteria::Value(value) = item { literal_text(value) } else { None })
169 .collect::<Option<Vec<String>>>()?;
170 Some(Node::Condition { field: field_name(field)?, op, value: String::new(), values })
171 };
172 let read = match criteria {
173 Criteria::Group(op, items) => {
174 return Node::Group { all: *op == GroupOp::And, items: items.iter().map(read_node).collect() };
175 }
176 Criteria::Binary(op, field, value) => {
177 let op = match op {
178 BinaryOp::Eq => Some(Op::Equal),
179 BinaryOp::Ne => Some(Op::NotEqual),
180 BinaryOp::Gt => Some(Op::Greater),
181 BinaryOp::Ge => Some(Op::GreaterOrEqual),
182 BinaryOp::Lt => Some(Op::Less),
183 BinaryOp::Le => Some(Op::LessOrEqual),
184 _ => None,
185 };
186 op.and_then(|op| condition(field, op, value))
187 }
188 Criteria::Function(function, arguments) => match (function, arguments.as_slice()) {
189 (Function::Contains, [field, value]) => condition(field, Op::Contains, value),
190 (Function::StartsWith, [field, value]) => condition(field, Op::StartsWith, value),
191 _ => None,
192 },
193 Criteria::In(field, items) => list(field, Op::AnyOf, items),
194 Criteria::IsNull(field) => field_name(field).map(|field| Node::Condition {
195 field,
196 op: Op::IsEmpty,
197 value: String::new(),
198 values: Vec::new(),
199 }),
200 Criteria::Unary(UnaryOp::Not, inner) => match inner.as_ref() {
201 Criteria::IsNull(field) => field_name(field).map(|field| Node::Condition {
202 field,
203 op: Op::IsNotEmpty,
204 value: String::new(),
205 values: Vec::new(),
206 }),
207 Criteria::In(field, items) => list(field, Op::NoneOf, items),
208 _ => None,
209 },
210 _ => None,
211 };
212 read.unwrap_or_else(|| Node::Raw(criteria.to_string()))
213}
214
215fn read_filter(text: &str) -> Node {
218 if text.trim().is_empty() {
219 return Node::Group { all: true, items: Vec::new() };
220 }
221 match Criteria::parse(text) {
222 Ok(criteria) => match read_node(&criteria) {
223 group @ Node::Group { .. } => group,
224 line => Node::Group { all: true, items: vec![line] },
225 },
226 Err(_) => Node::Group { all: true, items: vec![Node::Raw(text.to_owned())] },
227 }
228}
229
230fn write_value(value: &str, kind: FilterFieldKind) -> Option<String> {
231 let value = value.trim();
232 match kind {
233 FilterFieldKind::Text => Some(format!("'{}'", value.replace('\'', "''"))),
234 FilterFieldKind::Number => {
235 value.replace(',', ".").parse::<f64>().ok().filter(|n| n.is_finite()).map(|n| n.to_string())
236 }
237 FilterFieldKind::Boolean => match value.to_lowercase().as_str() {
238 "true" => Some("True".to_owned()),
239 "false" => Some("False".to_owned()),
240 _ => None,
241 },
242 FilterFieldKind::Date => {
243 let looks_right = value.len() >= 8 && value.chars().all(|c| c.is_ascii_digit() || c == '-');
244 looks_right.then(|| format!("#{value}#"))
245 }
246 }
247}
248
249fn write_node(node: &Node, fields: &[FilterField]) -> Option<String> {
251 match node {
252 Node::Raw(text) => Some(text.trim().to_owned()).filter(|t| !t.is_empty()),
253 Node::Condition { field, op, value, values } => {
254 let kind = fields.iter().find(|f| f.name == *field).map_or(FilterFieldKind::Text, |f| f.kind);
255 let written = if op.takes_list() {
256 let list = values.iter().map(|v| write_value(v, kind)).collect::<Option<Vec<String>>>()?;
257 if list.is_empty() {
258 return None;
259 }
260 list.join(", ")
261 } else if op.takes_value() {
262 if value.trim().is_empty() {
263 return None;
264 }
265 write_value(value, kind)?
266 } else {
267 String::new()
268 };
269 Some(match op {
270 Op::Equal => format!("[{field}] = {written}"),
271 Op::NotEqual => format!("[{field}] <> {written}"),
272 Op::Greater => format!("[{field}] > {written}"),
273 Op::GreaterOrEqual => format!("[{field}] >= {written}"),
274 Op::Less => format!("[{field}] < {written}"),
275 Op::LessOrEqual => format!("[{field}] <= {written}"),
276 Op::Contains => format!("Contains([{field}], {written})"),
277 Op::StartsWith => format!("StartsWith([{field}], {written})"),
278 Op::AnyOf => format!("[{field}] In ({written})"),
279 Op::NoneOf => format!("Not [{field}] In ({written})"),
280 Op::IsEmpty => format!("[{field}] Is Null"),
281 Op::IsNotEmpty => format!("Not [{field}] Is Null"),
282 })
283 }
284 Node::Group { all, items } => {
285 let parts: Vec<String> = items
286 .iter()
287 .filter_map(|item| {
288 let text = write_node(item, fields)?;
289 let nested = matches!(item, Node::Group { items, .. } if items.len() > 1);
290 Some(if nested { format!("({text})") } else { text })
291 })
292 .collect();
293 if parts.is_empty() { None } else { Some(parts.join(if *all { " And " } else { " Or " })) }
294 }
295 }
296}
297
298fn node_at<'a>(root: &'a mut Node, path: &[usize]) -> Option<&'a mut Node> {
300 let mut node = root;
301 for &i in path {
302 let Node::Group { items, .. } = node else { return None };
303 node = items.get_mut(i)?;
304 }
305 Some(node)
306}
307
308#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
325pub struct FilterBuilder {
326 value: Binding<String>,
327 fields: Rc<Vec<FilterField>>,
328}
329
330impl fmt::Debug for FilterBuilder {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 f.debug_struct("FilterBuilder").field("fields", &self.fields).finish_non_exhaustive()
333 }
334}
335
336#[cfg_attr(feature = "live", ravenrs_live::expose)]
337impl FilterBuilder {
338 pub fn new(value: Binding<String>, fields: impl IntoIterator<Item = FilterField>) -> Self {
340 Self { value, fields: Rc::new(fields.into_iter().collect()) }
341 }
342}
343
344#[derive(Clone)]
346struct Editor {
347 tree: State<Node>,
348 value: Binding<String>,
349 fields: Rc<Vec<FilterField>>,
350}
351
352#[cfg_attr(feature = "live", ravenrs_live::expose)]
353impl Editor {
354 fn change(&self, path: &[usize], f: impl FnOnce(&mut Node)) {
355 let mut tree = self.tree.get();
356 if let Some(node) = node_at(&mut tree, path) {
357 f(node);
358 }
359 let text = write_node(&tree, &self.fields).unwrap_or_default();
360 self.tree.set(tree);
361 if text != self.value.get() {
362 self.value.set(text);
363 }
364 }
365
366 fn remove(&self, path: &[usize]) {
367 let Some((last, parent)) = path.split_last() else { return };
368 self.change(parent, |node| {
369 if let Node::Group { items, .. } = node
370 && *last < items.len()
371 {
372 items.remove(*last);
373 }
374 });
375 }
376
377 fn new_line(&self) -> Node {
378 let first = self.fields.first();
379 Node::Condition {
380 field: first.map_or_else(String::new, |f| f.name.clone()),
381 op: if first.is_some_and(|f| f.kind == FilterFieldKind::Text) { Op::Contains } else { Op::Equal },
382 value: String::new(),
383 values: Vec::new(),
384 }
385 }
386}
387
388fn row_style() -> LayoutStyle {
389 LayoutStyle {
390 spacing: 8.0,
391 align: Align::Center,
392 align_self: Some(Align::Stretch),
393 wrap: true,
394 ..LayoutStyle::stack(Axis::Horizontal)
395 }
396}
397
398fn remove_button(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize]) -> NodeId {
399 let (editor, path) = (editor.clone(), path.to_vec());
400 cx.child(
401 &IconButton::new(IconName::X)
402 .label(cx.labels().filter_remove.clone())
403 .size(ButtonSize::Small)
404 .on_click(move || editor.remove(&path)),
405 )
406}
407
408fn build_condition(
409 cx: &mut BuildCx<'_>,
410 editor: &Editor,
411 path: &[usize],
412 field: &str,
413 op: Op,
414 value: &str,
415 values: &[String],
416) -> NodeId {
417 let labels = cx.labels();
418 let fields = editor.fields.clone();
419 let at = fields.iter().position(|f| f.name == field);
420 let kind = at.map_or(FilterFieldKind::Text, |i| fields[i].kind);
421
422 let mut children = Vec::new();
423 let mut names: Vec<String> = fields.iter().map(|f| f.caption.clone()).collect();
425 if at.is_none() {
426 names.push(field.to_owned());
427 }
428 let chosen = at.unwrap_or(names.len() - 1);
429 let (set, there, offered) = (editor.clone(), path.to_vec(), fields.clone());
430 children.push(
431 cx.child(
432 &Select::new(
433 Binding::new(
434 move || chosen,
435 move |i: usize| {
436 let Some(next) = offered.get(i).cloned() else { return };
437 set.change(&there, |node| {
438 if let Node::Condition { field, op, value, .. } = node {
439 *field = next.name.clone();
440 if !op.fits(next.kind) {
441 *op = Op::Equal;
442 }
443 if next.kind == FilterFieldKind::Boolean {
444 *value = "true".to_owned();
445 } else if value == "true" || value == "false" {
446 value.clear();
447 }
448 }
449 });
450 },
451 ),
452 names,
453 )
454 .label(labels.filter_field.clone())
455 .width(170.0),
456 ),
457 );
458
459 let ops: Vec<Op> = ALL_OPS.iter().copied().filter(|o| o.fits(kind) || *o == op).collect();
460 let current = ops.iter().position(|o| *o == op).unwrap_or(0);
461 let names: Vec<String> = ops.iter().map(|o| o.label(&labels).to_owned()).collect();
462 let (set, there) = (editor.clone(), path.to_vec());
463 children.push(
464 cx.child(
465 &Select::new(
466 Binding::new(
467 move || current,
468 move |i: usize| {
469 let Some(next) = ops.get(i).copied() else { return };
470 set.change(&there, |node| {
471 if let Node::Condition { op, value, values, .. } = node {
472 if next.takes_list() && !op.takes_list() {
474 *values = Some(value.trim().to_owned())
475 .filter(|v| !v.is_empty())
476 .into_iter()
477 .collect();
478 } else if !next.takes_list() && op.takes_list() {
479 *value = values.first().cloned().unwrap_or_default();
480 }
481 *op = next;
482 }
483 });
484 },
485 ),
486 names,
487 )
488 .label(labels.filter_comparison.clone())
489 .width(170.0),
490 ),
491 );
492
493 if op.takes_list() {
494 let (set, there, shown) = (editor.clone(), path.to_vec(), values.to_vec());
495 let chosen = Binding::new(
496 move || shown.clone(),
497 move |picked: Vec<String>| {
498 set.change(&there, |node| {
499 if let Node::Condition { values, .. } = node {
500 *values = picked;
501 }
502 });
503 },
504 );
505 let options = at.map(|i| fields[i].values.clone()).unwrap_or_default();
506 children.push(
507 cx.child(
508 &TagBox::new(chosen, options)
509 .allow_new(true)
510 .placeholder(labels.filter_value.clone())
511 .label(labels.filter_value.clone())
512 .width(190.0),
513 ),
514 );
515 } else if op.takes_value() {
516 let (set, there) = (editor.clone(), path.to_vec());
517 let shown = value.to_owned();
518 if kind == FilterFieldKind::Boolean {
519 children.push(
520 cx.child(
521 &Select::new(
522 Binding::new(
523 move || usize::from(shown != "true"),
524 move |i: usize| {
525 set.change(&there, |node| {
526 if let Node::Condition { value, .. } = node {
527 *value = if i == 0 { "true" } else { "false" }.to_owned();
528 }
529 });
530 },
531 ),
532 [labels.filter_yes.clone(), labels.filter_no.clone()],
533 )
534 .label(labels.filter_value.clone())
535 .width(120.0),
536 ),
537 );
538 } else {
539 let text = Binding::new(
540 move || shown.clone(),
541 move |text: String| {
542 set.change(&there, |node| {
543 if let Node::Condition { value, .. } = node {
544 *value = text;
545 }
546 });
547 },
548 );
549 children
550 .push(cx.child(&TextField::new(text).placeholder(labels.filter_value.clone()).width(180.0)));
551 }
552 }
553 children.push(remove_button(cx, editor, path));
554 cx.container(row_style(), &children, Paint::None)
555}
556
557fn build_raw(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize], text: &str) -> NodeId {
558 let (set, there, shown) = (editor.clone(), path.to_vec(), text.to_owned());
559 let binding = Binding::new(
560 move || shown.clone(),
561 move |text: String| {
562 set.change(&there, |node| *node = Node::Raw(text));
563 },
564 );
565 let field = cx.child(&TextField::new(binding).placeholder(cx.labels().filter_expression.clone()));
566 let field = cx.container(
567 LayoutStyle {
568 grow: 1.0,
569 min_width: Some(0.0),
570 align: Align::Stretch,
571 ..LayoutStyle::stack(Axis::Vertical)
572 },
573 &[field],
574 Paint::None,
575 );
576 let remove = remove_button(cx, editor, path);
577 cx.container(row_style(), &[field, remove], Paint::None)
578}
579
580fn build_group(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize], all: bool, items: &[Node]) -> NodeId {
581 let labels = cx.labels();
582 let (set, there) = (editor.clone(), path.to_vec());
583 let mode = cx.child(
584 &Select::new(
585 Binding::new(
586 move || usize::from(!all),
587 move |i: usize| {
588 set.change(&there, |node| {
589 if let Node::Group { all, .. } = node {
590 *all = i == 0;
591 }
592 });
593 },
594 ),
595 [labels.filter_all.clone(), labels.filter_any.clone()],
596 )
597 .label(labels.filter_match.clone())
598 .width(200.0),
599 );
600 let (set, there) = (editor.clone(), path.to_vec());
601 let add = cx.child(
602 &Button::new(labels.filter_add_condition.clone())
603 .style(ButtonStyle::Ghost)
604 .size(ButtonSize::Small)
605 .icon(IconName::Plus)
606 .on_click(move || {
607 let line = set.new_line();
608 set.change(&there, |node| {
609 if let Node::Group { items, .. } = node {
610 items.push(line);
611 }
612 });
613 }),
614 );
615 let (set, there) = (editor.clone(), path.to_vec());
616 let add_group = cx.child(
617 &Button::new(labels.filter_add_group.clone())
618 .style(ButtonStyle::Ghost)
619 .size(ButtonSize::Small)
620 .icon(IconName::Plus)
621 .on_click(move || {
622 let line = set.new_line();
623 set.change(&there, |node| {
624 if let Node::Group { items, .. } = node {
625 items.push(Node::Group { all: true, items: vec![line] });
626 }
627 });
628 }),
629 );
630 let mut heading = vec![mode, add, add_group];
631 if !path.is_empty() {
632 heading.push(remove_button(cx, editor, path));
633 }
634 let heading = cx.container(row_style(), &heading, Paint::None);
635 let mut lines = vec![heading];
636 for (i, item) in items.iter().enumerate() {
637 let mut child = path.to_vec();
638 child.push(i);
639 lines.push(match item {
640 Node::Condition { field, op, value, values } => {
641 build_condition(cx, editor, &child, field, *op, value, values)
642 }
643 Node::Raw(text) => build_raw(cx, editor, &child, text),
644 Node::Group { all, items } => build_group(cx, editor, &child, *all, items),
645 });
646 }
647 cx.container(
648 LayoutStyle {
649 spacing: 8.0,
650 padding: Insets { left: if path.is_empty() { 0.0 } else { 16.0 }, ..Insets::default() },
651 align: Align::Stretch,
652 align_self: Some(Align::Stretch),
653 ..LayoutStyle::stack(Axis::Vertical)
654 },
655 &lines,
656 Paint::None,
657 )
658}
659
660impl View for FilterBuilder {
661 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
662 let text = self.value.get();
663 let tree: State<Node> = {
664 let first = text.clone();
665 cx.use_state(move || read_filter(&first))
666 };
667 let mine = write_node(&tree.peek(), &self.fields).unwrap_or_default();
669 if mine != text {
670 tree.set(read_filter(&text));
671 }
672 let editor = Editor { tree: tree.clone(), value: self.value.clone(), fields: self.fields.clone() };
673 let Node::Group { all, items } = tree.get() else { return cx.empty() };
674 build_group(cx, &editor, &[], all, &items)
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn fields() -> Vec<FilterField> {
683 vec![
684 FilterField::new("Name", "Name", FilterFieldKind::Text),
685 FilterField::new("Price", "Price", FilterFieldKind::Number),
686 FilterField::new("Active", "Active", FilterFieldKind::Boolean),
687 FilterField::new("Due", "Due", FilterFieldKind::Date),
688 ]
689 }
690
691 fn round_trip(text: &str) -> String {
693 let written = write_node(&read_filter(text), &fields()).unwrap_or_default();
694 if !written.is_empty() {
695 assert!(Criteria::parse(&written).is_ok(), "{written}");
696 }
697 written
698 }
699
700 #[test]
701 fn lines_read_and_write_the_language() {
702 for text in [
703 "[Price] > 10",
704 "[Price] <= 2.5",
705 "[Name] = 'Acme'",
706 "[Name] <> 'It''s'",
707 "Contains([Name], 'ac')",
708 "StartsWith([Name], 'ac')",
709 "[Name] Is Null",
710 "Not [Name] Is Null",
711 "[Active] = True",
712 "[Due] >= #2026-11-02#",
713 "[Name] In ('Acme', 'It''s')",
714 "Not [Name] In ('Acme')",
715 "[Price] In (1, 2.5)",
716 "[Due] In (#2026-11-02#)",
717 "[Name] In ('Acme') And [Price] > 1",
718 "[Price] > 1 And [Price] < 5",
719 "[Price] > 1 Or [Name] = 'x'",
720 "[Price] > 1 And ([Name] = 'x' Or [Name] = 'y')",
721 ] {
722 assert_eq!(round_trip(text), text);
723 }
724 assert_eq!(round_trip(""), "");
725 }
726
727 #[test]
728 fn what_cannot_be_a_line_is_kept() {
729 let kept = round_trip("Upper([Name]) = 'X' And [Price] > 1");
730 assert!(kept.contains("Upper"), "{kept}");
731 assert!(kept.contains("[Price] > 1"));
732 let group = read_filter("Upper([Name]) = 'X'");
733 assert!(matches!(&group, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))));
734 let broken = read_filter("[Name] = ");
735 assert!(
736 matches!(&broken, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))),
737 "{broken:?}"
738 );
739 }
740
741 #[test]
742 fn unfinished_lines_say_nothing() {
743 let line = |field: &str, op, value: &str| Node::Condition {
744 field: field.to_owned(),
745 op,
746 value: value.to_owned(),
747 values: Vec::new(),
748 };
749 let tree = Node::Group {
750 all: true,
751 items: vec![
752 line("Name", Op::Contains, ""),
753 line("Price", Op::Greater, "abc"),
754 line("Price", Op::Greater, "1,5"),
755 Node::Group { all: false, items: vec![] },
756 ],
757 };
758 assert_eq!(write_node(&tree, &fields()).as_deref(), Some("[Price] > 1.5"));
759 }
760
761 #[test]
762 fn a_list_reads_as_one_line() {
763 let read = read_filter("[Name] In ('a', 'b')");
764 let Node::Group { items, .. } = &read else { panic!("{read:?}") };
765 assert_eq!(
766 items[0],
767 Node::Condition {
768 field: "Name".to_owned(),
769 op: Op::AnyOf,
770 value: String::new(),
771 values: vec!["a".to_owned(), "b".to_owned()],
772 }
773 );
774 let read = read_filter("[Name] In ([Other], 'b')");
776 assert!(matches!(&read, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))));
777 let empty = Node::Condition {
778 field: "Name".to_owned(),
779 op: Op::AnyOf,
780 value: String::new(),
781 values: Vec::new(),
782 };
783 assert_eq!(write_node(&empty, &fields()), None, "nothing picked says nothing yet");
784 }
785
786 #[test]
787 fn comparisons_fit_the_field() {
788 assert!(Op::AnyOf.fits(FilterFieldKind::Date));
789 assert!(!Op::AnyOf.fits(FilterFieldKind::Boolean));
790 assert!(Op::Contains.fits(FilterFieldKind::Text));
791 assert!(!Op::Contains.fits(FilterFieldKind::Number));
792 assert!(!Op::Greater.fits(FilterFieldKind::Boolean));
793 assert!(Op::IsEmpty.fits(FilterFieldKind::Date));
794 }
795}