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