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