Skip to main content

perspective_viewer/config/
workspace_config.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::collections::BTreeMap;
14
15use perspective_client::config::Filter;
16
17use crate::config::{OptionalUpdate, PanelViewerConfig, ViewerConfigInitial};
18use crate::utils::CssKind;
19
20/// The workspace config format (`{version, active?, layout, panels}`) —
21/// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as
22/// emitted by [`PerspectiveViewerElement::save`].
23///
24/// - `panels` entries are [`PanelViewerConfig`]s: per-panel state only, no
25///   `settings` key (element-level state).
26/// - `active` names the panel targeted by the *open* settings sidebar; it is
27///   omitted when the sidebar is closed.
28#[derive(serde::Serialize, ts_rs::TS)]
29pub struct WorkspaceConfig {
30    pub version: String,
31
32    #[serde(skip_serializing_if = "Option::is_none")]
33    #[ts(optional)]
34    pub active: Option<String>,
35
36    pub layout: Option<crate::js::Layout>,
37
38    /// `BTreeMap` (not `HashMap`) so `save()` serializes panels in a
39    /// DETERMINISTIC (sorted) key order — a fresh `HashMap` per call
40    /// iterates in a per-instance random order, which made consecutive
41    /// `save()` outputs byte-unstable.
42    pub panels: BTreeMap<String, PanelViewerConfig>,
43
44    /// The element-level global (master/detail cross-) filters. A transient
45    /// overlay on every detail panel's view — persisted here, never in a
46    /// per-panel entry. Omitted when empty.
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    #[ts(as = "Option<_>")]
49    #[ts(optional)]
50    pub global_filters: Vec<Filter>,
51
52    /// The MASTER (filter-source) panels' ids, referencing `panels` keys.
53    /// Roles are layout state (like the panel arrangement), so they persist;
54    /// which master contributed which clause does not — restored
55    /// `global_filters` are one unattributed bucket. Omitted when empty.
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    #[ts(as = "Option<_>")]
58    #[ts(optional)]
59    pub masters: Vec<String>,
60
61    /// Named color-scale definitions shared by every panel: CSS custom
62    /// property name (`--psp-user--<kind>-<name>`) → canonical CSS
63    /// value.
64    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
65    #[ts(as = "Option<_>")]
66    #[ts(optional)]
67    pub palette: BTreeMap<String, String>,
68}
69
70/// The [`PerspectiveViewerElement::restoreWorkspace`] argument: a field-wise
71/// update of the element's workspace state, like [`ViewerConfigUpdate`] for a
72/// panel, where an absent key is unchanged, `null` resets and a value
73/// replaces.
74#[derive(serde::Deserialize, ts_rs::TS)]
75pub struct WorkspaceConfigUpdate {
76    /// The panel to activate with the settings sidebar open, by `panels` key
77    /// or existing panel id; `null` closes the sidebar.
78    #[serde(default)]
79    #[ts(as = "Option<_>")]
80    #[ts(optional)]
81    pub active: OptionalUpdate<String>,
82
83    /// The layout tree to stage, naming `panels` keys or existing panel ids.
84    #[serde(default)]
85    #[ts(optional)]
86    pub layout: Option<crate::js::Layout>,
87
88    /// The complete replacement panel set, each [`ViewerConfigInitial`] entry
89    /// creating a new panel (`{}` empties the element); absent keeps the
90    /// existing panels.
91    #[serde(default)]
92    #[ts(optional)]
93    pub panels: Option<BTreeMap<String, ViewerConfigInitial>>,
94
95    /// The element-level cross-filter set applied to every detail panel as
96    /// one unattributed bucket, replacing the current set; `null` clears it.
97    #[serde(default)]
98    #[ts(as = "Option<_>")]
99    #[ts(optional)]
100    pub global_filters: OptionalUpdate<Vec<Filter>>,
101
102    /// The master (filter-source) panels by `panels` key or existing panel
103    /// id, unknown ids dropped with a warning; `null` demotes every panel.
104    #[serde(default)]
105    #[ts(as = "Option<_>")]
106    #[ts(optional)]
107    pub masters: OptionalUpdate<Vec<String>>,
108
109    /// Named color-scale definitions applied to the host (see
110    /// [`WorkspaceConfig::palette`]), replacing the previous palette; `null`
111    /// clears it.
112    #[serde(default)]
113    #[ts(as = "Option<_>")]
114    #[ts(optional)]
115    pub palette: OptionalUpdate<BTreeMap<String, String>>,
116}
117
118/// Validate a restored palette map: each key's `--psp-user--<kind>-`
119/// prefix selects the reader that canonicalizes its value.
120pub fn validate_palette(
121    palette: BTreeMap<String, String>,
122) -> Result<BTreeMap<String, String>, String> {
123    palette
124        .into_iter()
125        .map(|(name, value)| {
126            let kind = CssKind::of_var(&name).ok_or_else(|| {
127                format!(
128                    "`palette` key `{name}` must start with `--psp-user--gradient-`, \
129                     `--psp-user--palette-` or `--psp-user--color-`"
130                )
131            })?;
132
133            let canonical = kind
134                .canonicalize(&value)
135                .map_err(|error| format!("`palette[\"{name}\"]`: {error}"))?;
136
137            Ok((name, canonical))
138        })
139        .collect()
140}
141
142#[cfg(test)]
143mod tests {
144    use serde_json::json;
145
146    use super::*;
147
148    #[test]
149    fn palette_serializes_only_when_present() {
150        let config = WorkspaceConfig {
151            version: "x".to_owned(),
152            active: None,
153            layout: None,
154            panels: BTreeMap::new(),
155            global_filters: vec![],
156            masters: vec![],
157            palette: BTreeMap::new(),
158        };
159
160        assert_eq!(
161            serde_json::to_value(&config).unwrap(),
162            json!({ "version": "x", "layout": null, "panels": {} })
163        );
164
165        let mut palette = BTreeMap::new();
166        palette.insert("--psp-user--color-hot".to_owned(), "#ff0000".to_owned());
167
168        let config = WorkspaceConfig { palette, ..config };
169        assert_eq!(
170            serde_json::to_value(&config).unwrap(),
171            json!({
172                "version": "x",
173                "layout": null,
174                "panels": {},
175                "palette": { "--psp-user--color-hot": "#ff0000" },
176            })
177        );
178    }
179
180    #[test]
181    fn update_absent_fields_are_missing_and_null_resets() {
182        let update: WorkspaceConfigUpdate = serde_json::from_value(json!({})).unwrap();
183        assert!(update.panels.is_none());
184        assert!(update.layout.is_none());
185        assert_eq!(update.active, OptionalUpdate::Missing);
186        assert_eq!(update.global_filters, OptionalUpdate::Missing);
187        assert_eq!(update.masters, OptionalUpdate::Missing);
188        assert_eq!(update.palette, OptionalUpdate::Missing);
189
190        let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
191            "panels": null,
192            "active": null,
193            "global_filters": null,
194            "masters": null,
195            "palette": null,
196        }))
197        .unwrap();
198
199        assert!(update.panels.is_none());
200        assert_eq!(update.active, OptionalUpdate::SetDefault);
201        assert_eq!(update.global_filters, OptionalUpdate::SetDefault);
202        assert_eq!(update.masters, OptionalUpdate::SetDefault);
203        assert_eq!(update.palette, OptionalUpdate::SetDefault);
204
205        let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
206            "panels": {},
207            "global_filters": [["Region", "==", "West"]],
208            "masters": ["a"],
209        }))
210        .unwrap();
211
212        assert_eq!(update.panels.map(|p| p.len()), Some(0));
213        assert_eq!(update.masters, OptionalUpdate::Update(vec!["a".to_owned()]));
214        assert!(matches!(update.global_filters, OptionalUpdate::Update(ref f) if f.len() == 1));
215    }
216
217    #[test]
218    fn update_palette_defaults_empty_and_validates_by_prefix() {
219        let update: WorkspaceConfigUpdate =
220            serde_json::from_value(json!({ "panels": {} })).unwrap();
221        assert_eq!(update.palette, OptionalUpdate::Missing);
222
223        let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
224            "panels": {},
225            "palette": {
226                "--psp-user--gradient-1": "linear-gradient(#000, #fff)",
227                "--psp-user--palette-warm": "linear-gradient(90deg, RGB(255,0,0), #ff0)",
228                "--psp-user--color-hot": "#F00",
229            },
230        }))
231        .unwrap();
232
233        let OptionalUpdate::Update(palette) = update.palette else {
234            panic!("palette missing");
235        };
236
237        let valid = validate_palette(palette).unwrap();
238        assert_eq!(
239            valid.get("--psp-user--gradient-1").unwrap(),
240            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
241        );
242        assert_eq!(
243            valid.get("--psp-user--palette-warm").unwrap(),
244            "linear-gradient(to right, #ff0000, #ffff00)"
245        );
246        assert_eq!(valid.get("--psp-user--color-hot").unwrap(), "#ff0000");
247
248        let bad = |name: &str, value: &str| {
249            let mut map = BTreeMap::new();
250            map.insert(name.to_owned(), value.to_owned());
251            validate_palette(map).unwrap_err()
252        };
253
254        assert!(bad("--psp-user--other-1", "#ff0000").contains("--psp-user--other-1"));
255        assert!(bad("--psp-charts--gradient", "#ff0000").contains("must start with"));
256        assert!(
257            bad(
258                "--psp-user--palette-1",
259                "linear-gradient(#000 0%, #fff 100%)"
260            )
261            .contains("--psp-user--palette-1")
262        );
263        assert!(bad("--psp-user--gradient-1", "#ff0000").contains("linear-gradient"));
264        assert!(bad("--psp-user--color-1", "red").contains("red"));
265    }
266}