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