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