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