perspective_viewer/config/
workspace_config.rs1use std::collections::BTreeMap;
14
15use perspective_client::config::Filter;
16
17use crate::config::{OptionalUpdate, PanelViewerConfig, ViewerConfigInitial};
18use crate::utils::CssKind;
19
20#[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 pub panels: BTreeMap<String, PanelViewerConfig>,
43
44 #[serde(skip_serializing_if = "Vec::is_empty")]
48 #[ts(as = "Option<_>")]
49 #[ts(optional)]
50 pub global_filters: Vec<Filter>,
51
52 #[serde(skip_serializing_if = "Vec::is_empty")]
57 #[ts(as = "Option<_>")]
58 #[ts(optional)]
59 pub masters: Vec<String>,
60
61 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
65 #[ts(as = "Option<_>")]
66 #[ts(optional)]
67 pub palette: BTreeMap<String, String>,
68}
69
70#[derive(serde::Deserialize, ts_rs::TS)]
75pub struct WorkspaceConfigUpdate {
76 #[serde(default)]
79 #[ts(as = "Option<_>")]
80 #[ts(optional)]
81 pub active: OptionalUpdate<String>,
82
83 #[serde(default)]
85 #[ts(optional)]
86 pub layout: Option<crate::js::Layout>,
87
88 #[serde(default)]
92 #[ts(optional)]
93 pub panels: Option<BTreeMap<String, ViewerConfigInitial>>,
94
95 #[serde(default)]
98 #[ts(as = "Option<_>")]
99 #[ts(optional)]
100 pub global_filters: OptionalUpdate<Vec<Filter>>,
101
102 #[serde(default)]
105 #[ts(as = "Option<_>")]
106 #[ts(optional)]
107 pub masters: OptionalUpdate<Vec<String>>,
108
109 #[serde(default)]
113 #[ts(as = "Option<_>")]
114 #[ts(optional)]
115 pub palette: OptionalUpdate<BTreeMap<String, String>>,
116}
117
118pub 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}