Skip to main content

perspective_viewer/components/
status_bar.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::rc::Rc;
14
15use perspective_client::config::Filter;
16use wasm_bindgen_futures::spawn_local;
17use web_sys::*;
18use yew::prelude::*;
19
20use super::status_indicator::StatusIndicator;
21use crate::components::copy_dropdown::CopyDropDownMenu;
22use crate::components::export_dropdown::ExportDropDownMenu;
23use crate::components::global_filter_bar::GlobalFilterBar;
24use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick, PanelLabels};
25use crate::components::style::StyleSurface;
26use crate::config::*;
27use crate::js::*;
28use crate::presentation::{Presentation, PresentationProps};
29use crate::queries::fetch_hosted_tables;
30use crate::renderer::*;
31use crate::session::*;
32use crate::tasks::*;
33use crate::ui::{PortalModal, Select, SelectItem};
34use crate::utils::*;
35use crate::workspace::Workspace;
36use crate::*;
37
38#[derive(Clone, Properties)]
39pub struct StatusBarProps {
40    // DOM Attribute
41    pub id: String,
42
43    /// Fired when the reset button is clicked.
44    pub on_reset: Callback<bool>,
45
46    /// The left-anchored "New" action, fired with the [`NewPanelMenu`] pick.
47    pub on_new_panel: Callback<NewPanelPick>,
48
49    /// Snapshots threaded from root.  Component reads `has_table`,
50    /// `has_table_cells`, `error` from session_props; `selected_theme`,
51    /// `available_themes` from presentation_props.
52    pub session_props: SessionProps,
53    pub presentation_props: PresentationProps,
54
55    /// Derived from root: `settings_open && has_table_loaded`.  Used
56    /// here to drive the title-input enabled state and the theme picker
57    /// visibility.
58    pub is_settings_open: bool,
59
60    /// In-flight render counter, threaded to `StatusIndicator`.
61    pub update_count: u32,
62
63    /// Element-level global filters (fed by master/detail selection); rendered
64    /// as removable chips between the row stats and the menu icons.
65    pub global_filters: Vec<Filter>,
66
67    /// Remove the global filter at this index (a chip's ×).
68    pub on_remove_global_filter: Callback<usize>,
69
70    /// Clear all global filters (the "Clear" affordance).
71    pub on_clear_global_filters: Callback<()>,
72
73    // State
74    pub session: Session,
75    pub renderer: Renderer,
76    pub presentation: Presentation,
77
78    /// The multi-panel model, so a theme change can restyle EVERY panel (not
79    /// just the active one this status bar targets) — non-active panels that
80    /// inherit the host theme otherwise render stale CSS until they redraw.
81    pub workspace: Workspace,
82}
83
84impl PartialEq for StatusBarProps {
85    fn eq(&self, other: &Self) -> bool {
86        self.id == other.id
87            && self.session_props == other.session_props
88            && self.presentation_props == other.presentation_props
89            && self.is_settings_open == other.is_settings_open
90            && self.update_count == other.update_count
91            && self.global_filters == other.global_filters
92    }
93}
94
95pub enum StatusBarMsg {
96    Reset(MouseEvent),
97
98    /// The "New" button: fetch the hosted-table listing, then open the
99    /// dropdown.
100    NewPanel,
101
102    /// The listing resolved: open the dropdown anchored at the button, if
103    /// the generation is still the newest.
104    OpenNewPanel(u32, HostedTables),
105    CloseNewPanel,
106
107    /// A pick in the "New" dropdown.
108    NewPanelPick(NewPanelPick),
109    Export,
110    Copy,
111    CloseExport,
112    CloseCopy,
113    Eject,
114    SetTheme(String),
115    ResetTheme,
116    PointerEvent(web_sys::PointerEvent),
117}
118
119/// A toolbar with buttons, and `Table` & `View` status information.
120pub struct StatusBar {
121    copy_ref: NodeRef,
122    export_ref: NodeRef,
123    new_ref: NodeRef,
124    statusbar_ref: NodeRef,
125    copy_target: Option<HtmlElement>,
126    export_target: Option<HtmlElement>,
127
128    /// The "New" dropdown's anchor and listings while it is open.
129    new_menu: Option<(HtmlElement, HostedTables, PanelLabels)>,
130
131    /// Bumped per "New" click; a resolving fetch opens only if it is still
132    /// the newest one.
133    new_generation: u32,
134}
135
136impl Component for StatusBar {
137    type Message = StatusBarMsg;
138    type Properties = StatusBarProps;
139
140    fn create(_ctx: &Context<Self>) -> Self {
141        Self {
142            copy_ref: NodeRef::default(),
143            export_ref: NodeRef::default(),
144            new_ref: NodeRef::default(),
145            statusbar_ref: NodeRef::default(),
146            copy_target: None,
147            export_target: None,
148            new_menu: None,
149            new_generation: 0,
150        }
151    }
152
153    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
154        match msg {
155            StatusBarMsg::Reset(event) => {
156                let all = event.shift_key();
157                ctx.props().on_reset.emit(all);
158                false
159            },
160            StatusBarMsg::NewPanel => {
161                self.new_generation = self.new_generation.wrapping_add(1);
162                let generation = self.new_generation;
163                let workspace = ctx.props().workspace.clone();
164                let link = ctx.link().clone();
165                ApiFuture::spawn(async move {
166                    let tables = fetch_hosted_tables(&workspace).await;
167                    link.send_message(StatusBarMsg::OpenNewPanel(generation, Rc::new(tables)));
168                    Ok(())
169                });
170
171                false
172            },
173            StatusBarMsg::OpenNewPanel(generation, tables) => {
174                if generation != self.new_generation {
175                    return false;
176                }
177
178                match self.new_ref.cast::<HtmlElement>() {
179                    Some(target) => {
180                        let workspace = &ctx.props().workspace;
181                        let panels = Rc::new(
182                            workspace
183                                .panel_ids()
184                                .into_iter()
185                                .filter_map(|id| {
186                                    let panel = workspace.panel(&id)?;
187                                    let title = panel.session.get_title().filter(|t| !t.is_empty());
188                                    Some((id.as_str().to_owned(), title))
189                                })
190                                .collect::<Vec<_>>(),
191                        );
192
193                        self.new_menu = Some((target, tables, panels));
194                        true
195                    },
196                    None => false,
197                }
198            },
199            StatusBarMsg::CloseNewPanel => {
200                self.new_menu = None;
201                true
202            },
203            StatusBarMsg::NewPanelPick(pick) => {
204                ctx.props().on_new_panel.emit(pick);
205                self.new_menu = None;
206                true
207            },
208            StatusBarMsg::ResetTheme => {
209                update_theme(
210                    &ctx.props().renderer,
211                    &ctx.props().presentation,
212                    &ctx.props().workspace,
213                    None,
214                );
215                true
216            },
217            StatusBarMsg::SetTheme(theme_name) => {
218                update_theme(
219                    &ctx.props().renderer,
220                    &ctx.props().presentation,
221                    &ctx.props().workspace,
222                    Some(theme_name),
223                );
224                false
225            },
226            StatusBarMsg::Export => {
227                self.export_target = self.export_ref.cast::<HtmlElement>();
228                true
229            },
230            StatusBarMsg::Copy => {
231                self.copy_target = self.copy_ref.cast::<HtmlElement>();
232                true
233            },
234            StatusBarMsg::CloseExport => {
235                self.export_target = None;
236                true
237            },
238            StatusBarMsg::CloseCopy => {
239                self.copy_target = None;
240                true
241            },
242            StatusBarMsg::Eject => {
243                ctx.props().presentation.on_eject.emit(());
244                false
245            },
246            StatusBarMsg::PointerEvent(event) => {
247                if event.target().map(JsValue::from)
248                    == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
249                {
250                    ctx.props().presentation.statusbar_pointer_event.emit(event);
251                }
252
253                false
254            },
255        }
256    }
257
258    fn view(&self, ctx: &Context<Self>) -> Html {
259        let Self::Properties {
260            renderer, session, ..
261        } = ctx.props();
262
263        let has_table = ctx.props().session_props.has_table.clone();
264        let is_errored = ctx.props().session_props.is_errored();
265        let is_settings_open = ctx.props().is_settings_open;
266
267        let mut is_updating_class_name = classes!();
268        if !is_settings_open {
269            is_updating_class_name.push("settings-closed");
270        };
271
272        if !matches!(has_table, Some(TableLoadState::Loaded)) {
273            is_updating_class_name.push("updating");
274        }
275
276        // TODO Memoizing these would reduce some vdom diffing later on
277        let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
278        let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
279        let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
280        let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
281        let onreset = ctx.link().callback(StatusBarMsg::Reset);
282        let onnew = ctx.link().callback(|_: MouseEvent| StatusBarMsg::NewPanel);
283        let on_new_select = ctx.link().callback(StatusBarMsg::NewPanelPick);
284        let on_close_new = ctx.link().callback(|_| StatusBarMsg::CloseNewPanel);
285
286        // Project only the *active* panel's plugin toolbar into the shared status
287        // bar. Each panel's toolbar slots into `statusbar-extra-{its-panel-id}`
288        // (see datagrid `toolbar.ts`); the active panel's id comes from the
289        // active renderer this status bar is bound to.
290        let extra_slot = ctx
291            .props()
292            .renderer
293            .slot_name()
294            .map(|id| format!("statusbar-extra-{id}"))
295            .unwrap_or_else(|| "statusbar-extra".to_owned());
296        let is_menu = matches!(has_table, Some(TableLoadState::Loaded)) && is_settings_open;
297        let is_panel_bar = !ctx.props().workspace.clients().is_empty();
298        let is_settings =
299            !matches!(has_table, Some(TableLoadState::Loaded)) || is_errored || is_settings_open;
300
301        let on_copy_select = {
302            let props = ctx.props().clone();
303            let link = ctx.link().clone();
304            Callback::from(move |x: ExportFile| {
305                let props = props.clone();
306                let link = link.clone();
307                spawn_local(async move {
308                    let mime = x.method.mimetype(x.is_chart);
309                    let task = export_method_to_blob(
310                        &props.session,
311                        &props.renderer,
312                        &props.presentation,
313                        x.method,
314                    );
315                    let result = copy_to_clipboard(task, mime).await;
316                    let r = (|| -> ApiResult<()> {
317                        result?;
318                        link.send_message(StatusBarMsg::CloseCopy);
319                        Ok(())
320                    })();
321                    if let Err(e) = r {
322                        web_sys::console::warn_1(&e.into());
323                    }
324                })
325            })
326        };
327
328        let on_export_select = {
329            let props = ctx.props().clone();
330            let link = ctx.link().clone();
331            Callback::from(move |x: ExportFile| {
332                if !x.name.is_empty() {
333                    clone!(props, link);
334                    spawn_local(async move {
335                        let val = export_method_to_blob(
336                            &props.session,
337                            &props.renderer,
338                            &props.presentation,
339                            x.method,
340                        )
341                        .await
342                        .unwrap();
343                        let is_chart = props.renderer.is_chart();
344                        download(&x.as_filename(is_chart), &val).unwrap();
345                        link.send_message(StatusBarMsg::CloseExport);
346                    })
347                }
348            })
349        };
350
351        let on_close_copy = ctx.link().callback(|_| StatusBarMsg::CloseCopy);
352        let on_close_export = ctx.link().callback(|_| StatusBarMsg::CloseExport);
353
354        if is_settings {
355            html! {
356                <>
357                    <div
358                        ref={&self.statusbar_ref}
359                        id={ctx.props().id.clone()}
360                        class={is_updating_class_name}
361                        {onpointerdown}
362                    >
363                        <StatusIndicator
364                            {renderer}
365                            {session}
366                            update_count={ctx.props().update_count}
367                            session_props={ctx.props().session_props.clone()}
368                        />
369                        if is_panel_bar {
370                            <div id="panel-bar" class="section">
371                                <span ref={&self.new_ref} class="hover-target" onmousedown={onnew}>
372                                    <span id="new_panel" class="button">
373                                        <span class="icon" />
374                                        <span class="icon-label" />
375                                    </span>
376                                </span>
377                            </div>
378                        }
379                        if !ctx.props().global_filters.is_empty() {
380                            <GlobalFilterBar
381                                filters={ctx.props().global_filters.clone()}
382                                on_remove={ctx.props().on_remove_global_filter.clone()}
383                                on_clear={ctx.props().on_clear_global_filters.clone()}
384                            />
385                        }
386                        <div id="spacer" />
387                        if is_menu {
388                            <div id="menu-bar" class="section">
389                                <ThemeSelector
390                                    theme={ctx.props().presentation_props.selected_theme.clone()}
391                                    themes={ctx.props().presentation_props.available_themes.clone()}
392                                    on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
393                                    on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
394                                />
395                                <div id="plugin-settings"><slot name={extra_slot} /></div>
396                                <span class="hover-target">
397                                    <span id="reset" class="button" onmousedown={&onreset}>
398                                        <span class="icon shift-alt-icon" />
399                                        <span class="icon-label" />
400                                    </span>
401                                </span>
402                                <span
403                                    ref={&self.export_ref}
404                                    class="hover-target"
405                                    onmousedown={onexport}
406                                >
407                                    <span id="export" class="button">
408                                        <span class="icon" />
409                                        <span class="icon-label" />
410                                    </span>
411                                </span>
412                                <span
413                                    ref={&self.copy_ref}
414                                    class="hover-target"
415                                    onmousedown={oncopy}
416                                >
417                                    <span id="copy" class="button">
418                                        <span class="icon" />
419                                        <span class="icon-label" />
420                                    </span>
421                                </span>
422                            </div>
423                        }
424                        if !is_settings_open {
425                            <div id="close_button" class="noselect" onmousedown={onclose}>
426                                <span class="icon" />
427                            </div>
428                        }
429                    </div>
430                    <PortalModal
431                        tag_name="perspective-new-panel-menu"
432                        sheet={StyleSurface::DropdownMenu.sheet()}
433                        target={self.new_menu.as_ref().map(|(target, ..)| target.clone())}
434                        own_focus=true
435                        on_close={on_close_new}
436                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
437                    >
438                        if let Some((_, tables, panels)) = &self.new_menu {
439                            <NewPanelMenu
440                                tables={Some(tables.clone())}
441                                panels={panels.clone()}
442                                callback={on_new_select}
443                            />
444                        }
445                    </PortalModal>
446                    <PortalModal
447                        tag_name="perspective-copy-menu"
448                        sheet={StyleSurface::DropdownMenu.sheet()}
449                        target={self.copy_target.clone()}
450                        own_focus=true
451                        on_close={on_close_copy}
452                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
453                    >
454                        <CopyDropDownMenu renderer={renderer.clone()} callback={on_copy_select} />
455                    </PortalModal>
456                    <PortalModal
457                        tag_name="perspective-export-menu"
458                        sheet={StyleSurface::DropdownMenu.sheet()}
459                        target={self.export_target.clone()}
460                        own_focus=true
461                        on_close={on_close_export}
462                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
463                    >
464                        <ExportDropDownMenu
465                            renderer={renderer.clone()}
466                            session={session.clone()}
467                            callback={on_export_select}
468                        />
469                    </PortalModal>
470                </>
471            }
472        } else {
473            // Settings closed + loaded: no docked status bar. The open-settings
474            // affordance lives on the `PanelTab`s; only the (default-hidden)
475            // eject button floats here.
476            let class = classes!(is_updating_class_name, "floating");
477            html! {
478                <div id={ctx.props().id.clone()} {class}>
479                    <div id="close_button" class="noselect" onmousedown={&onclose} />
480                </div>
481            }
482        }
483    }
484}
485
486#[derive(Properties, PartialEq)]
487struct ThemeSelectorProps {
488    pub theme: Option<String>,
489    pub themes: PtrEqRc<Vec<String>>,
490    pub on_reset: Callback<()>,
491    pub on_change: Callback<String>,
492}
493
494#[function_component]
495fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
496    let is_first = props
497        .theme
498        .as_ref()
499        .and_then(|x| props.themes.first().map(|y| y == x))
500        .unwrap_or_default();
501
502    let values = use_memo(props.themes.clone(), |themes| {
503        themes
504            .iter()
505            .cloned()
506            .map(SelectItem::Option)
507            .collect::<Vec<_>>()
508    });
509
510    match &props.theme {
511        None => html! {},
512        Some(selected) => {
513            html! {
514                if values.len() > 1 {
515                    <span class="hover-target">
516                        <div
517                            id="theme_icon"
518                            class={if is_first {""} else {"modified"}}
519                            tabindex="0"
520                            onclick={props.on_reset.reform(|_| ())}
521                        />
522                        <span id="theme" class="button">
523                            <span class="icon" />
524                            <Select<String>
525                                id="theme_selector"
526                                {values}
527                                selected={selected.to_owned()}
528                                on_select={props.on_change.clone()}
529                            />
530                        </span>
531                    </span>
532                }
533            }
534        },
535    }
536}