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 workspace 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/// workspace 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/// The rejection every panel-creating route without a `table` resolves to.
319pub const CREATE_REQUIRES_TABLE: &str = "Cannot create a panel without a `table` — `load()` a \
320                                         `Client` and include `table` in the config, or use \
321                                         `addPanel()`";
322
323fn down<T: Clone>(value: OptionalUpdate<T>) -> Option<T> {
324    match value {
325        OptionalUpdate::Update(value) => Some(value),
326        OptionalUpdate::Missing | OptionalUpdate::SetDefault => None,
327    }
328}
329
330impl TryFrom<ViewerConfigUpdate> for ViewerConfigInitial {
331    type Error = ApiError;
332
333    fn try_from(value: ViewerConfigUpdate) -> Result<Self, Self::Error> {
334        // Exhaustive (no `..`) on purpose — the same drift alarm as the
335        // `From<ViewerConfigInitial>` impl above. `settings` is element-level
336        // state, stripped by `restore()` before resolution; discarded here.
337        let ViewerConfigUpdate {
338            version,
339            plugin,
340            title,
341            table,
342            theme,
343            settings: _settings,
344            plugin_config,
345            columns_config,
346            view_config,
347        } = value;
348
349        let OptionalUpdate::Update(table) = table else {
350            return Err(ApiError::new(CREATE_REQUIRES_TABLE));
351        };
352
353        Ok(Self {
354            table,
355            version: down(version),
356            plugin: down(plugin),
357            title: down(title),
358            theme: down(theme),
359            plugin_config: down(plugin_config),
360            columns_config: down(columns_config),
361            view_config,
362        })
363    }
364}
365
366impl std::fmt::Display for ViewerConfigUpdate {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        write!(
369            f,
370            "{}",
371            serde_json::to_string(self).map_err(|_| std::fmt::Error)?
372        )
373    }
374}
375
376#[derive(Clone, Debug, Serialize, PartialEq, TS)]
377#[serde(untagged)]
378// #[ts(untagged)]
379pub enum OptionalUpdate<T: Clone> {
380    #[ts(skip)]
381    SetDefault,
382
383    // #[ts(skip)]
384    // #[ts(type = "undefined")]
385    Missing,
386
387    // #[ts(type = "_")]
388    // #[ts(untagged)]
389    Update(T),
390}
391
392pub type PluginUpdate = OptionalUpdate<String>;
393pub type SettingsUpdate = OptionalUpdate<bool>;
394pub type ThemeUpdate = OptionalUpdate<String>;
395pub type TitleUpdate = OptionalUpdate<String>;
396pub type TableUpdate = OptionalUpdate<String>;
397pub type VersionUpdate = OptionalUpdate<String>;
398pub type ColumnConfigUpdate = OptionalUpdate<ColumnConfigMap>;
399pub type PluginConfigUpdate = OptionalUpdate<serde_json::Map<String, Value>>;
400
401/// Handles `{}` when included as a field with `#[serde(default)]`.
402impl<T: Clone> Default for OptionalUpdate<T> {
403    fn default() -> Self {
404        Self::Missing
405    }
406}
407
408/// Handles `{plugin: null}` and `{plugin: val}` by treating this type as an
409/// option.
410impl<T: Clone> From<Option<T>> for OptionalUpdate<T> {
411    fn from(opt: Option<T>) -> Self {
412        match opt {
413            Some(v) => Self::Update(v),
414            None => Self::SetDefault,
415        }
416    }
417}
418
419/// Treats `PluginUpdate` enum as an `Option<T>` when present during
420/// deserialization.
421impl<'a, T> Deserialize<'a> for OptionalUpdate<T>
422where
423    T: Deserialize<'a> + Clone,
424{
425    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
426    where
427        D: Deserializer<'a>,
428    {
429        Option::deserialize(deserializer).map(Into::into)
430    }
431}