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}