perspective_viewer/config/viewer_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::ops::Deref;
14use std::sync::LazyLock;
15
16use perspective_client::config::*;
17use perspective_js::utils::*;
18use serde::{Deserialize, Deserializer, Serialize};
19use serde_json::Value;
20use ts_rs::TS;
21use wasm_bindgen::prelude::*;
22
23use crate::renderer::ColumnConfigMap;
24
25/// The state of an entire `custom_elements::PerspectiveViewerElement` component
26/// and its `Plugin`: the element-level `settings` flag plus the per-panel
27/// [`PanelViewerConfig`]. The split exists so the whole-element config format
28/// can serialize panel entries *without* a `settings` key (it is element-level
29/// state there, carried by the top-level `active` field instead), while the
30/// single-panel format flattens back to the legacy shape.
31#[derive(Debug, Default, Serialize, PartialEq, TS)]
32#[serde(deny_unknown_fields)]
33pub struct ViewerConfig<V: TS = String> {
34 pub settings: bool,
35
36 #[serde(flatten)]
37 pub panel: PanelViewerConfig<V>,
38}
39
40/// The per-panel state of a [`ViewerConfig`] — everything except the
41/// element-level `settings` flag. This is the `panels` entry type of the
42/// whole-element config format.
43#[derive(Debug, Default, Serialize, PartialEq, TS)]
44pub struct PanelViewerConfig<V: TS = String> {
45 /// The `@perspective-dev/viewer` version that wrote this config,
46 /// stamped on save and used to migrate older tokens. Callers do not
47 /// set it.
48 pub version: V,
49
50 /// Per-column styling, keyed by column name. The viewer treats the
51 /// values as opaque — their shape is defined by the ACTIVE plugin,
52 /// so a config written for one plugin may carry keys another
53 /// ignores. Query the live shape with the `get_style_schema` agent
54 /// tool, or the plugin's `column_config_schema()`.
55 pub columns_config: ColumnConfigMap,
56
57 /// Name of the visualization plugin, from the set registered on the
58 /// page (the `list_plugins` agent tool, or the plugin picker). This
59 /// also decides what the view fields MEAN visually: `columns` is
60 /// positional and each plugin reads the positions differently, and
61 /// `group_by`/`split_by` draw different things per plugin.
62 pub plugin: String,
63
64 /// Plugin-wide settings (as opposed to the per-column
65 /// [`Self::columns_config`]). Opaque to the viewer and defined by
66 /// the active plugin; see `get_style_schema`.
67 pub plugin_config: serde_json::Map<String, Value>,
68
69 /// Name of the `Table` this panel renders, as hosted on the panel's
70 /// `Client`. Every placed panel has a table binding (creation
71 /// requires one by type — [`ViewerConfigInitial`]), so the saved
72 /// config carries it unconditionally.
73 pub table: String,
74
75 /// Selected theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid
76 /// names are the Perspective themes loaded on the page, which
77 /// `resetThemes()` re-scans. `None` selects the first available.
78 pub theme: Option<String>,
79
80 /// Panel title, shown in its tab. `None` renders the default title.
81 pub title: Option<String>,
82
83 #[serde(flatten)]
84 pub view_config: ViewConfig,
85}
86
87impl<V: TS> Deref for ViewerConfig<V> {
88 type Target = PanelViewerConfig<V>;
89
90 fn deref(&self) -> &Self::Target {
91 &self.panel
92 }
93}
94
95pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| {
96 #[derive(Deserialize)]
97 struct Package {
98 version: &'static str,
99 }
100 let pkg: &'static str = include_str!("../../../package.json");
101 let pkg: Package = serde_json::from_str(pkg).unwrap();
102 pkg.version
103});
104
105impl ViewerConfig {
106 /// Encode a `ViewerConfig` to a `JsValue` in a supported type.
107 pub fn encode(&self) -> ApiResult<JsValue> {
108 Ok(JsValue::from_serde_ext(self)?)
109 }
110}
111
112#[derive(Clone, Debug, TS, Deserialize, PartialEq, Serialize)]
113#[serde(transparent)]
114pub struct PluginConfig(serde_json::Value);
115
116impl Deref for PluginConfig {
117 type Target = Value;
118
119 fn deref(&self) -> &Self::Target {
120 &self.0
121 }
122}
123
124#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
125pub struct ViewerConfigUpdate {
126 /// The `@perspective-dev/viewer` version a saved config was written
127 /// by, used to migrate older tokens. Omit it — the viewer stamps
128 /// the current version on save.
129 #[serde(default)]
130 #[ts(as = "Option<_>")]
131 #[ts(optional)]
132 pub version: VersionUpdate,
133
134 /// Name of the visualization plugin to switch to, from the set
135 /// registered on the page (the `list_plugins` agent tool, or the
136 /// plugin picker). Changing it changes what the view fields MEAN:
137 /// `columns` is positional and every plugin reads the positions
138 /// differently, and `group_by`/`split_by` draw different things per
139 /// plugin — so read the new plugin's roles before writing `columns`
140 /// for it.
141 #[serde(default)]
142 #[ts(as = "Option<_>")]
143 #[ts(optional)]
144 pub plugin: PluginUpdate,
145
146 /// Panel title, shown in its tab. `null` restores the default
147 /// title; omitting the field leaves the current one.
148 #[serde(default)]
149 #[ts(as = "Option<_>")]
150 #[ts(optional)]
151 pub title: TitleUpdate,
152
153 /// Name of the `Table` to render, as hosted on this panel's
154 /// `Client`. Rebinding an existing panel to another table keeps the
155 /// rest of the config, so column names that do not exist in the new
156 /// table will fail validation.
157 #[serde(default)]
158 #[ts(as = "Option<_>")]
159 #[ts(optional)]
160 pub table: TableUpdate,
161
162 /// Theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid names are
163 /// the Perspective themes loaded on the page, re-scanned by
164 /// `resetThemes()`. `null` selects the default.
165 #[serde(default)]
166 #[ts(as = "Option<_>")]
167 #[ts(optional)]
168 pub theme: ThemeUpdate,
169
170 /// Whether the settings sidebar is OPEN. Purely cosmetic chrome —
171 /// it does not affect what the viewer renders, and it is
172 /// element-level rather than per-panel.
173 #[serde(default)]
174 #[ts(as = "Option<_>")]
175 #[ts(optional)]
176 pub settings: SettingsUpdate,
177
178 /// Plugin-wide settings (as opposed to the per-column
179 /// [`Self::columns_config`]). The viewer passes these through
180 /// opaquely — their valid keys are defined by the ACTIVE plugin and
181 /// vary by plugin and by state, so query them with the
182 /// `get_style_schema` agent tool rather than guessing.
183 #[serde(default)]
184 #[ts(as = "Option<_>")]
185 #[ts(optional)]
186 pub plugin_config: PluginConfigUpdate,
187
188 /// Per-column styling — formatting, colors, and other per-column
189 /// controls — keyed by column name. Opaque to the viewer and
190 /// plugin-defined like [`Self::plugin_config`]; `get_style_schema`
191 /// reports the valid keys for a given column under the active
192 /// plugin.
193 #[serde(default)]
194 #[ts(as = "Option<_>")]
195 #[ts(optional)]
196 pub columns_config: ColumnConfigUpdate,
197
198 #[serde(flatten)]
199 pub view_config: ViewConfigUpdate,
200}
201
202impl ViewerConfigUpdate {
203 /// Decode a `JsValue` into a `ViewerConfigUpdate` by auto-detecting format
204 /// from JavaScript type.
205 pub fn decode(update: &JsValue) -> ApiResult<Self> {
206 Ok(update.into_serde_ext()?)
207 }
208
209 pub fn migrate(&self) -> ApiResult<Self> {
210 // TODO: Call the migrate script from js
211 Ok(self.clone())
212 }
213}
214
215/// The initial configuration of a NEW panel (`addPanel`, `restore`'s
216/// panel-creating upsert, `restoreWorkspace` `panels` entries). Unlike
217/// [`ViewerConfigUpdate`] — a patch against existing state — creation has
218/// no prior state: `table` is REQUIRED (a placed panel without a table
219/// binding would be permanently blank), absent fields mean "default"
220/// rather than "leave unchanged" (so no [`OptionalUpdate`] tri-state), and
221/// there is no `settings` field (element-level, not per-panel).
222#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
223pub struct ViewerConfigInitial {
224 /// Name of the `Table` the new panel renders, as hosted on the
225 /// `Client`. REQUIRED: a placed panel with no table binding would be
226 /// permanently blank.
227 pub table: String,
228
229 /// The `@perspective-dev/viewer` version a saved config was written
230 /// by. Omit it when creating a panel.
231 #[ts(optional)]
232 pub version: Option<String>,
233
234 /// Name of the visualization plugin, from the set registered on the
235 /// page (the `list_plugins` agent tool, or the plugin picker).
236 /// Decides what the view fields MEAN: `columns` is positional and
237 /// every plugin reads the positions differently, and
238 /// `group_by`/`split_by` draw different things per plugin. Absent
239 /// uses the default plugin.
240 #[ts(optional)]
241 pub plugin: Option<String>,
242
243 /// Panel title, shown in its tab. Absent renders the default title.
244 #[ts(optional)]
245 pub title: Option<String>,
246
247 /// Theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid names are
248 /// the Perspective themes loaded on the page. Absent uses the
249 /// default.
250 #[ts(optional)]
251 pub theme: Option<String>,
252
253 /// Plugin-wide settings (as opposed to the per-column
254 /// [`Self::columns_config`]). Opaque to the viewer and defined by
255 /// the ACTIVE plugin; query the valid keys with `get_style_schema`
256 /// rather than guessing.
257 #[ts(optional)]
258 pub plugin_config: Option<serde_json::Map<String, Value>>,
259
260 /// Per-column styling — formatting, colors, and other per-column
261 /// controls — keyed by column name. Plugin-defined like
262 /// [`Self::plugin_config`]; see `get_style_schema`.
263 #[ts(optional)]
264 pub columns_config: Option<ColumnConfigMap>,
265
266 #[serde(flatten)]
267 pub view_config: ViewConfigUpdate,
268}
269
270impl ViewerConfigInitial {
271 /// Decode a `JsValue` by auto-detecting format from JavaScript type.
272 pub fn decode(config: &JsValue) -> ApiResult<Self> {
273 Ok(config.into_serde_ext()?)
274 }
275
276 /// A default-config panel bound to `table` (the "New panel" menus).
277 /// Exhaustive on purpose — same drift alarm as the `From` impl below.
278 pub fn new(table: impl Into<String>) -> Self {
279 Self {
280 table: table.into(),
281 version: None,
282 plugin: None,
283 title: None,
284 theme: None,
285 plugin_config: None,
286 columns_config: None,
287 view_config: ViewConfigUpdate::default(),
288 }
289 }
290}
291
292fn up<T: Clone>(value: Option<T>) -> OptionalUpdate<T> {
293 match value {
294 Some(value) => OptionalUpdate::Update(value),
295 None => OptionalUpdate::Missing,
296 }
297}
298
299// Constructed EXHAUSTIVELY on purpose: a field added to
300// `ViewerConfigUpdate` breaks this impl at compile time, forcing the
301// "should creation carry it?" decision (the drift alarm).
302impl From<ViewerConfigInitial> for ViewerConfigUpdate {
303 fn from(value: ViewerConfigInitial) -> Self {
304 ViewerConfigUpdate {
305 version: up(value.version),
306 plugin: up(value.plugin),
307 title: up(value.title),
308 table: OptionalUpdate::Update(value.table),
309 theme: up(value.theme),
310 settings: OptionalUpdate::Missing,
311 plugin_config: up(value.plugin_config),
312 columns_config: up(value.columns_config),
313 view_config: value.view_config,
314 }
315 }
316}
317
318// There is deliberately NO `TryFrom<ViewerConfigUpdate>` here. Requiring a
319// `table` is a property of the CREATION ENTRY POINTS — `addPanel`'s
320// argument type, `WorkspaceConfigUpdate::panels`, and the agent's
321// `add_panel` decode — each of which already has a `ViewerConfigInitial`
322// in hand. An update→initial conversion exists only to let a route holding
323// a PATCH pretend it is creating from scratch, which is how `restore`'s
324// upsert acquired the gate and started rejecting the table-less
325// restore-then-`load` contract. Its absence is what keeps that from
326// recurring: `create_panel` takes an update, so no caller needs one.
327
328impl std::fmt::Display for ViewerConfigUpdate {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 write!(
331 f,
332 "{}",
333 serde_json::to_string(self).map_err(|_| std::fmt::Error)?
334 )
335 }
336}
337
338#[derive(Clone, Debug, Serialize, PartialEq, TS)]
339#[serde(untagged)]
340// #[ts(untagged)]
341pub enum OptionalUpdate<T: Clone> {
342 #[ts(skip)]
343 SetDefault,
344
345 // #[ts(skip)]
346 // #[ts(type = "undefined")]
347 Missing,
348
349 // #[ts(type = "_")]
350 // #[ts(untagged)]
351 Update(T),
352}
353
354pub type PluginUpdate = OptionalUpdate<String>;
355pub type SettingsUpdate = OptionalUpdate<bool>;
356pub type ThemeUpdate = OptionalUpdate<String>;
357pub type TitleUpdate = OptionalUpdate<String>;
358pub type TableUpdate = OptionalUpdate<String>;
359pub type VersionUpdate = OptionalUpdate<String>;
360pub type ColumnConfigUpdate = OptionalUpdate<ColumnConfigMap>;
361pub type PluginConfigUpdate = OptionalUpdate<serde_json::Map<String, Value>>;
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn initial_requires_a_table() {
369 let json = serde_json::json!({ "group_by": ["State"] });
370 let err = serde_json::from_value::<ViewerConfigInitial>(json).unwrap_err();
371 assert!(format!("{err}").contains("table"));
372 }
373
374 /// The creation → patch direction is the only one that exists (see the
375 /// note above the `From` impl); a `table` becomes a concrete update and
376 /// the element-level `settings` is not carried.
377 #[test]
378 fn initial_widens_to_an_update() {
379 let json = serde_json::json!({
380 "table": "superstore",
381 "plugin": "Datagrid",
382 "group_by": ["State"],
383 });
384
385 let initial: ViewerConfigInitial = serde_json::from_value(json).unwrap();
386 let update = ViewerConfigUpdate::from(initial);
387 assert!(matches!(&update.table, OptionalUpdate::Update(x) if x == "superstore"));
388 assert!(matches!(&update.settings, OptionalUpdate::Missing));
389 assert!(matches!(&update.plugin, OptionalUpdate::Update(x) if x == "Datagrid"));
390 assert_eq!(
391 update.view_config.group_by.as_deref(),
392 Some(&["State".to_owned()][..])
393 );
394 }
395
396 /// A table-less patch is a legitimate creation input now that
397 /// `create_panel` takes an update — the deferred panel a `load()`
398 /// binds. Nothing in this module may reject it.
399 #[test]
400 fn a_table_less_update_is_representable() {
401 let json = serde_json::json!({ "group_by": ["State"] });
402 let update: ViewerConfigUpdate = serde_json::from_value(json).unwrap();
403 assert!(matches!(&update.table, OptionalUpdate::Missing));
404 }
405}
406
407/// Handles `{}` when included as a field with `#[serde(default)]`.
408impl<T: Clone> Default for OptionalUpdate<T> {
409 fn default() -> Self {
410 Self::Missing
411 }
412}
413
414/// Handles `{plugin: null}` and `{plugin: val}` by treating this type as an
415/// option.
416impl<T: Clone> From<Option<T>> for OptionalUpdate<T> {
417 fn from(opt: Option<T>) -> Self {
418 match opt {
419 Some(v) => Self::Update(v),
420 None => Self::SetDefault,
421 }
422 }
423}
424
425/// Treats `PluginUpdate` enum as an `Option<T>` when present during
426/// deserialization.
427impl<'a, T> Deserialize<'a> for OptionalUpdate<T>
428where
429 T: Deserialize<'a> + Clone,
430{
431 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
432 where
433 D: Deserializer<'a>,
434 {
435 Option::deserialize(deserializer).map(Into::into)
436 }
437}