Skip to main content

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}