Skip to main content

perspective_viewer/components/
editable_header.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 itertools::Itertools;
16use web_sys::{FocusEvent, HtmlInputElement, KeyboardEvent};
17use yew::prelude::*;
18
19use super::type_icon::TypeIconType;
20use crate::components::type_icon::TypeIcon;
21use crate::session::SessionMetadataRc;
22
23#[derive(Clone, PartialEq, Properties)]
24pub struct EditableHeaderProps {
25    pub icon_type: Option<TypeIconType>,
26    pub on_change: Callback<(Option<String>, bool)>,
27    pub editable: bool,
28
29    /// The owner's draft.
30    pub value: Option<String>,
31
32    /// The saved name the draft is measured against.
33    pub initial_value: Option<String>,
34    pub placeholder: Rc<String>,
35
36    #[prop_or_default]
37    pub update_on_input: bool,
38
39    /// Session metadata snapshot — threaded from `SessionProps`.
40    pub metadata: SessionMetadataRc,
41}
42
43impl EditableHeaderProps {
44    fn split_placeholder(&self) -> String {
45        let split = self
46            .placeholder
47            .split_once('\n')
48            .map(|(a, _)| a)
49            .unwrap_or(&*self.placeholder);
50
51        match split.char_indices().nth(25) {
52            None => split.to_string(),
53            Some((idx, _)) => split[..idx].to_owned(),
54        }
55    }
56
57    fn is_valid(&self, value: &Option<String>, placeholder: &str) -> bool {
58        let Some(value) = value else {
59            return true;
60        };
61
62        if value == placeholder || Some(value) == self.initial_value.as_ref() {
63            return true;
64        }
65
66        let metadata = &self.metadata;
67        let Some(table_columns) = metadata.get_table_columns() else {
68            return true;
69        };
70
71        !table_columns
72            .iter()
73            .chain(metadata.get_expression_columns())
74            .chain(metadata.get_window_columns())
75            .contains(value)
76    }
77}
78
79#[function_component(EditableHeader)]
80pub fn editable_header(props: &EditableHeaderProps) -> Html {
81    let noderef = use_node_ref();
82    let placeholder = props.split_placeholder();
83    let edited = props.value != props.initial_value;
84    let valid = props.is_valid(&props.value, &placeholder);
85    let mut classes = classes!("sidebar_header_contents");
86    if props.editable {
87        classes.push("editable");
88    }
89
90    if !valid {
91        classes.push("invalid");
92    }
93
94    if edited {
95        classes.push("edited");
96    }
97
98    let on_value = {
99        let props = props.clone();
100        let placeholder = placeholder.clone();
101        Callback::from(move |value: String| {
102            let value = (!value.is_empty()).then_some(value);
103            let valid = props.is_valid(&value, &placeholder);
104            props.on_change.emit((value, valid));
105        })
106    };
107
108    let onkeyup =
109        on_value.reform(|e: KeyboardEvent| e.target_unchecked_into::<HtmlInputElement>().value());
110    let onblur =
111        on_value.reform(|e: FocusEvent| e.target_unchecked_into::<HtmlInputElement>().value());
112    let oninput = {
113        let update_on_input = props.update_on_input;
114        let on_value = on_value.clone();
115        Callback::from(move |e: InputEvent| {
116            if update_on_input {
117                on_value.emit(e.target_unchecked_into::<HtmlInputElement>().value());
118            }
119        })
120    };
121
122    let onclick = {
123        let noderef = noderef.clone();
124        Callback::from(move |_: MouseEvent| {
125            if let Some(input) = noderef.cast::<HtmlInputElement>() {
126                let _ = input.focus();
127            }
128        })
129    };
130
131    html! {
132        <div class={classes} {onclick}>
133            if let Some(icon) = props.icon_type { <TypeIcon ty={icon} /> }
134            <input
135                ref={noderef}
136                type="search"
137                class="sidebar_header_title"
138                disabled={!props.editable}
139                {onblur}
140                {onkeyup}
141                {oninput}
142                value={props.value.clone()}
143                {placeholder}
144            />
145        </div>
146    }
147}