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