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