Skip to main content

perspective_viewer/components/
column_selector.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13mod active_column;
14mod add_expression_button;
15mod aggregate_selector;
16mod config_selector;
17mod empty_column;
18mod expr_edit_button;
19mod filter_column;
20mod inactive_column;
21mod invalid_column;
22mod pivot_column;
23mod sort_column;
24
25use std::iter::*;
26use std::rc::Rc;
27
28pub use empty_column::*;
29pub use invalid_column::*;
30use perspective_client::config::ViewConfig;
31use perspective_js::utils::ApiFuture;
32pub use pivot_column::*;
33use web_sys::*;
34use yew::prelude::*;
35
36use self::active_column::*;
37use self::add_expression_button::AddExpressionButton;
38use self::config_selector::ConfigSelector;
39use self::inactive_column::*;
40use super::containers::scroll_panel::*;
41use super::containers::split_panel::{Orientation, SplitPanel};
42use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
43use crate::components::containers::scroll_panel_item::ScrollPanelItem;
44use crate::presentation::{ColumnLocator, DragDropContainer, Presentation};
45use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet};
46use crate::renderer::*;
47use crate::session::drag_drop_update::*;
48use crate::session::*;
49use crate::utils::*;
50
51#[derive(Properties)]
52pub struct ColumnSelectorProps {
53    /// Fires when the expression/config column is open.
54    pub on_open_expr_panel: Callback<ColumnLocator>,
55
56    /// This is passed to the add_expression_button for styling.
57    pub selected_column: Option<ColumnLocator>,
58
59    /// Value props threaded from root's `SessionProps` / `RendererProps`.
60    pub has_table: Option<TableLoadState>,
61    pub named_column_count: usize,
62    pub view_config: PtrEqRc<ViewConfig>,
63    pub drag_column: Option<String>,
64
65    /// Cloned session metadata snapshot — threaded from `SessionProps`
66    /// so that metadata changes trigger re-renders via prop diffing.
67    pub metadata: SessionMetadataRc,
68
69    /// Selected theme name, threaded for PortalModal consumers.
70    pub selected_theme: Option<String>,
71
72    // State
73    pub session: Session,
74    pub renderer: Renderer,
75    pub presentation: Presentation,
76
77    /// Fires when this component is resized via the UI.
78    #[prop_or_default]
79    pub on_resize: Option<Rc<PubSub<()>>>,
80
81    /// Trap-door width pinned by the parent `SettingsPanel` so switching
82    /// tabs doesn't shrink the panel. Threaded into the inner
83    /// `ScrollPanel` as `initial_width`.
84    #[prop_or_default]
85    pub initial_width: f64,
86
87    /// Fires when the inner `ScrollPanel` measures its natural width.
88    /// Routed up to `SettingsPanel` which keeps the running max.
89    #[prop_or_default]
90    pub on_auto_width: Callback<f64>,
91
92    /// External "release the trap-door" signal from the outer settings
93    /// split-panel divider reset. Forwarded into `self.on_reset` so both
94    /// inner `ScrollPanel`s drop their cached `viewport_width`.
95    #[prop_or_default]
96    pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
97}
98
99impl PartialEq for ColumnSelectorProps {
100    fn eq(&self, rhs: &Self) -> bool {
101        self.selected_column == rhs.selected_column
102            && self.has_table == rhs.has_table
103            && self.named_column_count == rhs.named_column_count
104            && self.view_config == rhs.view_config
105            && self.drag_column == rhs.drag_column
106            && self.metadata == rhs.metadata
107            && self.selected_theme == rhs.selected_theme
108            && self.initial_width == rhs.initial_width
109    }
110}
111
112#[derive(Debug)]
113pub enum ColumnSelectorMsg {
114    /// Triggers a plain re-render; used as `onselect`/`ondragenter` callbacks
115    /// from `ConfigSelector` after it mutates the view config.
116    Redraw,
117    HoverActiveIndex(Option<usize>),
118    Drop((String, DragTarget, DragEffect, usize)),
119}
120
121use ColumnSelectorMsg::*;
122
123/// A `ColumnSelector` controls the `columns` field of the `ViewConfig`,
124/// deriving its options from the table columns and `ViewConfig` expressions.
125pub struct ColumnSelector {
126    _subscriptions: Vec<Subscription>,
127    drag_container: DragDropContainer,
128    column_dropdown: ColumnDropDownElement,
129    on_reset: Rc<PubSub<()>>,
130}
131
132impl Component for ColumnSelector {
133    type Message = ColumnSelectorMsg;
134    type Properties = ColumnSelectorProps;
135
136    fn create(ctx: &Context<Self>) -> Self {
137        let ColumnSelectorProps {
138            presentation,
139            session,
140            ..
141        } = ctx.props();
142
143        let drop_sub = {
144            let cb = ctx.link().callback(ColumnSelectorMsg::Drop);
145            presentation.drop_received.add_listener(cb)
146        };
147
148        let drag_container = DragDropContainer::new(|| {}, {
149            let link = ctx.link().clone();
150            move || link.send_message(ColumnSelectorMsg::HoverActiveIndex(None))
151        });
152
153        let column_dropdown = ColumnDropDownElement::new(session.clone());
154        let on_reset: Rc<PubSub<()>> = Default::default();
155        let mut subscriptions = vec![drop_sub];
156        if let Some(outer_reset) = ctx.props().on_dimensions_reset.as_ref() {
157            let on_reset = on_reset.clone();
158            subscriptions.push(outer_reset.add_listener(move |()| on_reset.emit(())));
159        }
160
161        Self {
162            _subscriptions: subscriptions,
163            drag_container,
164            column_dropdown,
165            on_reset,
166        }
167    }
168
169    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
170        match msg {
171            Redraw => true,
172            HoverActiveIndex(Some(to_index)) => ctx
173                .props()
174                .presentation
175                .notify_drag_enter(DragTarget::Active, to_index),
176            HoverActiveIndex(_) => {
177                ctx.props()
178                    .presentation
179                    .notify_drag_leave(DragTarget::Active);
180                true
181            },
182            Drop((column, DragTarget::Active, DragEffect::Move(DragTarget::Active), index)) => {
183                let is_invalid = {
184                    let config = &ctx.props().view_config;
185                    let from_index = config
186                        .columns
187                        .iter()
188                        .position(|x| x.as_ref() == Some(&column));
189
190                    let min_cols = ctx.props().renderer.metadata().min_config_columns;
191                    let is_to_empty = !config
192                        .columns
193                        .get(index)
194                        .map(|x| x.is_some())
195                        .unwrap_or_default();
196
197                    min_cols
198                        .and_then(|x| from_index.map(|fi| fi < x))
199                        .unwrap_or_default()
200                        && is_to_empty
201                };
202                if !is_invalid {
203                    let col_type = ctx
204                        .props()
205                        .metadata
206                        .get_column_table_type(column.as_str())
207                        .unwrap();
208
209                    let update = ctx.props().view_config.create_drag_drop_update(
210                        column,
211                        col_type,
212                        index,
213                        DragTarget::Active,
214                        DragEffect::Move(DragTarget::Active),
215                        &ctx.props().renderer.metadata(),
216                        ctx.props().metadata.get_features().unwrap(),
217                    );
218
219                    let session = ctx.props().session.clone();
220                    let renderer = ctx.props().renderer.clone();
221                    if session.update_view_config(update).is_ok() {
222                        ApiFuture::spawn(async move {
223                            renderer.apply_pending_plugin()?;
224                            renderer.draw(session.validate().await?.create_view()).await
225                        });
226                    }
227                }
228
229                true
230            },
231            Drop((column, DragTarget::Active, effect, index)) => {
232                let col_type = ctx
233                    .props()
234                    .metadata
235                    .get_column_table_type(column.as_str())
236                    .unwrap();
237                let update = ctx.props().view_config.create_drag_drop_update(
238                    column,
239                    col_type,
240                    index,
241                    DragTarget::Active,
242                    effect,
243                    &ctx.props().renderer.metadata(),
244                    ctx.props().metadata.get_features().unwrap(),
245                );
246
247                let session = ctx.props().session.clone();
248                let renderer = ctx.props().renderer.clone();
249                if session.update_view_config(update).is_ok() {
250                    ApiFuture::spawn(async move {
251                        renderer.apply_pending_plugin()?;
252                        renderer.draw(session.validate().await?.create_view()).await
253                    });
254                }
255
256                true
257            },
258            Drop((_, _, DragEffect::Move(DragTarget::Active), _)) => true,
259            Drop((..)) => true,
260        }
261    }
262
263    fn view(&self, ctx: &Context<Self>) -> Html {
264        let ColumnSelectorProps {
265            session,
266            renderer,
267            presentation,
268            ..
269        } = ctx.props();
270        let metadata = &ctx.props().metadata;
271
272        // When `config.columns` is empty but the table has columns (transient
273        // state during `load()` after `reset()` clears the config), fill in
274        // all table columns as active — matching `validate_view_config()`.
275        let prop_config = &ctx.props().view_config;
276        let config = if prop_config.columns.is_empty() {
277            if let Some(table_cols) = metadata.get_table_columns() {
278                ViewConfig {
279                    columns: table_cols.iter().map(|c| Some(c.clone())).collect(),
280                    ..(**prop_config).clone()
281                }
282                .into()
283            } else {
284                prop_config.clone()
285            }
286        } else {
287            prop_config.clone()
288        };
289
290        let is_aggregated = config.is_aggregated();
291        let columns_iter = ColumnsIteratorSet::new(&config, metadata, renderer, presentation);
292        let onselect = ctx.link().callback(|()| Redraw);
293        let ondragenter = ctx.link().callback(HoverActiveIndex);
294        let ondragover = Callback::from(|_event: DragEvent| _event.prevent_default());
295        let ondrop = Callback::from({
296            clone!(presentation);
297            move |event| presentation.notify_drop(&event)
298        });
299
300        let ondragend = Callback::from({
301            clone!(presentation);
302            move |_| presentation.notify_drag_end()
303        });
304
305        let mut active_classes = classes!("scrollable");
306        if ctx.props().drag_column.is_some() {
307            active_classes.push("dragdrop-highlight");
308        };
309
310        if is_aggregated {
311            active_classes.push("is-aggregated");
312        }
313
314        let size_hint = 28.0f64.mul_add(
315            (config.group_by.len()
316                + config.split_by.len()
317                + config.filter.len()
318                + config.sort.len()) as f64,
319            metadata
320                .get_features()
321                .map(|x| {
322                    let mut y = 0.0;
323                    if !x.filter_ops.is_empty() {
324                        y += 1.0;
325                    }
326
327                    if x.group_by {
328                        y += 1.0;
329                    }
330
331                    if x.split_by {
332                        y += 1.0;
333                    }
334
335                    if x.sort {
336                        y += 1.0;
337                    }
338
339                    y * 55.0
340                })
341                .unwrap_or_default(),
342        );
343
344        let config_selector = html_nested! {
345            <ScrollPanelItem key="config_selector" {size_hint}>
346                <ConfigSelector
347                    onselect={onselect.clone()}
348                    ondragenter={ctx.link().callback(|()| Redraw)}
349                    view_config={ctx.props().view_config.clone()}
350                    drag_column={ctx.props().drag_column.clone()}
351                    metadata={metadata.clone()}
352                    selected_theme={ctx.props().selected_theme.clone()}
353                    {presentation}
354                    {renderer}
355                    {session}
356                />
357            </ScrollPanelItem>
358        };
359
360        let mut named_count = ctx.props().named_column_count;
361        let mut active_columns: Vec<_> = columns_iter
362            .active()
363            .enumerate()
364            .map(|(idx, name): (usize, ActiveColumnState)| {
365                let ondragenter = ondragenter.reform(move |_| Some(idx));
366                let size_hint = if named_count > 0 { 50.0 } else { 28.0 };
367                named_count = named_count.saturating_sub(1);
368                let key = name
369                    .get_name()
370                    .map(|x| x.to_owned())
371                    .unwrap_or_else(|| format!("__auto_{idx}__"));
372
373                let column_dropdown = self.column_dropdown.clone();
374                let is_editing = matches!(
375                    &ctx.props().selected_column,
376                    Some(ColumnLocator::Table(x)) | Some(ColumnLocator::Expression(x))
377                if x == &key );
378
379                // Compute metadata-derived props here so that changes to
380                // session metadata propagate via prop diffing.
381                // For DragOver placeholders, resolve the type from the
382                // dragged column (since `get_name()` returns `None`).
383                let col_type = name
384                    .get_name()
385                    .and_then(|n| metadata.get_column_table_type(n))
386                    .or_else(|| {
387                        if matches!(name.state, ActiveColumnStateData::DragOver) {
388                            presentation
389                                .get_drag_column()
390                                .and_then(|c| metadata.get_column_table_type(&c))
391                        } else {
392                            None
393                        }
394                    });
395
396                let is_expression = name
397                    .get_name()
398                    .map(|n| metadata.is_column_expression(n))
399                    .unwrap_or(false);
400
401                let can_render_styles =
402                    name.get_name().is_some() && renderer.can_render_column_styles();
403
404                let show_edit_btn = is_expression || can_render_styles;
405                let on_open_expr_panel = &ctx.props().on_open_expr_panel;
406                html_nested! {
407                    <ScrollPanelItem {key} {size_hint}>
408                        <ActiveColumn
409                            {column_dropdown}
410                            {idx}
411                            {is_aggregated}
412                            {is_editing}
413                            {is_expression}
414                            {show_edit_btn}
415                            {col_type}
416                            view_config={config.clone()}
417                            metadata={metadata.clone()}
418                            {name}
419                            {on_open_expr_panel}
420                            {ondragenter}
421                            ondragend={&ondragend}
422                            onselect={&onselect}
423                            {presentation}
424                            {renderer}
425                            {session}
426                        />
427                    </ScrollPanelItem>
428                }
429            })
430            .collect();
431
432        let mut inactive_children: Vec<_> = columns_iter
433            .expression()
434            .chain(columns_iter.inactive())
435            .enumerate()
436            .map(|(idx, vc)| {
437                let selected_column = ctx.props().selected_column.as_ref();
438                let is_editing = matches!(selected_column, Some(ColumnLocator::Expression(x)) if x.as_str() == vc.name);
439                let is_expression = metadata.is_column_expression(vc.name);
440                html_nested! {
441                    <ScrollPanelItem key={vc.name} size_hint=28.0>
442                        <InactiveColumn
443                            {idx}
444                            visible={vc.is_visible}
445                            name={vc.name.to_owned()}
446                            {is_editing}
447                            {is_expression}
448                            view_config={config.clone()}
449                            metadata={metadata.clone()}
450                            onselect={&onselect}
451                            ondragend={&ondragend}
452                            on_open_expr_panel={&ctx.props().on_open_expr_panel}
453                            {presentation}
454                            {renderer}
455                            {session}
456                        />
457                    </ScrollPanelItem>
458                }
459            })
460            .collect();
461
462        let size = 28.0;
463
464        let add_column = if metadata.get_features().unwrap().expressions {
465            html_nested! {
466                <ScrollPanelItem key="__add_expression__" size_hint={size}>
467                    <AddExpressionButton
468                        on_open_expr_panel={&ctx.props().on_open_expr_panel}
469                        selected_column={ctx.props().selected_column.clone()}
470                    />
471                </ScrollPanelItem>
472            }
473        } else {
474            html_nested! {
475                <ScrollPanelItem key="__add_expression__" size_hint=0_f64><span /></ScrollPanelItem>
476            }
477        };
478
479        if inactive_children.is_empty() {
480            active_columns.push(add_column)
481        } else {
482            inactive_children.insert(0, add_column);
483        }
484
485        let mut selected_columns = vec![html! {
486            <div id="selected-columns" key="__active_columns__">
487                <ScrollPanel
488                    id="active-columns"
489                    omit_autosize_div={true}
490                    class={active_classes}
491                    dragover={ondragover}
492                    dragenter={&self.drag_container.dragenter}
493                    dragleave={&self.drag_container.dragleave}
494                    viewport_ref={&self.drag_container.noderef}
495                    initial_width={ctx.props().initial_width}
496                    on_auto_width={ctx.props().on_auto_width.clone()}
497                    drop={ondrop}
498                    on_resize={&ctx.props().on_resize}
499                    on_dimensions_reset={&self.on_reset}
500                    children={std::iter::once(config_selector).chain(active_columns).collect::<Vec<_>>()}
501                />
502            </div>
503        }];
504
505        if !inactive_children.is_empty() {
506            selected_columns.push(html! {
507                <ScrollPanel
508                    id="sub-columns"
509                    key="__sub_columns__"
510                    class={classes!("scrollable")}
511                    on_resize={&ctx.props().on_resize}
512                    on_dimensions_reset={&self.on_reset}
513                    children={inactive_children}
514                />
515            })
516        }
517
518        html! {
519            <>
520                <SplitPanel
521                    no_wrap=true
522                    on_reset={self.on_reset.callback()}
523                    skip_empty=true
524                    orientation={Orientation::Vertical}
525                >
526                    { for selected_columns }
527                </SplitPanel>
528                <ColumnDropDownPortal
529                    element={self.column_dropdown.clone()}
530                    theme={ctx.props().selected_theme.clone().unwrap_or_default()}
531                />
532            </>
533        }
534    }
535}