Skip to main content

sqlly_datatable/pivot/
sidebar.rs

1//! The pivot configuration sidebar: a field list plus the Rows / Columns /
2//! Values / Filters drop zones, driven by GPUI's native drag-and-drop
3//! (`on_drag` / `drag_over` / `on_drop`).
4//!
5//! The sidebar and the pivot grid share one [`Entity<PivotState>`]: every
6//! drop mutates [`crate::pivot::PivotConfig`] through the same
7//! `move_field` API available to programmatic callers, then triggers
8//! `recompute()`.
9
10use crate::pivot::aggregation::AggregationFn;
11use crate::pivot::config::PivotZone;
12use crate::pivot::state::PivotState;
13
14use gpui::{
15    anchored, deferred, div, point, px, App, AppContext as _, Context, Corner, Entity,
16    InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Render,
17    StatefulInteractiveElement, Styled, Window,
18};
19
20/// Fixed sidebar width in pixels.
21pub const SIDEBAR_WIDTH: f32 = 260.0;
22/// Draw priority for the filter popover overlay (matches the grid's menus).
23const POPOVER_PRIORITY: usize = 1_000_000;
24/// Max checklist rows rendered in the filter popover.
25const FILTER_POPOVER_MAX_ROWS: usize = 200;
26
27/// Payload carried by a field-chip drag.
28struct DraggedField {
29    field: usize,
30}
31
32/// The ghost chip rendered under the cursor during a drag.
33struct DragGhost {
34    label: String,
35    bg: gpui::Hsla,
36    fg: gpui::Hsla,
37    border: gpui::Hsla,
38}
39
40impl Render for DragGhost {
41    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
42        div()
43            .px(px(8.0))
44            .py(px(3.0))
45            .rounded(px(4.0))
46            .bg(self.bg)
47            .border_1()
48            .border_color(self.border)
49            .text_color(self.fg)
50            .text_size(px(12.0))
51            .child(self.label.clone())
52    }
53}
54
55/// Drag-and-drop pivot configuration sidebar. Owns nothing but a handle to
56/// the shared [`PivotState`].
57pub struct PivotSidebar {
58    /// The shared pivot state.
59    pub state: Entity<PivotState>,
60}
61
62impl PivotSidebar {
63    /// Wrap an existing pivot state entity.
64    #[must_use]
65    pub fn new(state: Entity<PivotState>) -> Self {
66        Self { state }
67    }
68
69    /// A draggable field chip. `zone` is `None` for the source field list.
70    #[allow(clippy::too_many_arguments)]
71    fn chip(
72        state: &Entity<PivotState>,
73        id: (&'static str, usize),
74        field: usize,
75        label: String,
76        zone: Option<PivotZone>,
77        removable: bool,
78        marker: Option<&'static str>,
79        cx: &App,
80    ) -> gpui::AnyElement {
81        let s = state.read(cx);
82        let theme = s.theme.clone();
83        let ghost_label = label.clone();
84        let ghost_bg = theme.pivot_chip_bg;
85        let ghost_fg = theme.pivot_chip_fg;
86        let ghost_border = theme.grid_line;
87
88        let state_drop = state.clone();
89        let state_remove = state.clone();
90
91        let mut chip = div()
92            .id(id)
93            .px(px(8.0))
94            .py(px(3.0))
95            .rounded(px(4.0))
96            .bg(theme.pivot_chip_bg)
97            .border_1()
98            .border_color(theme.grid_line)
99            .text_color(theme.pivot_chip_fg)
100            .text_size(px(12.0))
101            .flex()
102            .items_center()
103            .gap(px(6.0))
104            .cursor_pointer()
105            .on_drag(
106                DraggedField { field },
107                move |_drag, _offset, _window, cx| {
108                    cx.new(|_| DragGhost {
109                        label: ghost_label.clone(),
110                        bg: ghost_bg,
111                        fg: ghost_fg,
112                        border: ghost_border,
113                    })
114                },
115            )
116            .child(div().flex_1().child(label));
117
118        if let Some(marker) = marker {
119            chip = chip.child(
120                div()
121                    .text_color(theme.sort_indicator)
122                    .text_size(px(11.0))
123                    .child(marker),
124            );
125        }
126
127        if removable {
128            chip = chip.child(
129                div()
130                    .text_color(theme.muted_text)
131                    .cursor_pointer()
132                    .child("✕")
133                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
134                        cx.stop_propagation();
135                        state_remove.update(cx, |s, cx| {
136                            s.config.remove_field(field);
137                            s.recompute();
138                            cx.notify();
139                        });
140                    }),
141            );
142        }
143
144        // Dropping onto a chip inside a zone inserts the dragged field at
145        // this chip's position (reorder support).
146        if let Some(zone) = zone {
147            chip = chip.on_drop::<DraggedField>(move |dragged, _window, cx| {
148                cx.stop_propagation();
149                let dragged_field = dragged.field;
150                state_drop.update(cx, |s, cx| {
151                    let index = s.config.fields_in(zone).iter().position(|&f| f == field);
152                    s.config.move_field(dragged_field, zone, index);
153                    s.recompute();
154                    cx.notify();
155                });
156            });
157        }
158
159        chip.into_any_element()
160    }
161
162    /// One drop zone with its label, hint, and assigned chips.
163    fn zone(
164        state: &Entity<PivotState>,
165        zone: PivotZone,
166        title: &'static str,
167        cx: &App,
168    ) -> gpui::AnyElement {
169        let s = state.read(cx);
170        let theme = s.theme.clone();
171        let fields = s.config.fields_in(zone);
172        let columns = s.source_columns().to_vec();
173        let agg = s.config.aggregation;
174        let agg_menu_open = s.agg_menu_open;
175
176        let state_drop = state.clone();
177        let active_bg = theme.pivot_drop_zone_active_bg;
178
179        let mut chips: Vec<gpui::AnyElement> = Vec::new();
180        for (i, &field) in fields.iter().enumerate() {
181            let name = columns
182                .get(field)
183                .map(|c| c.name.clone())
184                .unwrap_or_else(|| format!("column {field}"));
185            match zone {
186                PivotZone::Values => {
187                    chips.push(Self::values_chip(
188                        state,
189                        field,
190                        agg,
191                        agg_menu_open,
192                        name,
193                        cx,
194                    ));
195                }
196                PivotZone::Filters => {
197                    let filter_on = s.filter_active(field);
198                    let state_open = state.clone();
199                    let chip = div()
200                        .id(("pivot-filter-chip", i))
201                        .on_mouse_down(MouseButton::Left, {
202                            let state_open = state_open.clone();
203                            move |e: &MouseDownEvent, _w, cx| {
204                                state_open.update(cx, |s, cx| {
205                                    s.open_filter_popover(field, e.position);
206                                    cx.notify();
207                                });
208                            }
209                        })
210                        .child(Self::chip(
211                            state,
212                            ("pivot-filter-chip-inner", i),
213                            field,
214                            name,
215                            Some(zone),
216                            true,
217                            filter_on.then_some("●"),
218                            cx,
219                        ));
220                    chips.push(chip.into_any_element());
221                }
222                PivotZone::Rows | PivotZone::Columns => {
223                    let id = match zone {
224                        PivotZone::Rows => ("pivot-row-chip", i),
225                        _ => ("pivot-col-chip", i),
226                    };
227                    chips.push(Self::chip(
228                        state,
229                        id,
230                        field,
231                        name,
232                        Some(zone),
233                        true,
234                        None,
235                        cx,
236                    ));
237                }
238            }
239        }
240
241        let hint = if chips.is_empty() {
242            Some(
243                div()
244                    .text_color(theme.muted_text)
245                    .text_size(px(11.0))
246                    .child("Drag fields here"),
247            )
248        } else {
249            None
250        };
251
252        div()
253            .flex()
254            .flex_col()
255            .gap(px(4.0))
256            .child(
257                div()
258                    .text_color(theme.muted_text)
259                    .text_size(px(11.0))
260                    .child(title),
261            )
262            .child(
263                div()
264                    .min_h(px(40.0))
265                    .p(px(6.0))
266                    .rounded(px(4.0))
267                    .bg(theme.pivot_drop_zone_bg)
268                    .border_1()
269                    .border_color(theme.grid_line)
270                    .flex()
271                    .flex_col()
272                    .gap(px(4.0))
273                    .drag_over::<DraggedField>(move |style, _, _, _| style.bg(active_bg))
274                    .on_drop::<DraggedField>(move |dragged, _window, cx| {
275                        let dragged_field = dragged.field;
276                        state_drop.update(cx, |s, cx| {
277                            s.config.move_field(dragged_field, zone, None);
278                            s.recompute();
279                            cx.notify();
280                        });
281                    })
282                    .children(chips)
283                    .children(hint),
284            )
285            .into_any_element()
286    }
287
288    /// The Values-zone chip: caption plus the aggregation picker.
289    fn values_chip(
290        state: &Entity<PivotState>,
291        field: usize,
292        agg: AggregationFn,
293        menu_open: bool,
294        name: String,
295        cx: &App,
296    ) -> gpui::AnyElement {
297        let s = state.read(cx);
298        let theme = s.theme.clone();
299        let state_toggle = state.clone();
300        let state_remove = state.clone();
301
302        let mut rows: Vec<gpui::AnyElement> = Vec::new();
303        let ghost_label = agg.caption(&name);
304        let ghost_bg = theme.pivot_chip_bg;
305        let ghost_fg = theme.pivot_chip_fg;
306        let ghost_border = theme.grid_line;
307        rows.push(
308            div()
309                .id("pivot-values-chip")
310                .px(px(8.0))
311                .py(px(3.0))
312                .rounded(px(4.0))
313                .bg(theme.pivot_chip_bg)
314                .border_1()
315                .border_color(theme.grid_line)
316                .text_color(theme.pivot_chip_fg)
317                .text_size(px(12.0))
318                .flex()
319                .items_center()
320                .gap(px(6.0))
321                .cursor_pointer()
322                .on_drag(
323                    DraggedField { field },
324                    move |_drag, _offset, _window, cx| {
325                        cx.new(|_| DragGhost {
326                            label: ghost_label.clone(),
327                            bg: ghost_bg,
328                            fg: ghost_fg,
329                            border: ghost_border,
330                        })
331                    },
332                )
333                .child(div().flex_1().child(agg.caption(&name)))
334                .child(
335                    div()
336                        .cursor_pointer()
337                        .text_color(theme.sort_indicator)
338                        .child(if menu_open { "▲" } else { "▼" })
339                        .on_mouse_down(MouseButton::Left, {
340                            let state_toggle = state_toggle.clone();
341                            move |_e: &MouseDownEvent, _w, cx| {
342                                cx.stop_propagation();
343                                state_toggle.update(cx, |s, cx| {
344                                    s.agg_menu_open = !s.agg_menu_open;
345                                    cx.notify();
346                                });
347                            }
348                        }),
349                )
350                .child(
351                    div()
352                        .text_color(theme.muted_text)
353                        .cursor_pointer()
354                        .child("✕")
355                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
356                            cx.stop_propagation();
357                            state_remove.update(cx, |s, cx| {
358                                s.config.remove_field(field);
359                                s.recompute();
360                                cx.notify();
361                            });
362                        }),
363                )
364                .into_any_element(),
365        );
366
367        if menu_open {
368            for func in AggregationFn::all() {
369                let selected = func == agg;
370                let state_pick = state.clone();
371                rows.push(
372                    div()
373                        .px(px(8.0))
374                        .py(px(2.0))
375                        .rounded(px(3.0))
376                        .bg(if selected {
377                            theme.menu_hover_bg
378                        } else {
379                            theme.menu_bg
380                        })
381                        .text_color(theme.menu_fg)
382                        .text_size(px(12.0))
383                        .cursor_pointer()
384                        .child(func.label())
385                        .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
386                            state_pick.update(cx, |s, cx| {
387                                s.config.aggregation = func;
388                                s.agg_menu_open = false;
389                                s.recompute();
390                                cx.notify();
391                            });
392                        })
393                        .into_any_element(),
394                );
395            }
396        }
397
398        div()
399            .flex()
400            .flex_col()
401            .gap(px(2.0))
402            .children(rows)
403            .into_any_element()
404    }
405
406    /// A labelled checkbox row bound to one totals option.
407    fn option_row(
408        state: &Entity<PivotState>,
409        label: &'static str,
410        checked: bool,
411        apply: impl Fn(&mut PivotState) + 'static,
412        cx: &App,
413    ) -> gpui::AnyElement {
414        let theme = state.read(cx).theme.clone();
415        let state_toggle = state.clone();
416        let boxed = div()
417            .w(px(12.0))
418            .h(px(12.0))
419            .border_1()
420            .border_color(theme.grid_line)
421            .bg(theme.menu_bg)
422            .flex()
423            .items_center()
424            .justify_center()
425            .children(checked.then(|| div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator)));
426        div()
427            .flex()
428            .items_center()
429            .gap(px(6.0))
430            .cursor_pointer()
431            .text_size(px(12.0))
432            .text_color(theme.menu_fg)
433            .child(boxed)
434            .child(label)
435            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
436                state_toggle.update(cx, |s, cx| {
437                    apply(s);
438                    s.recompute();
439                    cx.notify();
440                });
441            })
442            .into_any_element()
443    }
444
445    /// Small inline text button.
446    fn text_button(
447        state: &Entity<PivotState>,
448        label: &'static str,
449        apply: impl Fn(&mut PivotState, &mut App) + 'static,
450        cx: &App,
451    ) -> gpui::AnyElement {
452        let theme = state.read(cx).theme.clone();
453        let state_btn = state.clone();
454        div()
455            .px(px(6.0))
456            .py(px(2.0))
457            .rounded(px(3.0))
458            .border_1()
459            .border_color(theme.grid_line)
460            .bg(theme.pivot_drop_zone_bg)
461            .text_color(theme.menu_fg)
462            .text_size(px(11.0))
463            .cursor_pointer()
464            .child(label)
465            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
466                state_btn.update(cx, |s, cx| {
467                    apply(s, cx);
468                    cx.notify();
469                });
470            })
471            .into_any_element()
472    }
473
474    /// The Filters-zone checklist popover overlay, if open.
475    fn render_filter_popover(
476        state: &Entity<PivotState>,
477        cx: &App,
478    ) -> Option<impl IntoElement + use<>> {
479        let s = state.read(cx);
480        let popover = s.filter_popover()?.clone();
481        let theme = s.theme.clone();
482        let field_name = s
483            .source_columns()
484            .get(popover.field)
485            .map(|c| c.name.clone())
486            .unwrap_or_default();
487
488        let checkbox = |checked: bool| {
489            let mut b = div()
490                .w(px(12.0))
491                .h(px(12.0))
492                .border_1()
493                .border_color(theme.grid_line)
494                .bg(theme.menu_bg)
495                .flex()
496                .items_center()
497                .justify_center();
498            if checked {
499                b = b.child(div().w(px(6.0)).h(px(6.0)).bg(theme.sort_indicator));
500            }
501            b
502        };
503
504        let state_all = state.clone();
505        let select_all = div()
506            .h(px(22.0))
507            .flex()
508            .items_center()
509            .gap(px(6.0))
510            .cursor_pointer()
511            .child(checkbox(popover.all_checked()))
512            .child("(Select All)")
513            .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
514                state_all.update(cx, |s, cx| {
515                    s.toggle_filter_popover_select_all();
516                    cx.notify();
517                });
518            });
519
520        let mut value_rows: Vec<gpui::AnyElement> = Vec::new();
521        for (i, row) in popover
522            .rows
523            .iter()
524            .enumerate()
525            .take(FILTER_POPOVER_MAX_ROWS)
526        {
527            let state_val = state.clone();
528            value_rows.push(
529                div()
530                    .h(px(20.0))
531                    .flex()
532                    .items_center()
533                    .gap(px(6.0))
534                    .cursor_pointer()
535                    .child(checkbox(row.checked))
536                    .child(div().text_color(theme.menu_fg).child(row.label.clone()))
537                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
538                        state_val.update(cx, |s, cx| {
539                            s.toggle_filter_popover_value(i);
540                            cx.notify();
541                        });
542                    })
543                    .into_any_element(),
544            );
545        }
546        let truncated = popover.rows.len() > FILTER_POPOVER_MAX_ROWS;
547
548        let state_clear = state.clone();
549        let state_close = state.clone();
550        let clear_field = popover.field;
551        let buttons = div()
552            .flex()
553            .gap(px(6.0))
554            .child(
555                div()
556                    .flex_1()
557                    .h(px(24.0))
558                    .flex()
559                    .items_center()
560                    .justify_center()
561                    .border_1()
562                    .border_color(theme.grid_line)
563                    .bg(theme.menu_hover_bg)
564                    .cursor_pointer()
565                    .child("Clear")
566                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
567                        state_clear.update(cx, |s, cx| {
568                            s.clear_filter(clear_field);
569                            cx.notify();
570                        });
571                    }),
572            )
573            .child(
574                div()
575                    .flex_1()
576                    .h(px(24.0))
577                    .flex()
578                    .items_center()
579                    .justify_center()
580                    .border_1()
581                    .border_color(theme.grid_line)
582                    .bg(theme.menu_hover_bg)
583                    .cursor_pointer()
584                    .child("Close")
585                    .on_mouse_down(MouseButton::Left, move |_e: &MouseDownEvent, _w, cx| {
586                        state_close.update(cx, |s, cx| {
587                            s.close_filter_popover();
588                            cx.notify();
589                        });
590                    }),
591            );
592
593        let body = div()
594            .flex()
595            .flex_col()
596            .w(px(240.0))
597            .p(px(8.0))
598            .gap(px(6.0))
599            .bg(theme.menu_bg)
600            .border_1()
601            .border_color(theme.grid_line)
602            .text_color(theme.menu_fg)
603            .text_size(px(12.0))
604            .child(
605                div()
606                    .text_color(theme.muted_text)
607                    .text_size(px(11.0))
608                    .child(format!("Filter: {field_name}")),
609            )
610            .child(select_all)
611            .child(
612                div()
613                    .id("pivot-filter-values")
614                    .flex()
615                    .flex_col()
616                    .max_h(px(200.0))
617                    .overflow_y_scroll()
618                    .children(value_rows)
619                    .children(truncated.then(|| {
620                        div()
621                            .text_color(theme.muted_text)
622                            .text_size(px(11.0))
623                            .child(format!("Showing first {FILTER_POPOVER_MAX_ROWS} values…"))
624                    })),
625            )
626            .child(buttons);
627
628        let state_backdrop = state.clone();
629        let overlay = deferred(
630            anchored()
631                .anchor(Corner::TopLeft)
632                .position(point(popover.anchor.x, popover.anchor.y))
633                .child(div().occlude().child(body).on_mouse_down_out(
634                    move |_e: &MouseDownEvent, _window, cx| {
635                        state_backdrop.update(cx, |s, cx| {
636                            if s.filter_popover().is_some() {
637                                s.close_filter_popover();
638                                cx.notify();
639                            }
640                        });
641                    },
642                )),
643        )
644        .with_priority(POPOVER_PRIORITY);
645        Some(overlay)
646    }
647}
648
649impl Render for PivotSidebar {
650    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
651        let (theme, columns, config) = {
652            let s = self.state.read(cx);
653            (
654                s.theme.clone(),
655                s.source_columns().to_vec(),
656                s.config.clone(),
657            )
658        };
659
660        // Source field list with zone badges.
661        let mut field_chips: Vec<gpui::AnyElement> = Vec::new();
662        for (i, col) in columns.iter().enumerate() {
663            let badge = match config.zone_of(i) {
664                Some(PivotZone::Rows) => Some("R"),
665                Some(PivotZone::Columns) => Some("C"),
666                Some(PivotZone::Values) => Some("V"),
667                Some(PivotZone::Filters) => Some("F"),
668                None => None,
669            };
670            field_chips.push(Self::chip(
671                &self.state,
672                ("pivot-source-field", i),
673                i,
674                col.name.clone(),
675                None,
676                false,
677                badge,
678                cx,
679            ));
680        }
681
682        let options = div()
683            .flex()
684            .flex_col()
685            .gap(px(4.0))
686            .child(
687                div()
688                    .text_color(theme.muted_text)
689                    .text_size(px(11.0))
690                    .child("Totals"),
691            )
692            .child(Self::option_row(
693                &self.state,
694                "Row subtotals",
695                config.show_row_subtotals,
696                |s| s.config.show_row_subtotals = !s.config.show_row_subtotals,
697                cx,
698            ))
699            .child(Self::option_row(
700                &self.state,
701                "Column subtotal columns",
702                config.show_column_subtotals,
703                |s| s.config.show_column_subtotals = !s.config.show_column_subtotals,
704                cx,
705            ))
706            .child(Self::option_row(
707                &self.state,
708                "Grand total row",
709                config.show_row_grand_total,
710                |s| s.config.show_row_grand_total = !s.config.show_row_grand_total,
711                cx,
712            ))
713            .child(Self::option_row(
714                &self.state,
715                "Grand total column",
716                config.show_column_grand_total,
717                |s| s.config.show_column_grand_total = !s.config.show_column_grand_total,
718                cx,
719            ));
720
721        let tools = div()
722            .flex()
723            .flex_col()
724            .gap(px(4.0))
725            .child(
726                div()
727                    .text_color(theme.muted_text)
728                    .text_size(px(11.0))
729                    .child("Groups"),
730            )
731            .child(
732                div()
733                    .flex()
734                    .gap(px(4.0))
735                    .child(Self::text_button(
736                        &self.state,
737                        "Expand rows",
738                        |s, _| s.expand_all_rows(),
739                        cx,
740                    ))
741                    .child(Self::text_button(
742                        &self.state,
743                        "Collapse rows",
744                        |s, _| s.collapse_all_rows(),
745                        cx,
746                    )),
747            )
748            .child(
749                div()
750                    .flex()
751                    .gap(px(4.0))
752                    .child(Self::text_button(
753                        &self.state,
754                        "Expand cols",
755                        |s, _| s.expand_all_cols(),
756                        cx,
757                    ))
758                    .child(Self::text_button(
759                        &self.state,
760                        "Collapse cols",
761                        |s, _| s.collapse_all_cols(),
762                        cx,
763                    )),
764            )
765            .child(
766                div()
767                    .text_color(theme.muted_text)
768                    .text_size(px(11.0))
769                    .child("Export"),
770            )
771            .child(Self::text_button(
772                &self.state,
773                "Copy pivot as CSV",
774                |s, cx| {
775                    let csv = s.to_csv();
776                    if !csv.is_empty() {
777                        cx.write_to_clipboard(gpui::ClipboardItem::new_string(csv));
778                    }
779                },
780                cx,
781            ));
782
783        div()
784            .id("pivot-sidebar")
785            .w(px(SIDEBAR_WIDTH))
786            .h_full()
787            .flex_none()
788            .flex()
789            .flex_col()
790            .gap(px(10.0))
791            .p(px(8.0))
792            .bg(theme.menu_bg)
793            .border_r_1()
794            .border_color(theme.grid_line)
795            .text_color(theme.menu_fg)
796            .text_size(px(12.0))
797            .overflow_y_scroll()
798            .child(
799                div()
800                    .text_color(theme.header_fg)
801                    .text_size(px(13.0))
802                    .child("Pivot Fields"),
803            )
804            .child(
805                div()
806                    .flex()
807                    .flex_col()
808                    .gap(px(3.0))
809                    .child(
810                        div()
811                            .text_color(theme.muted_text)
812                            .text_size(px(11.0))
813                            .child("Fields (drag into a zone)"),
814                    )
815                    .child(
816                        div()
817                            .id("pivot-field-list")
818                            .flex()
819                            .flex_col()
820                            .gap(px(3.0))
821                            .max_h(px(220.0))
822                            .overflow_y_scroll()
823                            .children(field_chips),
824                    ),
825            )
826            .child(Self::zone(&self.state, PivotZone::Filters, "Filters", cx))
827            .child(Self::zone(&self.state, PivotZone::Columns, "Columns", cx))
828            .child(Self::zone(&self.state, PivotZone::Rows, "Rows", cx))
829            .child(Self::zone(&self.state, PivotZone::Values, "Values", cx))
830            .child(options)
831            .child(tools)
832            .children(Self::render_filter_popover(&self.state, cx))
833    }
834}