@perspective-dev/viewer 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  43. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  44. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  45. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  46. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  47. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  50. package/dist/wasm/perspective-viewer.d.ts +126 -35
  51. package/dist/wasm/perspective-viewer.js +320 -185
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  54. package/package.json +33 -20
  55. package/src/css/column-selector.css +205 -50
  56. package/src/css/ui/containers/context-menu.css +12 -1
  57. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  58. package/src/css/ui/containers/scroll-panel.css +4 -1
  59. package/src/rust/agent/client/protocol.rs +0 -89
  60. package/src/rust/agent/markdown/mod.rs +0 -238
  61. package/src/rust/agent/preamble.md +1 -0
  62. package/src/rust/agent/tools.rs +2 -16
  63. package/src/rust/components/column_selector/active_column.rs +15 -5
  64. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  65. package/src/rust/components/column_selector/config_selector.rs +34 -6
  66. package/src/rust/components/column_selector/derived_control.rs +229 -0
  67. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  68. package/src/rust/components/column_selector/filter_column.rs +0 -2
  69. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  70. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  71. package/src/rust/components/column_selector/sort_column.rs +4 -3
  72. package/src/rust/components/column_selector.rs +21 -1
  73. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  74. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  75. package/src/rust/components/column_settings_sidebar.rs +25 -19
  76. package/src/rust/components/debug_panel.rs +38 -29
  77. package/src/rust/components/dragdrop_list.rs +8 -9
  78. package/src/rust/components/export_dropdown.rs +6 -0
  79. package/src/rust/components/main_panel/msg.rs +6 -4
  80. package/src/rust/components/main_panel/reconcile.rs +6 -1
  81. package/src/rust/components/main_panel/render.rs +70 -51
  82. package/src/rust/components/main_panel/update.rs +39 -1
  83. package/src/rust/components/main_panel.rs +97 -59
  84. package/src/rust/components/new_panel_menu.rs +69 -1
  85. package/src/rust/components/panel_menu.rs +63 -12
  86. package/src/rust/components/plugin_tab.rs +3 -2
  87. package/src/rust/components/render_warning.rs +2 -1
  88. package/src/rust/components/settings_panel.rs +4 -4
  89. package/src/rust/components/status_bar.rs +80 -21
  90. package/src/rust/components/status_indicator.rs +8 -1
  91. package/src/rust/components/viewer/filters.rs +15 -3
  92. package/src/rust/components/viewer/msg.rs +8 -0
  93. package/src/rust/components/viewer/panels.rs +44 -4
  94. package/src/rust/components/viewer/render.rs +1 -0
  95. package/src/rust/components/viewer/settings.rs +0 -22
  96. package/src/rust/components/viewer/snapshots.rs +2 -2
  97. package/src/rust/components/viewer/wiring.rs +18 -1
  98. package/src/rust/components/viewer.rs +47 -27
  99. package/src/rust/components/window_editor.rs +5 -4
  100. package/src/rust/config/column_config_schema.rs +38 -377
  101. package/src/rust/config/export_method.rs +24 -0
  102. package/src/rust/config/number_string_format.rs +0 -94
  103. package/src/rust/config/options.rs +3 -7
  104. package/src/rust/config/plugin_static_config.rs +0 -42
  105. package/src/rust/config/viewer_config.rs +47 -17
  106. package/src/rust/config/workspace_config.rs +158 -128
  107. package/src/rust/custom_elements/viewer.rs +189 -129
  108. package/src/rust/custom_events.rs +33 -18
  109. package/src/rust/derived.rs +266 -0
  110. package/src/rust/exprtk/tokenize.rs +0 -54
  111. package/src/rust/js/plugin.rs +1 -1
  112. package/src/rust/lib.rs +3 -0
  113. package/src/rust/presentation.rs +23 -4
  114. package/src/rust/queries/column_locator.rs +55 -48
  115. package/src/rust/queries/get_viewer_config.rs +1 -1
  116. package/src/rust/queries/plugin_column_styles.rs +22 -23
  117. package/src/rust/renderer/dispatch.rs +2 -0
  118. package/src/rust/renderer/plugin_config.rs +652 -487
  119. package/src/rust/renderer/props.rs +4 -0
  120. package/src/rust/renderer/registry.rs +2 -6
  121. package/src/rust/renderer/state.rs +205 -0
  122. package/src/rust/renderer.rs +201 -59
  123. package/src/rust/session/metadata.rs +35 -19
  124. package/src/rust/session/op_queue.rs +430 -0
  125. package/src/rust/session/panel_state.rs +327 -0
  126. package/src/rust/session/view_subscription.rs +42 -3
  127. package/src/rust/session.rs +931 -419
  128. package/src/rust/tasks/apply_global_filters.rs +49 -35
  129. package/src/rust/tasks/auto_pause.rs +22 -12
  130. package/src/rust/tasks/copy_export.rs +72 -0
  131. package/src/rust/tasks/create_panel.rs +46 -21
  132. package/src/rust/tasks/derive.rs +312 -0
  133. package/src/rust/tasks/mod.rs +6 -2
  134. package/src/rust/tasks/pipeline.rs +152 -89
  135. package/src/rust/tasks/reset_all.rs +31 -20
  136. package/src/rust/tasks/restore_derived.rs +200 -0
  137. package/src/rust/tasks/restore_panel.rs +153 -141
  138. package/src/rust/tasks/send_column_config.rs +56 -29
  139. package/src/rust/tasks/send_plugin_config.rs +52 -23
  140. package/src/rust/tasks/set_edit_mode.rs +33 -25
  141. package/src/rust/tasks/sync_update_panels.rs +40 -13
  142. package/src/rust/tasks/table_lifecycle.rs +70 -34
  143. package/src/rust/tasks/transactional_restore.rs +387 -0
  144. package/src/rust/tasks/update_theme.rs +45 -6
  145. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  146. package/src/rust/ui/containers/split_panel.rs +0 -41
  147. package/src/rust/ui/context_menu.rs +157 -18
  148. package/src/rust/ui/font_loader.rs +23 -35
  149. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  150. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  151. package/src/rust/ui/form/palette_selector.rs +0 -51
  152. package/src/rust/ui/portal.rs +4 -10
  153. package/src/rust/utils/browser/mod.rs +2 -3
  154. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  155. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  156. package/src/rust/utils/css.rs +0 -360
  157. package/src/rust/utils/{tests/mod.rs → in_flight.rs} +62 -2
  158. package/src/rust/utils/local_fonts.rs +0 -36
  159. package/src/rust/utils/mod.rs +4 -3
  160. package/src/rust/utils/modal_position.rs +15 -21
  161. package/src/rust/utils/pending_effects.rs +6 -30
  162. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  163. package/src/rust/utils/pubsub.rs +0 -1
  164. package/src/rust/workspace.rs +187 -140
  165. package/src/svg/derived.svg +6 -0
  166. package/src/themes/icons.css +1 -0
  167. package/src/themes/intl/ar.css +10 -0
  168. package/src/themes/intl/de.css +10 -0
  169. package/src/themes/intl/es.css +10 -0
  170. package/src/themes/intl/fr.css +10 -0
  171. package/src/themes/intl/ja.css +10 -0
  172. package/src/themes/intl/pt.css +10 -0
  173. package/src/themes/intl/zh.css +10 -0
  174. package/src/themes/intl.css +10 -0
  175. package/src/themes/monokai.css +1 -1
  176. package/src/themes/vaporwave.css +1 -1
  177. package/src/ts/extensions.ts +3 -36
  178. package/src/ts/perspective-viewer.ts +1 -1
  179. package/src/ts/plugin.ts +4 -1
  180. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  181. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  182. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  183. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  184. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  185. package/src/ts/ts-rs/TableRef.ts +9 -0
  186. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  187. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  188. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  189. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  190. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  191. package/dist/esm/perspective-viewer.inline.js +0 -21
  192. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  193. package/src/rust/tasks/restore_and_render.rs +0 -107
  194. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  195. package/src/rust/utils/tests/pubsub.rs +0 -95
  196. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  197. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  198. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  199. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  200. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  201. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -20,6 +20,7 @@ use serde_json::Value;
20
20
  use ts_rs::TS;
21
21
  use wasm_bindgen::prelude::*;
22
22
 
23
+ use crate::config::TableRef;
23
24
  use crate::renderer::ColumnConfigMap;
24
25
 
25
26
  /// The state of an entire `custom_elements::PerspectiveViewerElement` component
@@ -30,18 +31,18 @@ use crate::renderer::ColumnConfigMap;
30
31
  /// single-panel format flattens back to the legacy shape.
31
32
  #[derive(Debug, Default, Serialize, PartialEq, TS)]
32
33
  #[serde(deny_unknown_fields)]
33
- pub struct ViewerConfig<V: TS = String> {
34
+ pub struct ViewerConfig<V: TS = String, T: TS = TableRef> {
34
35
  pub settings: bool,
35
36
 
36
37
  #[serde(flatten)]
37
- pub panel: PanelViewerConfig<V>,
38
+ pub panel: PanelViewerConfig<V, T>,
38
39
  }
39
40
 
40
41
  /// The per-panel state of a [`ViewerConfig`] — everything except the
41
42
  /// element-level `settings` flag. This is the `panels` entry type of the
42
43
  /// workspace config format.
43
44
  #[derive(Debug, Default, Serialize, PartialEq, TS)]
44
- pub struct PanelViewerConfig<V: TS = String> {
45
+ pub struct PanelViewerConfig<V: TS = String, T: TS = TableRef> {
45
46
  /// The `@perspective-dev/viewer` version that wrote this config,
46
47
  /// stamped on save and used to migrate older tokens. Callers do not
47
48
  /// set it.
@@ -66,11 +67,11 @@ pub struct PanelViewerConfig<V: TS = String> {
66
67
  /// the active plugin; see `get_style_schema`.
67
68
  pub plugin_config: serde_json::Map<String, Value>,
68
69
 
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,
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,
74
75
 
75
76
  /// Selected theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid
76
77
  /// names are the Perspective themes loaded on the page, which
@@ -84,14 +85,40 @@ pub struct PanelViewerConfig<V: TS = String> {
84
85
  pub view_config: ViewConfig,
85
86
  }
86
87
 
87
- impl<V: TS> Deref for ViewerConfig<V> {
88
- type Target = PanelViewerConfig<V>;
88
+ impl<V: TS, T: TS> Deref for ViewerConfig<V, T> {
89
+ type Target = PanelViewerConfig<V, T>;
89
90
 
90
91
  fn deref(&self) -> &Self::Target {
91
92
  &self.panel
92
93
  }
93
94
  }
94
95
 
96
+ impl<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
+
113
+ impl<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
+
95
122
  pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| {
96
123
  #[derive(Deserialize)]
97
124
  struct Package {
@@ -102,7 +129,7 @@ pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| {
102
129
  pkg.version
103
130
  });
104
131
 
105
- impl ViewerConfig {
132
+ impl<V: TS + Serialize, T: TS + Serialize> ViewerConfig<V, T> {
106
133
  /// Encode a `ViewerConfig` to a `JsValue` in a supported type.
107
134
  pub fn encode(&self) -> ApiResult<JsValue> {
108
135
  Ok(JsValue::from_serde_ext(self)?)
@@ -150,10 +177,11 @@ pub struct ViewerConfigUpdate {
150
177
  #[ts(optional)]
151
178
  pub title: TitleUpdate,
152
179
 
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.
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.
157
185
  #[serde(default)]
158
186
  #[ts(as = "Option<_>")]
159
187
  #[ts(optional)]
@@ -305,7 +333,7 @@ impl From<ViewerConfigInitial> for ViewerConfigUpdate {
305
333
  version: up(value.version),
306
334
  plugin: up(value.plugin),
307
335
  title: up(value.title),
308
- table: OptionalUpdate::Update(value.table),
336
+ table: OptionalUpdate::Update(value.table.into()),
309
337
  theme: up(value.theme),
310
338
  settings: OptionalUpdate::Missing,
311
339
  plugin_config: up(value.plugin_config),
@@ -350,6 +378,8 @@ impl TryFrom<ViewerConfigUpdate> for ViewerConfigInitial {
350
378
  return Err(ApiError::new(CREATE_REQUIRES_TABLE));
351
379
  };
352
380
 
381
+ let table = table.name()?.to_owned();
382
+
353
383
  Ok(Self {
354
384
  table,
355
385
  version: down(version),
@@ -393,7 +423,7 @@ pub type PluginUpdate = OptionalUpdate<String>;
393
423
  pub type SettingsUpdate = OptionalUpdate<bool>;
394
424
  pub type ThemeUpdate = OptionalUpdate<String>;
395
425
  pub type TitleUpdate = OptionalUpdate<String>;
396
- pub type TableUpdate = OptionalUpdate<String>;
426
+ pub type TableUpdate = OptionalUpdate<TableRef>;
397
427
  pub type VersionUpdate = OptionalUpdate<String>;
398
428
  pub type ColumnConfigUpdate = OptionalUpdate<ColumnConfigMap>;
399
429
  pub type PluginConfigUpdate = OptionalUpdate<serde_json::Map<String, Value>>;
@@ -12,11 +12,153 @@
12
12
 
13
13
  use std::collections::BTreeMap;
14
14
 
15
- use perspective_client::config::Filter;
15
+ use perspective_client::config::{Filter, ViewConfig};
16
16
 
17
17
  use crate::config::{OptionalUpdate, PanelViewerConfig, ViewerConfigInitial};
18
18
  use crate::utils::CssKind;
19
19
 
20
+ /// A derived `Table`'s definition: the `Table` its source `View` reads, and
21
+ /// the config of that `View`; generic over the `table` slot, which is a name
22
+ /// in the workspace `derived_tables` map and a recursive [`TableRef`] when
23
+ /// inlined in a single-panel config.
24
+ #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
25
+ pub struct DerivedTableConfig<T: ts_rs::TS = String> {
26
+ /// The `Table` this derivation reads, which may itself be another
27
+ /// derived `Table`.
28
+ pub table: T,
29
+
30
+ #[serde(flatten)]
31
+ pub view_config: ViewConfig,
32
+ }
33
+
34
+ /// What may occupy a `table` position in a single-panel config: a hosted
35
+ /// `Table`'s name, or an inline derived-table definition recursing through
36
+ /// its own `table` field until a concrete name.
37
+ #[derive(Clone, Debug, PartialEq, serde::Serialize)]
38
+ #[serde(untagged)]
39
+ pub enum TableRef {
40
+ Name(String),
41
+ Derived(Box<DerivedTableConfig<TableRef>>),
42
+ }
43
+
44
+ /// Hand-written because the derived decl's recursion sits in a generic alias
45
+ /// instantiation (`DerivedTableConfig<TableRef>`), which TypeScript expands
46
+ /// eagerly and rejects as circular; recursion is legal only deferred through
47
+ /// an object-literal node.
48
+ impl ts_rs::TS for TableRef {
49
+ type OptionInnerType = Self;
50
+ type WithoutGenerics = Self;
51
+
52
+ fn docs() -> Option<String> {
53
+ Some(
54
+ "/**\n * What may occupy a `table` position in a single-panel config: a hosted\n * \
55
+ `Table`'s name, or an inline derived-table definition recursing through\n * its own \
56
+ `table` field until a concrete name.\n */\n"
57
+ .to_owned(),
58
+ )
59
+ }
60
+
61
+ fn name() -> String {
62
+ "TableRef".to_owned()
63
+ }
64
+
65
+ fn decl() -> String {
66
+ format!(
67
+ "type TableRef = string | (Omit<{}, \"table\"> & {{ table: TableRef }});",
68
+ <DerivedTableConfig as ts_rs::TS>::ident()
69
+ )
70
+ }
71
+
72
+ fn decl_concrete() -> String {
73
+ <Self as ts_rs::TS>::decl()
74
+ }
75
+
76
+ fn inline() -> String {
77
+ format!(
78
+ "string | (Omit<{}, \"table\"> & {{ table: TableRef }})",
79
+ <DerivedTableConfig as ts_rs::TS>::ident()
80
+ )
81
+ }
82
+
83
+ fn inline_flattened() -> String {
84
+ panic!("`TableRef` cannot be flattened")
85
+ }
86
+
87
+ fn visit_dependencies(visitor: &mut impl ts_rs::TypeVisitor)
88
+ where
89
+ Self: 'static,
90
+ {
91
+ visitor.visit::<DerivedTableConfig>();
92
+ }
93
+
94
+ fn output_path() -> Option<std::path::PathBuf> {
95
+ Some(std::path::PathBuf::from("TableRef.ts"))
96
+ }
97
+ }
98
+
99
+ impl Default for TableRef {
100
+ fn default() -> Self {
101
+ Self::Name(String::default())
102
+ }
103
+ }
104
+
105
+ impl From<String> for TableRef {
106
+ fn from(value: String) -> Self {
107
+ Self::Name(value)
108
+ }
109
+ }
110
+
111
+ impl TableRef {
112
+ /// The concrete `Table` name, or an error for an inline definition which
113
+ /// a restore boundary has not resolved.
114
+ pub fn name(&self) -> Result<&str, perspective_js::utils::ApiError> {
115
+ match self {
116
+ Self::Name(name) => Ok(name),
117
+ Self::Derived(_) => Err(perspective_js::utils::ApiError::new(
118
+ "Unresolved inline derived table",
119
+ )),
120
+ }
121
+ }
122
+ }
123
+
124
+ impl<'de> serde::Deserialize<'de> for TableRef {
125
+ /// Hand-written (not `untagged`) so a malformed inline definition
126
+ /// reports its own error instead of "data did not match any variant".
127
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128
+ where
129
+ D: serde::Deserializer<'de>,
130
+ {
131
+ struct TableRefVisitor;
132
+ impl<'de> serde::de::Visitor<'de> for TableRefVisitor {
133
+ type Value = TableRef;
134
+
135
+ fn expecting(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136
+ fmt.write_str("a table name or an inline derived table definition")
137
+ }
138
+
139
+ fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
140
+ where
141
+ E: serde::de::Error,
142
+ {
143
+ Ok(TableRef::Name(value.to_owned()))
144
+ }
145
+
146
+ fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
147
+ where
148
+ A: serde::de::MapAccess<'de>,
149
+ {
150
+ let config = <DerivedTableConfig<TableRef> as serde::Deserialize>::deserialize(
151
+ serde::de::value::MapAccessDeserializer::new(map),
152
+ )?;
153
+
154
+ Ok(TableRef::Derived(Box::new(config)))
155
+ }
156
+ }
157
+
158
+ deserializer.deserialize_any(TableRefVisitor)
159
+ }
160
+ }
161
+
20
162
  /// The workspace config format (`{version, active?, layout, panels}`) —
21
163
  /// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as
22
164
  /// emitted by [`PerspectiveViewerElement::save`].
@@ -39,7 +181,14 @@ pub struct WorkspaceConfig {
39
181
  /// DETERMINISTIC (sorted) key order — a fresh `HashMap` per call
40
182
  /// iterates in a per-instance random order, which made consecutive
41
183
  /// `save()` outputs byte-unstable.
42
- pub panels: BTreeMap<String, PanelViewerConfig>,
184
+ pub panels: BTreeMap<String, PanelViewerConfig<String, String>>,
185
+
186
+ /// Named derived `Table`s, each a `View` of its `parent` promoted to a
187
+ /// `Table`. A `BTreeMap` for the same determinism reason as `panels`.
188
+ #[serde(skip_serializing_if = "BTreeMap::is_empty")]
189
+ #[ts(as = "Option<_>")]
190
+ #[ts(optional)]
191
+ pub derived_tables: BTreeMap<String, DerivedTableConfig>,
43
192
 
44
193
  /// The element-level global (master/detail cross-) filters. A transient
45
194
  /// overlay on every detail panel's view — persisted here, never in a
@@ -92,6 +241,13 @@ pub struct WorkspaceConfigUpdate {
92
241
  #[ts(optional)]
93
242
  pub panels: Option<BTreeMap<String, ViewerConfigInitial>>,
94
243
 
244
+ /// The complete replacement set of derived `Table`s, in any order (they
245
+ /// are built parent-first); absent keeps the current ones, `{}` deletes
246
+ /// them all.
247
+ #[serde(default)]
248
+ #[ts(optional)]
249
+ pub derived_tables: Option<BTreeMap<String, DerivedTableConfig>>,
250
+
95
251
  /// The element-level cross-filter set applied to every detail panel as
96
252
  /// one unattributed bucket, replacing the current set; `null` clears it.
97
253
  #[serde(default)]
@@ -138,129 +294,3 @@ pub fn validate_palette(
138
294
  })
139
295
  .collect()
140
296
  }
141
-
142
- #[cfg(test)]
143
- mod tests {
144
- use serde_json::json;
145
-
146
- use super::*;
147
-
148
- #[test]
149
- fn palette_serializes_only_when_present() {
150
- let config = WorkspaceConfig {
151
- version: "x".to_owned(),
152
- active: None,
153
- layout: None,
154
- panels: BTreeMap::new(),
155
- global_filters: vec![],
156
- masters: vec![],
157
- palette: BTreeMap::new(),
158
- };
159
-
160
- assert_eq!(
161
- serde_json::to_value(&config).unwrap(),
162
- json!({ "version": "x", "layout": null, "panels": {} })
163
- );
164
-
165
- let mut palette = BTreeMap::new();
166
- palette.insert("--psp-user--color-hot".to_owned(), "#ff0000".to_owned());
167
-
168
- let config = WorkspaceConfig { palette, ..config };
169
- assert_eq!(
170
- serde_json::to_value(&config).unwrap(),
171
- json!({
172
- "version": "x",
173
- "layout": null,
174
- "panels": {},
175
- "palette": { "--psp-user--color-hot": "#ff0000" },
176
- })
177
- );
178
- }
179
-
180
- #[test]
181
- fn update_absent_fields_are_missing_and_null_resets() {
182
- let update: WorkspaceConfigUpdate = serde_json::from_value(json!({})).unwrap();
183
- assert!(update.panels.is_none());
184
- assert!(update.layout.is_none());
185
- assert_eq!(update.active, OptionalUpdate::Missing);
186
- assert_eq!(update.global_filters, OptionalUpdate::Missing);
187
- assert_eq!(update.masters, OptionalUpdate::Missing);
188
- assert_eq!(update.palette, OptionalUpdate::Missing);
189
-
190
- let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
191
- "panels": null,
192
- "active": null,
193
- "global_filters": null,
194
- "masters": null,
195
- "palette": null,
196
- }))
197
- .unwrap();
198
-
199
- assert!(update.panels.is_none());
200
- assert_eq!(update.active, OptionalUpdate::SetDefault);
201
- assert_eq!(update.global_filters, OptionalUpdate::SetDefault);
202
- assert_eq!(update.masters, OptionalUpdate::SetDefault);
203
- assert_eq!(update.palette, OptionalUpdate::SetDefault);
204
-
205
- let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
206
- "panels": {},
207
- "global_filters": [["Region", "==", "West"]],
208
- "masters": ["a"],
209
- }))
210
- .unwrap();
211
-
212
- assert_eq!(update.panels.map(|p| p.len()), Some(0));
213
- assert_eq!(update.masters, OptionalUpdate::Update(vec!["a".to_owned()]));
214
- assert!(matches!(update.global_filters, OptionalUpdate::Update(ref f) if f.len() == 1));
215
- }
216
-
217
- #[test]
218
- fn update_palette_defaults_empty_and_validates_by_prefix() {
219
- let update: WorkspaceConfigUpdate =
220
- serde_json::from_value(json!({ "panels": {} })).unwrap();
221
- assert_eq!(update.palette, OptionalUpdate::Missing);
222
-
223
- let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
224
- "panels": {},
225
- "palette": {
226
- "--psp-user--gradient-1": "linear-gradient(#000, #fff)",
227
- "--psp-user--palette-warm": "linear-gradient(90deg, RGB(255,0,0), #ff0)",
228
- "--psp-user--color-hot": "#F00",
229
- },
230
- }))
231
- .unwrap();
232
-
233
- let OptionalUpdate::Update(palette) = update.palette else {
234
- panic!("palette missing");
235
- };
236
-
237
- let valid = validate_palette(palette).unwrap();
238
- assert_eq!(
239
- valid.get("--psp-user--gradient-1").unwrap(),
240
- "linear-gradient(to right, #000000 0%, #ffffff 100%)"
241
- );
242
- assert_eq!(
243
- valid.get("--psp-user--palette-warm").unwrap(),
244
- "linear-gradient(to right, #ff0000, #ffff00)"
245
- );
246
- assert_eq!(valid.get("--psp-user--color-hot").unwrap(), "#ff0000");
247
-
248
- let bad = |name: &str, value: &str| {
249
- let mut map = BTreeMap::new();
250
- map.insert(name.to_owned(), value.to_owned());
251
- validate_palette(map).unwrap_err()
252
- };
253
-
254
- assert!(bad("--psp-user--other-1", "#ff0000").contains("--psp-user--other-1"));
255
- assert!(bad("--psp-charts--gradient", "#ff0000").contains("must start with"));
256
- assert!(
257
- bad(
258
- "--psp-user--palette-1",
259
- "linear-gradient(#000 0%, #fff 100%)"
260
- )
261
- .contains("--psp-user--palette-1")
262
- );
263
- assert!(bad("--psp-user--gradient-1", "#ff0000").contains("linear-gradient"));
264
- assert!(bad("--psp-user--color-1", "red").contains("red"));
265
- }
266
- }