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 small_input = input.to_lowercase();
74        let mut values: Vec<InPlaceColumn> = vec![];
75        {
76            let metadata = self.session.metadata();
77            for col in metadata.get_table_columns()? {
78                if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
79                    values.push(InPlaceColumn::Column(col.to_owned()));
80                }
81            }
82
83            for col in metadata.get_expression_columns() {
84                if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
85                    values.push(InPlaceColumn::Column(col.to_owned()));
86                }
87            }
88        }
89
90        let width = target.get_bounding_client_rect().width();
91        let target_elem: HtmlElement = target.clone().into();
92
93        // Publish the synchronous matches immediately, *before* any async
94        // work, so the dropdown and `item_select` always reflect the latest
95        // keystroke rather than whichever async continuation happens to
96        // resolve last.
97        {
98            let mut s = self.state.borrow_mut();
99            s.values = values;
100            s.selected = 0;
101            s.width = width;
102            s.on_select = Some(callback);
103            s.target = Some(target_elem);
104            s.no_results = s.values.is_empty();
105        }
106        self.notify.emit(());
107
108        if !exclude.contains(&input) {
109            clone!(self.state, self.session, self.notify);
110            ApiFuture::spawn(async move {
111                let is_expr = crate::queries::validate_expr(&session, &input)
112                    .await?
113                    .is_none();
114                if is_expr && target.value() == input {
115                    let mut s = state.borrow_mut();
116                    s.values.push(InPlaceColumn::Expression(Expression::new(
117                        None,
118                        input.into(),
119                    )));
120                    s.no_results = false;
121                    drop(s);
122                    notify.emit(());
123                }
124                Ok(())
125            });
126        }
127
128        Some(())
129    }
130
131    pub fn item_select(&self) {
132        let state = self.state.borrow();
133        if let Some(value) = state.values.get(state.selected)
134            && let Some(ref cb) = state.on_select
135        {
136            cb.emit(value.clone());
137        }
138    }
139
140    pub fn item_down(&self) {
141        let mut state = self.state.borrow_mut();
142        state.selected += 1;
143        if state.selected >= state.values.len() {
144            state.selected = 0;
145        }
146
147        drop(state);
148        self.notify.emit(());
149    }
150
151    pub fn item_up(&self) {
152        let mut state = self.state.borrow_mut();
153        if state.selected < 1 {
154            state.selected = state.values.len();
155        }
156
157        state.selected -= 1;
158        drop(state);
159        self.notify.emit(());
160    }
161
162    pub fn hide(&self) -> ApiResult<()> {
163        self.state.borrow_mut().target = None;
164        self.notify.emit(());
165        Ok(())
166    }
167}
168
169/// A portal component that renders the column dropdown. Should be placed in
170/// the view of the component that creates the `ColumnDropDownElement`.
171#[derive(Properties, PartialEq)]
172pub struct ColumnDropDownPortalProps {
173    pub element: ColumnDropDownElement,
174    pub theme: String,
175}
176
177pub struct ColumnDropDownPortal {
178    _sub: Subscription,
179}
180
181impl Component for ColumnDropDownPortal {
182    type Message = ();
183    type Properties = ColumnDropDownPortalProps;
184
185    fn create(ctx: &Context<Self>) -> Self {
186        let link = ctx.link().clone();
187        let sub = ctx
188            .props()
189            .element
190            .notify
191            .add_listener(move |()| link.send_message(()));
192        Self { _sub: sub }
193    }
194
195    fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
196        true
197    }
198
199    fn view(&self, ctx: &Context<Self>) -> Html {
200        let state = ctx.props().element.state.borrow();
201        let target = state.target.clone();
202        let on_close = {
203            let element = ctx.props().element.clone();
204            Callback::from(move |()| {
205                let _ = element.hide();
206            })
207        };
208
209        if target.is_some() {
210            let values = state.values.clone();
211            let selected = state.selected;
212            let width = state.width;
213            let on_select = state.on_select.clone();
214            drop(state);
215
216            html! {
217                <PortalModal
218                    tag_name="perspective-dropdown"
219                    surface={StyleSurface::ColumnDropdown}
220                    {target}
221                    own_focus=false
222                    {on_close}
223                    theme={ctx.props().theme.clone()}
224                >
225                    <ColumnDropDownView {values} {selected} {width} {on_select} />
226                </PortalModal>
227            }
228        } else {
229            html! {}
230        }
231    }
232}
233
234/// Pure view component for the column dropdown content.
235#[derive(Properties, PartialEq)]
236struct ColumnDropDownViewProps {
237    values: Vec<InPlaceColumn>,
238    selected: usize,
239    width: f64,
240    on_select: Option<Callback<InPlaceColumn>>,
241}
242
243#[function_component]
244fn ColumnDropDownView(props: &ColumnDropDownViewProps) -> Html {
245    let body = html! {
246        if !props.values.is_empty() {
247            { for props.values
248                    .iter()
249                    .enumerate()
250                    .map(|(idx, value)| {
251                        let click = props.on_select.as_ref().unwrap().reform({
252                            let value = value.clone();
253                            move |_: MouseEvent| value.clone()
254                        });
255
256                        let row = match value {
257                            InPlaceColumn::Column(col) => html! {
258                                <span>{ col }</span>
259                            },
260                            InPlaceColumn::Expression(col) => html! {
261                                <span id="add-expression"><span class="icon" />{ col.name.clone() }</span>
262                            },
263                        };
264
265                        html! {
266                            if idx == props.selected {
267                                <span onmousedown={click} class="selected">{ row }</span>
268                            } else {
269                                <span onmousedown={click}>{ row }</span>
270                            }
271                        }
272                    }) }
273        } else {
274            <span class="no-results" />
275        }
276    };
277
278    let position = format!(
279        ":host{{min-width:{}px;max-width:{}px}}",
280        props.width, props.width
281    );
282
283    html! { <><style>{ position }</style>{ body }</> }
284}