perspective_viewer/components/
status_bar.rs1use 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 pub id: String,
42
43 pub on_reset: Callback<bool>,
45
46 pub on_new_panel: Callback<NewPanelPick>,
48
49 pub session_props: SessionProps,
53 pub presentation_props: PresentationProps,
54
55 pub is_settings_open: bool,
59
60 pub update_count: u32,
62
63 pub global_filters: Vec<Filter>,
66
67 pub on_remove_global_filter: Callback<usize>,
69
70 pub on_clear_global_filters: Callback<()>,
72
73 pub session: Session,
75 pub renderer: Renderer,
76 pub presentation: Presentation,
77
78 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 NewPanel,
101
102 OpenNewPanel(u32, HostedTables),
105 CloseNewPanel,
106
107 NewPanelPick(NewPanelPick),
109 Export,
110 Copy,
111 CloseExport,
112 CloseCopy,
113 Eject,
114 SetTheme(String),
115 ResetTheme,
116 PointerEvent(web_sys::PointerEvent),
117}
118
119pub 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 new_menu: Option<(HtmlElement, HostedTables, PanelLabels)>,
130
131 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 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 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 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}