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 crate::session::Session;
26use crate::utils::*;
27use crate::*;
28
29static CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/css/column-dropdown.css"));
30
31/// Shared state for the column dropdown, updated imperatively.
32#[derive(Default)]
33pub struct ColumnDropDownState {
34    pub values: Vec<InPlaceColumn>,
35    pub selected: usize,
36    pub width: f64,
37    pub on_select: Option<Callback<InPlaceColumn>>,
38    pub target: Option<HtmlElement>,
39    pub no_results: bool,
40}
41
42/// A clonable handle for the column dropdown shared state.
43#[derive(Clone)]
44pub struct ColumnDropDownElement {
45    state: Rc<RefCell<ColumnDropDownState>>,
46    session: Session,
47    notify: Rc<PubSub<()>>,
48}
49
50impl PartialEq for ColumnDropDownElement {
51    fn eq(&self, other: &Self) -> bool {
52        Rc::ptr_eq(&self.state, &other.state)
53    }
54}
55
56impl ImplicitClone for ColumnDropDownElement {}
57
58impl ColumnDropDownElement {
59    pub fn new(session: Session) -> Self {
60        Self {
61            state: Default::default(),
62            session,
63            notify: Rc::default(),
64        }
65    }
66
67    pub fn autocomplete(
68        &self,
69        target: HtmlInputElement,
70        exclude: HashSet<String>,
71        callback: Callback<InPlaceColumn>,
72    ) -> Option<()> {
73        let input = target.value();
74        let metadata = self.session.metadata();
75        let mut values: Vec<InPlaceColumn> = vec![];
76        let small_input = input.to_lowercase();
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 self.session.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        clone!(self.state, self.session, self.notify);
90        let target_elem: HtmlElement = target.clone().into();
91        let width = target.get_bounding_client_rect().width();
92        ApiFuture::spawn(async move {
93            if !exclude.contains(&input) {
94                let is_expr = crate::queries::validate_expr(&session, &input)
95                    .await?
96                    .is_none();
97                if is_expr {
98                    values.push(InPlaceColumn::Expression(Expression::new(
99                        None,
100                        input.into(),
101                    )));
102                }
103            }
104
105            let no_results = values.is_empty();
106            {
107                let mut s = state.borrow_mut();
108                s.values = values;
109                s.selected = 0;
110                s.width = width;
111                s.on_select = Some(callback);
112                s.target = Some(target_elem);
113                s.no_results = no_results;
114            }
115            notify.emit(());
116            Ok(())
117        });
118
119        Some(())
120    }
121
122    pub fn item_select(&self) {
123        let state = self.state.borrow();
124        if let Some(value) = state.values.get(state.selected)
125            && let Some(ref cb) = state.on_select
126        {
127            cb.emit(value.clone());
128        }
129    }
130
131    pub fn item_down(&self) {
132        let mut state = self.state.borrow_mut();
133        state.selected += 1;
134        if state.selected >= state.values.len() {
135            state.selected = 0;
136        }
137
138        drop(state);
139        self.notify.emit(());
140    }
141
142    pub fn item_up(&self) {
143        let mut state = self.state.borrow_mut();
144        if state.selected < 1 {
145            state.selected = state.values.len();
146        }
147
148        state.selected -= 1;
149        drop(state);
150        self.notify.emit(());
151    }
152
153    pub fn hide(&self) -> ApiResult<()> {
154        self.state.borrow_mut().target = None;
155        self.notify.emit(());
156        Ok(())
157    }
158}
159
160/// A portal component that renders the column dropdown. Should be placed in
161/// the view of the component that creates the `ColumnDropDownElement`.
162#[derive(Properties, PartialEq)]
163pub struct ColumnDropDownPortalProps {
164    pub element: ColumnDropDownElement,
165    pub theme: String,
166}
167
168pub struct ColumnDropDownPortal {
169    _sub: Subscription,
170}
171
172impl Component for ColumnDropDownPortal {
173    type Message = ();
174    type Properties = ColumnDropDownPortalProps;
175
176    fn create(ctx: &Context<Self>) -> Self {
177        let link = ctx.link().clone();
178        let sub = ctx
179            .props()
180            .element
181            .notify
182            .add_listener(move |()| link.send_message(()));
183        Self { _sub: sub }
184    }
185
186    fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
187        true
188    }
189
190    fn view(&self, ctx: &Context<Self>) -> Html {
191        let state = ctx.props().element.state.borrow();
192        let target = state.target.clone();
193        let on_close = {
194            let element = ctx.props().element.clone();
195            Callback::from(move |()| {
196                let _ = element.hide();
197            })
198        };
199
200        if target.is_some() {
201            let values = state.values.clone();
202            let selected = state.selected;
203            let width = state.width;
204            let on_select = state.on_select.clone();
205            drop(state);
206
207            html! {
208                <PortalModal
209                    tag_name="perspective-dropdown"
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>{ CSS }</style><style>{ position }</style>{ body }</> }
274}