Skip to main content

perspective_viewer/components/
column_selector.rs

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