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::{Callback, Component, Html, NodeRef, Properties, TargetCast, classes, html};
18
19use super::type_icon::TypeIconType;
20use crate::components::type_icon::TypeIcon;
21use crate::session::{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    pub initial_value: Option<String>,
29    pub placeholder: Rc<String>,
30
31    // TODO remove this pattern
32    #[prop_or_default]
33    pub reset_count: u8,
34
35    #[prop_or_default]
36    pub update_on_input: bool,
37
38    /// Session metadata snapshot — threaded from `SessionProps`.
39    pub metadata: SessionMetadataRc,
40
41    // State
42    pub session: Session,
43}
44
45impl EditableHeaderProps {
46    fn split_placeholder(&self) -> String {
47        let split = self
48            .placeholder
49            .split_once('\n')
50            .map(|(a, _)| a)
51            .unwrap_or(&*self.placeholder);
52
53        match split.char_indices().nth(25) {
54            None => split.to_string(),
55            Some((idx, _)) => split[..idx].to_owned(),
56        }
57    }
58}
59
60pub enum EditableHeaderMsg {
61    SetNewValue(String),
62    OnClick(()),
63}
64
65#[derive(Debug)]
66pub struct EditableHeader {
67    noderef: NodeRef,
68    edited: bool,
69    valid: bool,
70    value: Option<String>,
71    placeholder: String,
72}
73
74impl Component for EditableHeader {
75    type Message = EditableHeaderMsg;
76    type Properties = EditableHeaderProps;
77
78    fn create(ctx: &yew::prelude::Context<Self>) -> Self {
79        Self {
80            value: ctx.props().initial_value.clone(),
81            placeholder: ctx.props().split_placeholder(),
82            valid: true,
83            noderef: NodeRef::default(),
84            edited: false,
85        }
86    }
87
88    fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
89        if ctx.props().reset_count != old_props.reset_count {
90            self.value.clone_from(&ctx.props().initial_value);
91        }
92        if ctx.props().initial_value != old_props.initial_value {
93            self.edited = false;
94            self.value.clone_from(&ctx.props().initial_value);
95        }
96        if !ctx.props().editable {
97            self.edited = false;
98        }
99        self.placeholder = ctx.props().split_placeholder();
100        ctx.props() != old_props
101    }
102
103    fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
104        match msg {
105            EditableHeaderMsg::SetNewValue(new_value) => {
106                let maybe_value = (!new_value.is_empty()).then_some(new_value.clone());
107                self.edited = ctx.props().initial_value != maybe_value;
108
109                self.valid = (|| -> Option<bool> {
110                    if maybe_value
111                        .as_ref()
112                        .map(|v| v == &self.placeholder)
113                        .unwrap_or(true)
114                    {
115                        return Some(true);
116                    }
117                    if !self.edited {
118                        return Some(true);
119                    }
120                    let metadata = &ctx.props().metadata;
121                    let expressions = metadata.get_expression_columns();
122                    let found = metadata
123                        .get_table_columns()?
124                        .iter()
125                        .chain(expressions)
126                        .contains(&new_value);
127                    Some(!found)
128                })()
129                .unwrap_or(true);
130
131                self.value.clone_from(&maybe_value);
132                ctx.props().on_change.emit((maybe_value, self.valid));
133                true
134            },
135            EditableHeaderMsg::OnClick(()) => {
136                self.noderef
137                    .cast::<HtmlInputElement>()
138                    .unwrap()
139                    .focus()
140                    .unwrap();
141                false
142            },
143        }
144    }
145
146    fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
147        let mut classes = classes!("sidebar_header_contents");
148        if ctx.props().editable {
149            classes.push("editable");
150        }
151
152        if !self.valid {
153            classes.push("invalid");
154        }
155
156        if self.edited {
157            classes.push("edited");
158        }
159
160        let onkeyup = ctx.link().callback(|e: KeyboardEvent| {
161            let value = e.target_unchecked_into::<HtmlInputElement>().value();
162            EditableHeaderMsg::SetNewValue(value)
163        });
164
165        let onblur = ctx.link().callback(|e: FocusEvent| {
166            let value = e.target_unchecked_into::<HtmlInputElement>().value();
167            EditableHeaderMsg::SetNewValue(value)
168        });
169
170        let update_on_input = ctx.props().update_on_input;
171        let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| {
172            if update_on_input {
173                let value = e.target_unchecked_into::<HtmlInputElement>().value();
174                vec![EditableHeaderMsg::SetNewValue(value)]
175            } else {
176                vec![]
177            }
178        });
179
180        html! {
181            <div class={classes} onclick={ctx.link().callback(|_| EditableHeaderMsg::OnClick(()))}>
182                if let Some(icon) = ctx.props().icon_type { <TypeIcon ty={icon} /> }
183                <input
184                    ref={self.noderef.clone()}
185                    type="search"
186                    class="sidebar_header_title"
187                    disabled={!ctx.props().editable}
188                    {onblur}
189                    {onkeyup}
190                    {oninput}
191                    value={self.value.clone()}
192                    placeholder={self.placeholder.clone()}
193                />
194            </div>
195        }
196    }
197}
198
199#[derive(Default, Debug, PartialEq, Copy, Clone)]
200pub enum ValueState {
201    #[default]
202    Unedited,
203    Edited,
204}