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