Skip to main content

perspective_viewer/components/
column_dropdown.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
13use std::cell::RefCell;
14use std::collections::HashSet;
15use std::rc::Rc;
16
17use perspective_client::clone;
18use perspective_client::config::Expression;
19use web_sys::*;
20use yew::html::ImplicitClone;
21use yew::prelude::*;
22
23use super::column_selector::InPlaceColumn;
24use super::portal::PortalModal;
25use super::style::StyleSurface;
26use crate::session::Session;
27use crate::utils::*;
28use crate::*;
29
30/// Shared state for the column dropdown, updated imperatively.
31#[derive(Default)]
32pub struct ColumnDropDownState {
33    pub values: Vec<InPlaceColumn>,
34    pub selected: usize,
35    pub width: f64,
36    pub on_select: Option<Callback<InPlaceColumn>>,
37    pub target: Option<HtmlElement>,
38    pub no_results: bool,
39}
40
41/// A clonable handle for the column dropdown shared state.
42#[derive(Clone)]
43pub struct ColumnDropDownElement {
44    state: Rc<RefCell<ColumnDropDownState>>,
45    session: Session,
46    notify: Rc<PubSub<()>>,
47}
48
49impl PartialEq for ColumnDropDownElement {
50    fn eq(&self, other: &Self) -> bool {
51        Rc::ptr_eq(&self.state, &other.state)
52    }
53}
54
55impl ImplicitClone for ColumnDropDownElement {}
56
57impl ColumnDropDownElement {
58    pub fn new(session: Session) -> Self {
59        Self {
60            state: Default::default(),
61            session,
62            notify: Rc::default(),
63        }
64    }
65
66    pub fn autocomplete(
67        &self,
68        target: HtmlInputElement,
69        exclude: HashSet<String>,
70        callback: Callback<InPlaceColumn>,
71    ) -> Option<()> {
72        let input = target.value();
73        let metadata = self.session.metadata();
74        let mut values: Vec<InPlaceColumn> = vec![];
75        let small_input = input.to_lowercase();
76        for col in metadata.get_table_columns()? {
77            if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
78                values.push(InPlaceColumn::Column(col.to_owned()));
79            }
80        }
81
82        for col in self.session.metadata().get_expression_columns() {
83            if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
84                values.push(InPlaceColumn::Column(col.to_owned()));
85            }
86        }
87
88        clone!(self.state, self.session, self.notify);
89        let target_elem: HtmlElement = target.clone().into();
90        let width = target.get_bounding_client_rect().width();
91        ApiFuture::spawn(async move {
92            if !exclude.contains(&input) {
93                let is_expr = crate::queries::validate_expr(&session, &input)
94                    .await?
95                    .is_none();
96                if is_expr {
97                    values.push(InPlaceColumn::Expression(Expression::new(
98                        None,
99                        input.into(),
100                    )));
101                }
102            }
103
104            let no_results = values.is_empty();
105            {
106                let mut s = state.borrow_mut();
107                s.values = values;
108                s.selected = 0;
109                s.width = width;
110                s.on_select = Some(callback);
111                s.target = Some(target_elem);
112                s.no_results = no_results;
113            }
114            notify.emit(());
115            Ok(())
116        });
117
118        Some(())
119    }
120
121    pub fn item_select(&self) {
122        let state = self.state.borrow();
123        if let Some(value) = state.values.get(state.selected)
124            && let Some(ref cb) = state.on_select
125        {
126            cb.emit(value.clone());
127        }
128    }
129
130    pub fn item_down(&self) {
131        let mut state = self.state.borrow_mut();
132        state.selected += 1;
133        if state.selected >= state.values.len() {
134            state.selected = 0;
135        }
136
137        drop(state);
138        self.notify.emit(());
139    }
140
141    pub fn item_up(&self) {
142        let mut state = self.state.borrow_mut();
143        if state.selected < 1 {
144            state.selected = state.values.len();
145        }
146
147        state.selected -= 1;
148        drop(state);
149        self.notify.emit(());
150    }
151
152    pub fn hide(&self) -> ApiResult<()> {
153        self.state.borrow_mut().target = None;
154        self.notify.emit(());
155        Ok(())
156    }
157}
158
159/// A portal component that renders the column dropdown. Should be placed in
160/// the view of the component that creates the `ColumnDropDownElement`.
161#[derive(Properties, PartialEq)]
162pub struct ColumnDropDownPortalProps {
163    pub element: ColumnDropDownElement,
164    pub theme: String,
165}
166
167pub struct ColumnDropDownPortal {
168    _sub: Subscription,
169}
170
171impl Component for ColumnDropDownPortal {
172    type Message = ();
173    type Properties = ColumnDropDownPortalProps;
174
175    fn create(ctx: &Context<Self>) -> Self {
176        let link = ctx.link().clone();
177        let sub = ctx
178            .props()
179            .element
180            .notify
181            .add_listener(move |()| link.send_message(()));
182        Self { _sub: sub }
183    }
184
185    fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
186        true
187    }
188
189    fn view(&self, ctx: &Context<Self>) -> Html {
190        let state = ctx.props().element.state.borrow();
191        let target = state.target.clone();
192        let on_close = {
193            let element = ctx.props().element.clone();
194            Callback::from(move |()| {
195                let _ = element.hide();
196            })
197        };
198
199        if target.is_some() {
200            let values = state.values.clone();
201            let selected = state.selected;
202            let width = state.width;
203            let on_select = state.on_select.clone();
204            drop(state);
205
206            html! {
207                <PortalModal
208                    tag_name="perspective-dropdown"
209                    surface={StyleSurface::ColumnDropdown}
210                    {target}
211                    own_focus=false
212                    {on_close}
213                    theme={ctx.props().theme.clone()}
214                >
215                    <ColumnDropDownView {values} {selected} {width} {on_select} />
216                </PortalModal>
217            }
218        } else {
219            html! {}
220        }
221    }
222}
223
224/// Pure view component for the column dropdown content.
225#[derive(Properties, PartialEq)]
226struct ColumnDropDownViewProps {
227    values: Vec<InPlaceColumn>,
228    selected: usize,
229    width: f64,
230    on_select: Option<Callback<InPlaceColumn>>,
231}
232
233#[function_component]
234fn ColumnDropDownView(props: &ColumnDropDownViewProps) -> Html {
235    let body = html! {
236        if !props.values.is_empty() {
237            { for props.values
238                    .iter()
239                    .enumerate()
240                    .map(|(idx, value)| {
241                        let click = props.on_select.as_ref().unwrap().reform({
242                            let value = value.clone();
243                            move |_: MouseEvent| value.clone()
244                        });
245
246                        let row = match value {
247                            InPlaceColumn::Column(col) => html! {
248                                <span>{ col }</span>
249                            },
250                            InPlaceColumn::Expression(col) => html! {
251                                <span id="add-expression"><span class="icon" />{ col.name.clone() }</span>
252                            },
253                        };
254
255                        html! {
256                            if idx == props.selected {
257                                <span onmousedown={click} class="selected">{ row }</span>
258                            } else {
259                                <span onmousedown={click}>{ row }</span>
260                            }
261                        }
262                    }) }
263        } else {
264            <span class="no-results" />
265        }
266    };
267
268    let position = format!(
269        ":host{{min-width:{}px;max-width:{}px}}",
270        props.width, props.width
271    );
272
273    html! { <><style>{ position }</style>{ body }</> }
274}