@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
@@ -11,23 +11,68 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  //! The [`Renderer`]'s per-plugin config state: the plugin-level and
14
- //! per-column buckets (`plugin_states`), their schema-aware strip/merge
14
+ //! per-column buckets (`PanelState::buckets`), their schema-aware strip/merge
15
15
  //! write paths, and the restore-prep materialized snapshot.
16
16
 
17
- use std::collections::HashMap;
17
+ use std::collections::{HashMap, HashSet};
18
+ use std::rc::Rc;
18
19
 
19
20
  use futures::future::join_all;
20
21
  use perspective_client::config::ViewConfig;
22
+ use perspective_client::proto::ColumnType;
21
23
  use perspective_js::utils::{ApiError, ApiResult, JsValueSerdeExt};
22
24
  use serde_json::Value;
23
25
  use wasm_bindgen::prelude::*;
24
26
 
25
27
  use super::Renderer;
28
+ use super::state::PluginRef;
26
29
  use crate::config::*;
27
- use crate::queries::resolve_abs_max;
30
+ use crate::js::plugin::JsPerspectiveViewerPlugin;
31
+ use crate::queries::{is_pivot_only_column, resolve_abs_max};
28
32
  use crate::session::Session;
29
33
  use crate::utils::{CssKind, CssLiteralUse, parse_var_ref, resolve_css_refs};
30
34
 
35
+ type ConfigMap = serde_json::Map<String, serde_json::Value>;
36
+
37
+ /// A schema query's answer: `Ok(None)` when the plugin declares no schema.
38
+ pub type SchemaResult = Result<Option<ColumnConfigSchema>, ValidationError>;
39
+
40
+ /// Where in the `ViewConfig` a column being configured appears, which is a
41
+ /// different axis from its plugin column slot.
42
+ #[derive(Clone, Copy, Debug, PartialEq)]
43
+ pub enum ColumnRole {
44
+ Column,
45
+ GroupBy,
46
+ SplitBy,
47
+ }
48
+
49
+ impl ColumnRole {
50
+ pub fn as_str(&self) -> &'static str {
51
+ match self {
52
+ Self::Column => "column",
53
+ Self::GroupBy => "group_by",
54
+ Self::SplitBy => "split_by",
55
+ }
56
+ }
57
+ }
58
+
59
+ /// Everything a column's schema is a function of, besides the column itself.
60
+ struct ColumnSchemaEnv<'a> {
61
+ view_config: &'a ViewConfig,
62
+ session: &'a Session,
63
+
64
+ /// The view types of a config not yet bound to a `View`; `None` reads the
65
+ /// bound `View`'s schema.
66
+ view_schema: Option<&'a HashMap<String, ColumnType>>,
67
+
68
+ /// Whether the session's column stats describe this config's table (`false`
69
+ /// for a restore that replaces it).
70
+ column_stats: bool,
71
+
72
+ /// The plugin-level config the column will live under.
73
+ plugin_config: &'a ConfigMap,
74
+ }
75
+
31
76
  /// A per-column config map. Each inner [`serde_json::Map`] is a flat collection
32
77
  /// of plugin-defined JSON keys whose shape is dictated by the active plugin's
33
78
  /// [`crate::config::ColumnConfigSchema`].
@@ -35,7 +80,7 @@ pub type ColumnConfigMap = HashMap<String, serde_json::Map<String, serde_json::V
35
80
 
36
81
  /// Per-plugin config bucket. Holds the per-column style map and the
37
82
  /// plugin-level config map for one plugin. Buckets are keyed by plugin
38
- /// name in [`RendererMutData::plugin_states`], so foreign keys from a
83
+ /// name in [`crate::session::PanelState::buckets`], so foreign keys from a
39
84
  /// different plugin physically cannot appear in the active plugin's
40
85
  /// bucket.
41
86
  #[derive(Clone, Debug, Default)]
@@ -44,23 +89,438 @@ pub struct PluginScopedConfig {
44
89
  pub plugin: serde_json::Map<String, serde_json::Value>,
45
90
  }
46
91
 
92
+ /// The renderer's part of a restore that has passed every check.
93
+ pub struct PreparedRenderer {
94
+ /// The plugin selection to commit, when the restore ends on another plugin.
95
+ pub plugin: Option<PluginRef>,
96
+
97
+ /// The plugin whose bucket the validated updates belong to.
98
+ pub target_name: String,
99
+ pub plugin_config: ValidatedPluginConfig,
100
+ pub columns_config: ValidatedColumnsConfig,
101
+ pub theme: Option<Option<String>>,
102
+ }
103
+
104
+ /// Which of the target plugin's buckets a [`Renderer::commit`] changed.
105
+ pub struct RendererChanges {
106
+ pub plugin_config: bool,
107
+ pub columns_config: bool,
108
+ }
109
+
110
+ /// The plugin a bucket update is validated against and written to — named
111
+ /// EXPLICITLY, so a validation that precedes a plugin swap can name the swap
112
+ /// target rather than whichever plugin happens to be active.
113
+ pub struct PluginTarget {
114
+ pub element: JsPerspectiveViewerPlugin,
115
+ pub static_config: Rc<PluginStaticConfig>,
116
+ }
117
+
118
+ /// What [`Renderer::apply_plugin_config`] writes for a validated
119
+ /// [`PluginConfigUpdate`] — every fallible check already done.
120
+ #[derive(Clone, Debug, PartialEq)]
121
+ pub enum ValidatedPluginConfig {
122
+ Missing,
123
+ Clear,
124
+ Set {
125
+ /// Keys to insert, already normalized and filtered to the schema's
126
+ /// active keys.
127
+ map: ConfigMap,
128
+
129
+ /// Keys the update set to their declared default: removed from the
130
+ /// bucket rather than stored.
131
+ remove: Vec<String>,
132
+
133
+ /// The schema's active keys, when a schema was available; the bucket is
134
+ /// pruned to them after the write.
135
+ active: Option<HashSet<String>>,
136
+ },
137
+ }
138
+
139
+ /// What [`Renderer::apply_columns_config`] writes for a validated
140
+ /// [`ColumnConfigUpdate`]: per column, the normalized entry (an EMPTY entry
141
+ /// removes the column).
142
+ #[derive(Debug, PartialEq)]
143
+ pub enum ValidatedColumnsConfig {
144
+ Missing,
145
+ Clear,
146
+ Set(Vec<(String, ConfigMap)>),
147
+ }
148
+
149
+ /// A bucket value the schema rejects.
150
+ #[derive(Debug, PartialEq)]
151
+ pub struct ValidationError(pub String);
152
+
153
+ impl From<ValidationError> for ApiError {
154
+ fn from(err: ValidationError) -> Self {
155
+ ApiError::from(JsValue::from_str(&err.0))
156
+ }
157
+ }
158
+
159
+ /// Validate a plugin-level config update against the schema the merged result
160
+ /// would have.
161
+ pub fn validate_plugin_config(
162
+ current: &ConfigMap,
163
+ update: PluginConfigUpdate,
164
+ schema_of: &dyn Fn(&ConfigMap) -> SchemaResult,
165
+ ) -> Result<ValidatedPluginConfig, ValidationError> {
166
+ match update {
167
+ OptionalUpdate::SetDefault => Ok(ValidatedPluginConfig::Clear),
168
+ OptionalUpdate::Missing => Ok(ValidatedPluginConfig::Missing),
169
+ OptionalUpdate::Update(mut map) => {
170
+ let mut merged = current.clone();
171
+ for (k, v) in &map {
172
+ merged.insert(k.clone(), v.clone());
173
+ }
174
+
175
+ let schema = schema_of(&merged)?;
176
+ let mut active = None;
177
+ let mut remove = vec![];
178
+ if let Some(s) = &schema {
179
+ let keys = s.active_keys();
180
+ map.retain(|k, _| keys.contains(k));
181
+ active = Some(keys);
182
+ let errors = normalize_values(s, &mut map);
183
+ if let Some((key, error)) = errors.first() {
184
+ return Err(ValidationError(format!(
185
+ "Invalid `plugin_config.{key}`: {error}"
186
+ )));
187
+ }
188
+
189
+ let leaves = s.leaf_fields();
190
+ map.retain(|key, value| {
191
+ let is_default = leaves
192
+ .iter()
193
+ .any(|spec| matches_declared_default(spec, key, value));
194
+ if is_default {
195
+ remove.push(key.clone());
196
+ }
197
+
198
+ !is_default
199
+ });
200
+ }
201
+
202
+ Ok(ValidatedPluginConfig::Set {
203
+ map,
204
+ remove,
205
+ active,
206
+ })
207
+ },
208
+ }
209
+ }
210
+
211
+ /// Validate a per-column config update, column by column, against the schema
212
+ /// each column's entry would have.
213
+ pub fn validate_columns_config(
214
+ update: ColumnConfigUpdate,
215
+ schema_of: &dyn Fn(&str, &ConfigMap) -> SchemaResult,
216
+ view_type_of: &dyn Fn(&str) -> Option<ColumnType>,
217
+ resolve_css: &dyn Fn(&str, &mut ConfigMap),
218
+ ) -> Result<ValidatedColumnsConfig, ValidationError> {
219
+ match update {
220
+ OptionalUpdate::SetDefault => Ok(ValidatedColumnsConfig::Clear),
221
+ OptionalUpdate::Missing => Ok(ValidatedColumnsConfig::Missing),
222
+ OptionalUpdate::Update(map) => {
223
+ let mut stripped = Vec::with_capacity(map.len());
224
+ for (col, mut cfg) in map {
225
+ if let Some(schema) = schema_of(&col, &cfg)? {
226
+ let active = schema.active_keys();
227
+ cfg.retain(|k, _| active.contains(k));
228
+ let errors = normalize_values(&schema, &mut cfg);
229
+ if let Some((key, error)) = errors.first() {
230
+ let ty = view_type_of(&col)
231
+ .map(|t| format!(" for a {t} column"))
232
+ .unwrap_or_default();
233
+
234
+ return Err(ValidationError(format!(
235
+ "Invalid `columns_config[\"{col}\"].{key}`{ty}: {error}"
236
+ )));
237
+ }
238
+
239
+ resolve_css(&col, &mut cfg);
240
+ strip_default_values(&schema, &mut cfg);
241
+ }
242
+
243
+ stripped.push((col, cfg));
244
+ }
245
+
246
+ Ok(ValidatedColumnsConfig::Set(stripped))
247
+ },
248
+ }
249
+ }
250
+
251
+ /// Whether `target`'s plugin element declares the schema method `name`.
252
+ fn declares(target: &PluginTarget, name: &str) -> bool {
253
+ js_sys::Reflect::get(&target.element, &JsValue::from_str(name))
254
+ .map(|f| f.is_function())
255
+ .unwrap_or(false)
256
+ }
257
+
258
+ /// Write a validated plugin-level update into `bucket`.
259
+ pub fn apply_plugin_config_to(
260
+ bucket: &mut PluginScopedConfig,
261
+ validated: ValidatedPluginConfig,
262
+ ) -> bool {
263
+ match validated {
264
+ ValidatedPluginConfig::Missing => false,
265
+ ValidatedPluginConfig::Clear => {
266
+ let changed = !bucket.plugin.is_empty();
267
+ bucket.plugin.clear();
268
+ changed
269
+ },
270
+ ValidatedPluginConfig::Set {
271
+ map,
272
+ remove,
273
+ active,
274
+ } => {
275
+ let mut changed = false;
276
+ for key in remove {
277
+ if bucket.plugin.remove(&key).is_some() {
278
+ changed = true;
279
+ }
280
+ }
281
+
282
+ for (k, v) in map {
283
+ let prev = bucket.plugin.insert(k, v.clone());
284
+ if prev.as_ref() != Some(&v) {
285
+ changed = true;
286
+ }
287
+ }
288
+
289
+ if let Some(active) = active {
290
+ let before = bucket.plugin.len();
291
+ bucket.plugin.retain(|k, _| active.contains(k));
292
+ changed |= bucket.plugin.len() != before;
293
+ }
294
+
295
+ changed
296
+ },
297
+ }
298
+ }
299
+
300
+ /// Write a validated per-column update into `bucket`.
301
+ pub fn apply_columns_config_to(
302
+ bucket: &mut PluginScopedConfig,
303
+ validated: ValidatedColumnsConfig,
304
+ ) -> bool {
305
+ match validated {
306
+ ValidatedColumnsConfig::Missing => false,
307
+ ValidatedColumnsConfig::Clear => {
308
+ let was_nonempty = !bucket.columns.is_empty();
309
+ bucket.columns.clear();
310
+ was_nonempty
311
+ },
312
+ ValidatedColumnsConfig::Set(entries) => {
313
+ let mut changed = false;
314
+ for (col, cfg) in entries {
315
+ if cfg.is_empty() {
316
+ if bucket.columns.remove(&col).is_some() {
317
+ changed = true;
318
+ }
319
+ } else {
320
+ match bucket.columns.insert(col, cfg.clone()) {
321
+ None => changed = true,
322
+ Some(old) if old != cfg => changed = true,
323
+ _ => {},
324
+ }
325
+ }
326
+ }
327
+
328
+ changed
329
+ },
330
+ }
331
+ }
332
+
47
333
  impl Renderer {
48
334
  /// Name of the currently-active plugin (used as the key into
49
- /// `plugin_states`). Returns `None` when no plugin has been
335
+ /// `PanelState::buckets`). Returns `None` when no plugin has been
50
336
  /// activated yet.
51
337
  fn active_plugin_name(&self) -> Option<String> {
52
- Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty())
338
+ Some(self.metadata().name.clone()).filter(|n| !n.is_empty())
339
+ }
340
+
341
+ /// Rewrite the named plugin's bucket: `f` edits a COPY, which is then
342
+ /// swapped in as one new [`crate::session::PanelState`].
343
+ fn edit_bucket<R>(&self, name: &str, f: impl FnOnce(&mut PluginScopedConfig) -> R) -> R {
344
+ let state = self.state();
345
+ let mut bucket = state.bucket(name);
346
+ let result = f(&mut bucket);
347
+ self.swap(state.with_bucket(name, bucket));
348
+ result
349
+ }
350
+
351
+ /// The active plugin as a [`PluginTarget`], selecting the registry default
352
+ /// when none is selected yet (the command half, like
353
+ /// [`Renderer::ensure_plugin_selected`]).
354
+ pub fn active_target(&self) -> ApiResult<PluginTarget> {
355
+ let element = self.ensure_plugin_selected()?;
356
+ Ok(PluginTarget {
357
+ element,
358
+ static_config: self.metadata(),
359
+ })
360
+ }
361
+
362
+ /// The plugin at registry index `idx` as a [`PluginTarget`], WITHOUT
363
+ /// selecting it — for validating against a swap target before the swap.
364
+ pub fn target_at(&self, idx: usize) -> ApiResult<PluginTarget> {
365
+ let mut st = self.borrow_mut();
366
+ let element = st.plugin_store.plugins().get(idx).cloned();
367
+ let static_config = st.plugin_store.plugin_configs().get(idx).cloned();
368
+ Ok(PluginTarget {
369
+ element: element.ok_or("No Plugin")?,
370
+ static_config: static_config.ok_or("No Plugin")?,
371
+ })
53
372
  }
54
373
 
55
- /// Snapshot of the active plugin's per-column config map.
374
+ /// Write a validated plugin-level update into `target`'s bucket.
375
+ pub fn apply_plugin_config(
376
+ &self,
377
+ target: &PluginTarget,
378
+ validated: ValidatedPluginConfig,
379
+ ) -> bool {
380
+ self.edit_bucket(&target.static_config.name, |bucket| {
381
+ apply_plugin_config_to(bucket, validated)
382
+ })
383
+ }
384
+
385
+ /// Write a validated per-column update into `target`'s bucket.
386
+ pub fn apply_columns_config(
387
+ &self,
388
+ target: &PluginTarget,
389
+ validated: ValidatedColumnsConfig,
390
+ ) -> bool {
391
+ self.edit_bucket(&target.static_config.name, |bucket| {
392
+ apply_columns_config_to(bucket, validated)
393
+ })
394
+ }
395
+
396
+ /// The plugin a [`PluginUpdate`] would END on, resolved WITHOUT selecting
397
+ /// anything: the swap target when the update names a different plugin (with
398
+ /// its store index, to commit), else the selected plugin, else the registry
399
+ /// default a first run would select.
400
+ pub fn resolve_target(
401
+ &self,
402
+ update: &PluginUpdate,
403
+ ) -> ApiResult<(PluginTarget, Option<usize>)> {
404
+ if let Some((idx, _)) = self.resolve_plugin_update(update) {
405
+ return Ok((self.target_at(idx)?, Some(idx)));
406
+ }
407
+
408
+ if let Some(plugin) = self.state().plugin.as_ref() {
409
+ return Ok((self.target_at(plugin.idx)?, None));
410
+ }
411
+
412
+ let (idx, _) = self
413
+ .resolve_plugin_update(&OptionalUpdate::SetDefault)
414
+ .ok_or("No Plugin")?;
415
+
416
+ Ok((self.target_at(idx)?, Some(idx)))
417
+ }
418
+
419
+ /// The COMMIT half of a restore, as one swap of this renderer's state.
420
+ pub fn commit(&self, prepared: PreparedRenderer) -> RendererChanges {
421
+ let PreparedRenderer {
422
+ plugin,
423
+ target_name,
424
+ plugin_config,
425
+ columns_config,
426
+ theme,
427
+ } = prepared;
428
+
429
+ let mut next = (*self.state()).clone();
430
+ if let Some(plugin) = plugin {
431
+ next = next.with_plugin(plugin);
432
+ }
433
+
434
+ let mut bucket = next.bucket(&target_name);
435
+ let changes = RendererChanges {
436
+ plugin_config: apply_plugin_config_to(&mut bucket, plugin_config),
437
+ columns_config: apply_columns_config_to(&mut bucket, columns_config),
438
+ };
439
+
440
+ if changes.plugin_config || changes.columns_config {
441
+ next = next.with_bucket(&target_name, bucket);
442
+ }
443
+
444
+ if let Some(theme) = theme {
445
+ next = next.with_theme(theme);
446
+ }
447
+
448
+ self.swap(next);
449
+ changes
450
+ }
451
+
452
+ /// Validate BOTH bucket updates of a restore against `target` and the view
453
+ /// the restore would build (`view_config`, whose columns have the types in
454
+ /// `view_schema`) — before either is written, and without a `View`.
455
+ #[allow(clippy::too_many_arguments)]
456
+ pub fn prepare_buckets(
457
+ &self,
458
+ target: &PluginTarget,
459
+ session: &Session,
460
+ view_config: &ViewConfig,
461
+ view_schema: Option<&HashMap<String, ColumnType>>,
462
+ rebinds: bool,
463
+ plugin_config: PluginConfigUpdate,
464
+ columns_config: ColumnConfigUpdate,
465
+ ) -> ApiResult<(ValidatedPluginConfig, ValidatedColumnsConfig)> {
466
+ let current = self.state().bucket(&target.static_config.name);
467
+ let plugin = validate_plugin_config(&current.plugin, plugin_config, &|merged| {
468
+ if !declares(target, "plugin_config_schema") {
469
+ return Ok(None);
470
+ }
471
+
472
+ self.query_plugin_config_schema_for(target, view_config, Some(merged))
473
+ .map(Some)
474
+ .map_err(|e| ValidationError(format!("`plugin_config` schema failed: {e}")))
475
+ })?;
476
+
477
+ let mut ends_with = current.clone();
478
+ apply_plugin_config_to(&mut ends_with, plugin.clone());
479
+ let columns = validate_columns_config(
480
+ columns_config,
481
+ &|col, cfg| {
482
+ let Some(view_schema) = view_schema else {
483
+ return Ok(None);
484
+ };
485
+
486
+ if !declares(target, "column_config_schema") {
487
+ return Ok(None);
488
+ }
489
+
490
+ let env = ColumnSchemaEnv {
491
+ view_config,
492
+ session,
493
+ view_schema: Some(view_schema),
494
+ column_stats: !rebinds,
495
+ plugin_config: &ends_with.plugin,
496
+ };
497
+
498
+ self.query_column_config_schema_for(target, &env, col, Some(cfg))
499
+ .map(Some)
500
+ .map_err(|e| {
501
+ ValidationError(format!("`columns_config[\"{col}\"]` schema failed: {e}"))
502
+ })
503
+ },
504
+ &|col| view_schema.and_then(|x| x.get(col).copied()),
505
+ &|col, cfg| self.resolve_css_refs_in(col, cfg),
506
+ )?;
507
+
508
+ Ok((plugin, columns))
509
+ }
510
+
511
+ /// The active plugin's per-column config map as the UI and `save()` see it
512
+ /// — pending style edits included.
56
513
  pub fn all_columns_configs(&self) -> ColumnConfigMap {
57
514
  self.active_plugin_name()
58
- .and_then(|n| {
59
- self.borrow()
60
- .plugin_states
61
- .get(&n)
62
- .map(|b| b.columns.clone())
63
- })
515
+ .map(|n| self.projected_bucket(&n).columns)
516
+ .unwrap_or_default()
517
+ }
518
+
519
+ /// The active plugin's COMMITTED per-column config map — what a running
520
+ /// op's step reads, since the edits queued behind it have not happened.
521
+ pub fn committed_columns_configs(&self) -> ColumnConfigMap {
522
+ self.active_plugin_name()
523
+ .map(|n| self.state().bucket(&n).columns)
64
524
  .unwrap_or_default()
65
525
  }
66
526
 
@@ -119,7 +579,7 @@ impl Renderer {
119
579
  view_config: &ViewConfig,
120
580
  session: &Session,
121
581
  ) -> ColumnConfigMap {
122
- let mut configs = self.all_columns_configs();
582
+ let mut configs = self.committed_columns_configs();
123
583
  let mut to_warm: Vec<String> = vec![];
124
584
  for (col, entry) in &configs {
125
585
  if session
@@ -140,6 +600,7 @@ impl Renderer {
140
600
  ControlSpec::Number {
141
601
  key,
142
602
  include: Some(true),
603
+ default_stat: Some(_),
143
604
  ..
144
605
  } if !entry.contains_key(key)
145
606
  )
@@ -194,12 +655,7 @@ impl Renderer {
194
655
  /// Clear the active plugin's per-column config map.
195
656
  pub fn reset_columns_configs(&self) {
196
657
  if let Some(n) = self.active_plugin_name() {
197
- self.borrow_mut()
198
- .plugin_states
199
- .entry(n)
200
- .or_default()
201
- .columns
202
- .clear();
658
+ self.edit_bucket(&n, |bucket| bucket.columns.clear());
203
659
  }
204
660
  }
205
661
 
@@ -210,83 +666,43 @@ impl Renderer {
210
666
  column_name: &str,
211
667
  ) -> Option<serde_json::Map<String, serde_json::Value>> {
212
668
  let n = self.active_plugin_name()?;
213
- self.borrow()
214
- .plugin_states
215
- .get(&n)?
216
- .columns
217
- .get(column_name)
218
- .cloned()
669
+ self.projected_bucket(&n).columns.get(column_name).cloned()
219
670
  }
220
671
 
221
- /// Wholesale update the active plugin's per-column config map.
672
+ /// Wholesale update the active plugin's per-column config map:
673
+ /// [`validate_columns_config`] against the active target, then
674
+ /// [`Renderer::apply_columns_config`].
222
675
  pub fn update_columns_configs(
223
676
  &self,
224
677
  view_config: &ViewConfig,
225
678
  session: &Session,
226
679
  update: ColumnConfigUpdate,
227
680
  ) -> ApiResult<bool> {
228
- let Some(n) = self.active_plugin_name() else {
681
+ if self.active_plugin_name().is_none() {
229
682
  return Ok(false);
230
- };
231
-
232
- match update {
233
- OptionalUpdate::SetDefault => {
234
- let mut st = self.borrow_mut();
235
- let bucket = st.plugin_states.entry(n).or_default();
236
- let was_nonempty = !bucket.columns.is_empty();
237
- bucket.columns.clear();
238
- Ok(was_nonempty)
239
- },
240
- OptionalUpdate::Missing => Ok(false),
241
- OptionalUpdate::Update(map) => {
242
- let mut stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> =
243
- Vec::with_capacity(map.len());
244
- for (col, mut cfg) in map {
245
- if let Ok(schema) =
246
- self.query_column_config_schema(view_config, session, &col, Some(&cfg))
247
- {
248
- let active = schema.active_keys();
249
- cfg.retain(|k, _| active.contains(k));
250
- let errors = normalize_values(&schema, &mut cfg);
251
- if let Some((key, error)) = errors.first() {
252
- let ty = session
253
- .metadata()
254
- .get_column_view_type(&col)
255
- .map(|t| format!(" for a {t} column"))
256
- .unwrap_or_default();
257
-
258
- return Err(ApiError::from(JsValue::from_str(&format!(
259
- "Invalid `columns_config[\"{col}\"].{key}`{ty}: {error}"
260
- ))));
261
- }
262
-
263
- self.resolve_css_refs_in(&col, &mut cfg);
264
- strip_default_values(&schema, &mut cfg);
265
- }
266
-
267
- stripped.push((col, cfg));
268
- }
683
+ }
269
684
 
270
- let mut st = self.borrow_mut();
271
- let bucket = st.plugin_states.entry(n).or_default();
272
- let mut changed = false;
273
- for (col, cfg) in stripped {
274
- if cfg.is_empty() {
275
- if bucket.columns.remove(&col).is_some() {
276
- changed = true;
277
- }
278
- } else {
279
- match bucket.columns.insert(col, cfg.clone()) {
280
- None => changed = true,
281
- Some(old) if old != cfg => changed = true,
282
- _ => {},
283
- }
284
- }
285
- }
685
+ let target = self.active_target()?;
686
+ let validated = validate_columns_config(
687
+ update,
688
+ &|col, cfg| {
689
+ let env = ColumnSchemaEnv {
690
+ view_config,
691
+ session,
692
+ view_schema: None,
693
+ column_stats: true,
694
+ plugin_config: &self.committed_plugin_config(),
695
+ };
286
696
 
287
- Ok(changed)
697
+ Ok(self
698
+ .query_column_config_schema_for(&target, &env, col, Some(cfg))
699
+ .ok())
288
700
  },
289
- }
701
+ &|col| session.metadata().get_column_view_type(col),
702
+ &|col, cfg| self.resolve_css_refs_in(col, cfg),
703
+ )?;
704
+
705
+ Ok(self.apply_columns_config(&target, validated))
290
706
  }
291
707
 
292
708
  /// Apply a single schema-field update from the column-style UI to
@@ -304,7 +720,8 @@ impl Renderer {
304
720
  return;
305
721
  };
306
722
 
307
- let current_value = self.get_columns_config(&column_name);
723
+ let current_value = self.state().bucket(&n).columns.get(&column_name).cloned();
724
+
308
725
  if let Ok(schema) = self.query_column_config_schema(
309
726
  view_config,
310
727
  session,
@@ -319,9 +736,7 @@ impl Renderer {
319
736
  strip_default_values(&schema, &mut update.value);
320
737
  }
321
738
 
322
- let next = {
323
- let mut st = self.borrow_mut();
324
- let bucket = st.plugin_states.entry(n.clone()).or_default();
739
+ let next = self.edit_bucket(&n, |bucket| {
325
740
  let entry = bucket.columns.entry(column_name.clone()).or_default();
326
741
  for k in &update.keys {
327
742
  entry.remove(k);
@@ -333,7 +748,7 @@ impl Renderer {
333
748
  }
334
749
 
335
750
  entry.clone()
336
- };
751
+ });
337
752
 
338
753
  // The schema AFTER the update decides which keys survive and, for a
339
754
  // key whose control kind follows another key (`fg_color` reads as a
@@ -345,44 +760,45 @@ impl Renderer {
345
760
  .query_column_config_schema(view_config, session, &column_name, Some(&next))
346
761
  .ok();
347
762
 
348
- let mut st = self.borrow_mut();
349
- let bucket = st.plugin_states.entry(n).or_default();
350
- if let Some(entry) = bucket.columns.get_mut(&column_name) {
351
- if let Some(schema) = &after {
352
- let active = schema.active_keys();
353
- entry.retain(|k, _| active.contains(k));
354
- for (key, error) in normalize_values(schema, entry) {
355
- tracing::warn!("Dropping `{column_name}`.`{key}` after a mode change: {error}");
763
+ self.edit_bucket(&n, |bucket| {
764
+ if let Some(entry) = bucket.columns.get_mut(&column_name) {
765
+ if let Some(schema) = &after {
766
+ let active = schema.active_keys();
767
+ entry.retain(|k, _| active.contains(k));
768
+ for (key, error) in normalize_values(schema, entry) {
769
+ tracing::warn!(
770
+ "Dropping `{column_name}`.`{key}` after a mode change: {error}"
771
+ );
772
+ }
356
773
  }
357
- }
358
774
 
359
- if entry.is_empty() {
360
- bucket.columns.remove(&column_name);
775
+ if entry.is_empty() {
776
+ bucket.columns.remove(&column_name);
777
+ }
361
778
  }
362
- }
779
+ })
363
780
  }
364
781
 
365
- /// Snapshot of the active plugin's plugin-level config map.
782
+ /// The active plugin's plugin-level config map as the UI and `save()` see
783
+ /// it — pending settings edits included.
366
784
  pub fn get_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
367
785
  self.active_plugin_name()
368
- .and_then(|n| {
369
- self.borrow()
370
- .plugin_states
371
- .get(&n)
372
- .map(|b| b.plugin.clone())
373
- })
786
+ .map(|n| self.projected_bucket(&n).plugin)
787
+ .unwrap_or_default()
788
+ }
789
+
790
+ /// The active plugin's COMMITTED plugin-level config map — what a running
791
+ /// op's step reads.
792
+ pub fn committed_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
793
+ self.active_plugin_name()
794
+ .map(|n| self.state().bucket(&n).plugin)
374
795
  .unwrap_or_default()
375
796
  }
376
797
 
377
798
  /// Clear the active plugin's plugin-level config map.
378
799
  pub fn reset_plugin_config(&self) {
379
800
  if let Some(n) = self.active_plugin_name() {
380
- self.borrow_mut()
381
- .plugin_states
382
- .entry(n)
383
- .or_default()
384
- .plugin
385
- .clear();
801
+ self.edit_bucket(&n, |bucket| bucket.plugin.clear());
386
802
  }
387
803
  }
388
804
 
@@ -392,12 +808,25 @@ impl Renderer {
392
808
  fn query_plugin_config_schema(
393
809
  &self,
394
810
  view_config: &ViewConfig,
395
- current_value: Option<&serde_json::Map<String, serde_json::Value>>,
811
+ current_value: Option<&ConfigMap>,
812
+ ) -> ApiResult<ColumnConfigSchema> {
813
+ let target = self.active_target()?;
814
+ self.query_plugin_config_schema_for(&target, view_config, current_value)
815
+ }
816
+
817
+ /// [`Self::query_plugin_config_schema`] against an explicit target.
818
+ fn query_plugin_config_schema_for(
819
+ &self,
820
+ target: &PluginTarget,
821
+ view_config: &ViewConfig,
822
+ current_value: Option<&ConfigMap>,
396
823
  ) -> ApiResult<ColumnConfigSchema> {
397
- let plugin = self.ensure_plugin_selected()?;
398
824
  let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
399
825
  let current_js = JsValue::from_serde_ext(&current_value).unwrap_or(JsValue::NULL);
400
- let raw = plugin._plugin_config_schema(&view_config_js, &current_js)?;
826
+ let raw = target
827
+ .element
828
+ ._plugin_config_schema(&view_config_js, &current_js)?;
829
+
401
830
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
402
831
  .map(|schema| schema.canonicalize())
403
832
  .map_err(|e| e.into())
@@ -411,11 +840,40 @@ impl Renderer {
411
840
  view_config: &ViewConfig,
412
841
  session: &Session,
413
842
  column_name: &str,
414
- current_value: Option<&serde_json::Map<String, serde_json::Value>>,
843
+ current_value: Option<&ConfigMap>,
415
844
  ) -> ApiResult<ColumnConfigSchema> {
416
- let plugin = self.ensure_plugin_selected()?;
417
- let plugin_config = self.metadata();
418
- let names = &plugin_config.config_column_names;
845
+ let target = self.active_target()?;
846
+ let env = ColumnSchemaEnv {
847
+ view_config,
848
+ session,
849
+ view_schema: None,
850
+ column_stats: true,
851
+ plugin_config: &self.committed_plugin_config(),
852
+ };
853
+
854
+ self.query_column_config_schema_for(&target, &env, column_name, current_value)
855
+ }
856
+
857
+ /// [`Self::query_column_config_schema`] against an explicit target: the
858
+ /// column's group comes from the TARGET's static config, and its view type
859
+ /// from `view_schema` when given (a config not yet bound to a `View`), else
860
+ /// from the bound `View`'s schema.
861
+ fn query_column_config_schema_for(
862
+ &self,
863
+ target: &PluginTarget,
864
+ env: &ColumnSchemaEnv<'_>,
865
+ column_name: &str,
866
+ current_value: Option<&ConfigMap>,
867
+ ) -> ApiResult<ColumnConfigSchema> {
868
+ let ColumnSchemaEnv {
869
+ view_config,
870
+ session,
871
+ view_schema,
872
+ column_stats,
873
+ plugin_config,
874
+ } = *env;
875
+
876
+ let names = &target.static_config.config_column_names;
419
877
  let group = view_config
420
878
  .columns
421
879
  .iter()
@@ -423,115 +881,84 @@ impl Renderer {
423
881
  .and_then(|idx| names.get(idx))
424
882
  .map(|s| s.as_str());
425
883
 
426
- if !session.metadata().has_view_schema() {
427
- return Err(JsValue::from("view_schema not initialized").into());
428
- }
429
- let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
884
+ let role = if !is_pivot_only_column(column_name, view_config) {
885
+ ColumnRole::Column
886
+ } else if view_config.split_by.iter().any(|x| x == column_name) {
887
+ ColumnRole::SplitBy
888
+ } else {
889
+ ColumnRole::GroupBy
890
+ };
891
+
892
+ let view_type = match view_schema {
893
+ Some(view_schema) => view_schema.get(column_name).copied(),
894
+ None => {
895
+ if !session.metadata().has_view_schema() {
896
+ return Err(JsValue::from("view_schema not initialized").into());
897
+ }
898
+
899
+ session.metadata().get_column_view_type(column_name)
900
+ },
901
+ };
902
+
903
+ let view_type = match role {
904
+ ColumnRole::Column => view_type,
905
+ _ => session
906
+ .metadata()
907
+ .get_column_table_type(column_name)
908
+ .or(view_type),
909
+ };
910
+
911
+ let Some(view_type) = view_type else {
430
912
  return Ok(ColumnConfigSchema { fields: vec![] });
431
913
  };
432
914
 
433
915
  let current_js = JsValue::from_serde_ext(&current_value).unwrap_or(JsValue::NULL);
434
916
  let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
435
- let stats = session.get_column_stats(column_name).unwrap_or_default();
436
- let stats_json = serde_json::json!({
437
- "abs_max": stats.abs_max,
438
- });
439
- let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL);
440
-
441
- let raw = plugin._column_config_schema(
917
+ let plugin_config_js = JsValue::from_serde_ext(plugin_config).unwrap_or(JsValue::NULL);
918
+ let raw = target.element._column_config_schema(
442
919
  &view_type.to_string(),
443
920
  group,
444
921
  column_name,
445
922
  &current_js,
446
923
  &view_config_js,
447
- &stats_js,
924
+ &plugin_config_js,
925
+ role.as_str(),
448
926
  )?;
449
927
 
928
+ let abs_max = column_stats
929
+ .then(|| {
930
+ session
931
+ .get_column_stats(column_name)
932
+ .and_then(|s| s.abs_max)
933
+ })
934
+ .flatten();
935
+
450
936
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
451
- .map(|schema| schema.canonicalize())
937
+ .map(|schema| schema.canonicalize().resolve_stat_defaults(abs_max))
452
938
  .map_err(|e| e.into())
453
939
  }
454
940
 
455
- /// Merge an update into the active plugin's plugin-level config map,
456
- /// clearing entries equal to the schema default and dropping keys the
457
- /// resulting schema no longer advertises.
941
+ /// Merge an update into the active plugin's plugin-level config map:
942
+ /// [`validate_plugin_config`] against the active target, then
943
+ /// [`Renderer::apply_plugin_config`].
458
944
  pub fn update_plugin_config(
459
945
  &self,
460
946
  view_config: &ViewConfig,
461
947
  update: PluginConfigUpdate,
462
948
  ) -> ApiResult<bool> {
463
- let Some(n) = self.active_plugin_name() else {
949
+ if self.active_plugin_name().is_none() {
464
950
  return Ok(false);
465
- };
466
-
467
- match update {
468
- OptionalUpdate::SetDefault => {
469
- let mut st = self.borrow_mut();
470
- let bucket = st.plugin_states.entry(n).or_default();
471
- let changed = !bucket.plugin.is_empty();
472
- bucket.plugin.clear();
473
- Ok(changed)
474
- },
475
- OptionalUpdate::Missing => Ok(false),
476
- OptionalUpdate::Update(mut map) => {
477
- let mut merged = self.get_plugin_config();
478
- for (k, v) in &map {
479
- merged.insert(k.clone(), v.clone());
480
- }
481
-
482
- let schema = self
483
- .query_plugin_config_schema(view_config, Some(&merged))
484
- .ok();
485
-
486
- let mut active = None;
487
- if let Some(s) = &schema {
488
- let keys = s.active_keys();
489
- map.retain(|k, _| keys.contains(k));
490
- active = Some(keys);
491
- let errors = normalize_values(s, &mut map);
492
- if let Some((key, error)) = errors.first() {
493
- return Err(ApiError::from(JsValue::from_str(&format!(
494
- "Invalid `plugin_config.{key}`: {error}"
495
- ))));
496
- }
497
- }
498
-
499
- let mut st = self.borrow_mut();
500
- let bucket = st.plugin_states.entry(n).or_default();
501
- let mut changed = false;
502
- if let Some(s) = &schema {
503
- let leaves = s.leaf_fields();
504
- map.retain(|key, value| {
505
- let is_default = leaves
506
- .iter()
507
- .any(|spec| matches_declared_default(spec, key, value));
508
- if is_default {
509
- if bucket.plugin.remove(key).is_some() {
510
- changed = true;
511
- }
512
- false
513
- } else {
514
- true
515
- }
516
- });
517
- }
518
-
519
- for (k, v) in map {
520
- let prev = bucket.plugin.insert(k, v.clone());
521
- if prev.as_ref() != Some(&v) {
522
- changed = true;
523
- }
524
- }
951
+ }
525
952
 
526
- if let Some(active) = active {
527
- let before = bucket.plugin.len();
528
- bucket.plugin.retain(|k, _| active.contains(k));
529
- changed |= bucket.plugin.len() != before;
530
- }
953
+ let target = self.active_target()?;
954
+ let current = self.committed_plugin_config();
955
+ let validated = validate_plugin_config(&current, update, &|merged| {
956
+ Ok(self
957
+ .query_plugin_config_schema_for(&target, view_config, Some(merged))
958
+ .ok())
959
+ })?;
531
960
 
532
- Ok(changed)
533
- },
534
- }
961
+ Ok(self.apply_plugin_config(&target, validated))
535
962
  }
536
963
 
537
964
  /// Apply a single schema-field update from the plugin-settings UI,
@@ -545,7 +972,7 @@ impl Renderer {
545
972
  return false;
546
973
  };
547
974
 
548
- let mut next = self.get_plugin_config();
975
+ let mut next = self.committed_plugin_config();
549
976
  for k in &update.keys {
550
977
  match update.value.get(k) {
551
978
  Some(v) => {
@@ -569,29 +996,28 @@ impl Renderer {
569
996
  strip_default_values(schema, &mut update.value);
570
997
  }
571
998
 
572
- let mut st = self.borrow_mut();
573
- let bucket = st.plugin_states.entry(n).or_default();
574
- let mut changed = false;
575
-
576
- for k in &update.keys {
577
- if let Some(v) = update.value.get(k) {
578
- let prev = bucket.plugin.insert(k.to_string(), v.clone());
579
- if prev.as_ref() != Some(v) {
999
+ self.edit_bucket(&n, |bucket| {
1000
+ let mut changed = false;
1001
+ for k in &update.keys {
1002
+ if let Some(v) = update.value.get(k) {
1003
+ let prev = bucket.plugin.insert(k.to_string(), v.clone());
1004
+ if prev.as_ref() != Some(v) {
1005
+ changed = true;
1006
+ }
1007
+ } else if bucket.plugin.remove(k).is_some() {
580
1008
  changed = true;
581
1009
  }
582
- } else if bucket.plugin.remove(k).is_some() {
583
- changed = true;
584
1010
  }
585
- }
586
1011
 
587
- if let Some(schema) = &schema {
588
- let active = schema.active_keys();
589
- let before = bucket.plugin.len();
590
- bucket.plugin.retain(|k, _| active.contains(k));
591
- changed |= bucket.plugin.len() != before;
592
- }
1012
+ if let Some(schema) = &schema {
1013
+ let active = schema.active_keys();
1014
+ let before = bucket.plugin.len();
1015
+ bucket.plugin.retain(|k, _| active.contains(k));
1016
+ changed |= bucket.plugin.len() != before;
1017
+ }
593
1018
 
594
- changed
1019
+ changed
1020
+ })
595
1021
  }
596
1022
  }
597
1023
 
@@ -701,264 +1127,3 @@ fn css_matches(kind: CssKind, value: &Value, default: &str) -> bool {
701
1127
  .and_then(|src| kind.canonicalize(src).ok())
702
1128
  .is_some_and(|canonical| canonical == default)
703
1129
  }
704
-
705
- #[cfg(test)]
706
- mod tests {
707
- use serde_json::json;
708
-
709
- use super::*;
710
-
711
- #[test]
712
- fn palette_default_matches_canonically() {
713
- let spec = ControlSpec::Palette {
714
- key: "palette".to_owned(),
715
- default: "linear-gradient(to right, #0366d6, #ff7f0e)".to_owned(),
716
- max: None,
717
- };
718
-
719
- assert!(matches_declared_default(
720
- &spec,
721
- "palette",
722
- &json!("linear-gradient(to right, #0366d6, #ff7f0e)")
723
- ));
724
-
725
- assert!(matches_declared_default(
726
- &spec,
727
- "palette",
728
- &json!("linear-gradient(90deg, RGB(3,102,214), #FF7F0E)")
729
- ));
730
-
731
- assert!(!matches_declared_default(
732
- &spec,
733
- "palette",
734
- &json!("linear-gradient(to right, #ff7f0e, #0366d6)")
735
- ));
736
-
737
- assert!(!matches_declared_default(
738
- &spec,
739
- "palette",
740
- &json!("var(--psp-user--palette-1)")
741
- ));
742
-
743
- assert!(!matches_declared_default(
744
- &spec,
745
- "palette",
746
- &json!(["#0366d6"])
747
- ));
748
- assert!(!matches_declared_default(
749
- &spec,
750
- "other",
751
- &json!("linear-gradient(to right, #0366d6, #ff7f0e)")
752
- ));
753
- }
754
-
755
- #[test]
756
- fn gradient_default_matches_canonical_values() {
757
- let spec = ControlSpec::GradientStops {
758
- key: "gradient".to_owned(),
759
- default: "linear-gradient(to right, #0366d6 0%, #ff7f0e 33.3%)".to_owned(),
760
- discrete: false,
761
- };
762
-
763
- assert!(matches_declared_default(
764
- &spec,
765
- "gradient",
766
- &json!("linear-gradient(#0366d6, #ff7f0e 33.3%)")
767
- ));
768
-
769
- assert!(!matches_declared_default(
770
- &spec,
771
- "gradient",
772
- &json!("linear-gradient(to right, #0366d6 0%, #ff7f0e 33.4%)")
773
- ));
774
-
775
- let color = ControlSpec::Color {
776
- key: "color".to_owned(),
777
- default: "#ff7f0e".to_owned(),
778
- };
779
-
780
- assert!(matches_declared_default(&color, "color", &json!("#FF7F0E")));
781
- assert!(matches_declared_default(
782
- &color,
783
- "color",
784
- &json!("rgb(255, 127, 14)")
785
- ));
786
- assert!(!matches_declared_default(
787
- &color,
788
- "color",
789
- &json!("var(--psp-user--color-1)")
790
- ));
791
- }
792
-
793
- #[test]
794
- fn alignment_strips_only_a_declared_default() {
795
- let schema = ColumnConfigSchema {
796
- fields: vec![
797
- ControlSpec::Alignment {
798
- key: "align".to_owned(),
799
- default: None,
800
- corners: false,
801
- },
802
- ControlSpec::Alignment {
803
- key: "legend_anchor".to_owned(),
804
- default: Some(Alignment::TopRight),
805
- corners: true,
806
- },
807
- ],
808
- };
809
-
810
- let mut map = json!({ "align": "center", "legend_anchor": "top-right" })
811
- .as_object()
812
- .unwrap()
813
- .clone();
814
-
815
- strip_default_values(&schema, &mut map);
816
- assert_eq!(
817
- map,
818
- json!({ "align": "center" }).as_object().unwrap().clone()
819
- );
820
- }
821
-
822
- #[test]
823
- fn strip_default_values_sees_through_groups() {
824
- let leaves = vec![
825
- ControlSpec::Bool {
826
- key: "flag".to_owned(),
827
- default: false,
828
- },
829
- ControlSpec::Number {
830
- key: "size".to_owned(),
831
- default: 3.0,
832
- include: None,
833
- min: None,
834
- max: None,
835
- step: None,
836
- },
837
- ];
838
-
839
- let flat = ColumnConfigSchema {
840
- fields: leaves.clone(),
841
- };
842
-
843
- let grouped = ColumnConfigSchema {
844
- fields: vec![ControlSpec::Group {
845
- key: "section".to_owned(),
846
- fields: leaves,
847
- }],
848
- };
849
-
850
- let src = json!({ "flag": false, "size": 4.0, "foreign": 1 })
851
- .as_object()
852
- .unwrap()
853
- .clone();
854
-
855
- let mut a = src.clone();
856
- let mut b = src;
857
- strip_default_values(&flat, &mut a);
858
- strip_default_values(&grouped, &mut b);
859
- assert_eq!(a, b);
860
- assert_eq!(
861
- a,
862
- json!({ "size": 4.0, "foreign": 1 })
863
- .as_object()
864
- .unwrap()
865
- .clone()
866
- );
867
- }
868
-
869
- #[test]
870
- fn normalize_values_rejects_enum_values_outside_the_variants() {
871
- let schema = ColumnConfigSchema {
872
- fields: vec![ControlSpec::Group {
873
- key: "color".to_owned(),
874
- fields: vec![ControlSpec::Enum {
875
- key: "fg_mode".to_owned(),
876
- default: "color".to_owned(),
877
- variants: vec![
878
- EnumVariant {
879
- value: "disabled".to_owned(),
880
- label: None,
881
- },
882
- EnumVariant {
883
- value: "color".to_owned(),
884
- label: None,
885
- },
886
- ],
887
- }],
888
- }],
889
- };
890
-
891
- let mut map = json!({ "fg_mode": "color", "other": "series" })
892
- .as_object()
893
- .unwrap()
894
- .clone();
895
-
896
- assert!(normalize_values(&schema, &mut map).is_empty());
897
- assert_eq!(map.len(), 2);
898
-
899
- let mut map = json!({ "fg_mode": "series" }).as_object().unwrap().clone();
900
- let errors = normalize_values(&schema, &mut map);
901
- assert_eq!(errors.len(), 1);
902
- assert_eq!(errors[0].0, "fg_mode");
903
- assert_eq!(errors[0].1, "\"series\" is not one of `disabled`, `color`");
904
- assert!(map.is_empty());
905
-
906
- let mut map = json!({ "fg_mode": 3 }).as_object().unwrap().clone();
907
- let errors = normalize_values(&schema, &mut map);
908
- assert_eq!(errors.len(), 1);
909
- assert!(map.is_empty());
910
- }
911
-
912
- #[test]
913
- fn normalize_values_canonicalizes_and_reports() {
914
- let schema = ColumnConfigSchema {
915
- fields: vec![
916
- ControlSpec::Color {
917
- key: "color".to_owned(),
918
- default: "#000000".to_owned(),
919
- },
920
- ControlSpec::Palette {
921
- key: "palette".to_owned(),
922
- default: "linear-gradient(to right, #000000)".to_owned(),
923
- max: None,
924
- },
925
- ControlSpec::GradientStops {
926
- key: "gradient".to_owned(),
927
- default: "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
928
- discrete: false,
929
- },
930
- ControlSpec::Bool {
931
- key: "flag".to_owned(),
932
- default: false,
933
- },
934
- ],
935
- };
936
-
937
- let mut map = json!({
938
- "color": "RGB(255,0,0)",
939
- "palette": "var(--psp-user--palette-warm)",
940
- "gradient": [{ "color": "#000000", "offset": 0 }],
941
- "flag": true,
942
- "foreign": "linear-gradient(red, blue)",
943
- })
944
- .as_object()
945
- .unwrap()
946
- .clone();
947
-
948
- let errors = normalize_values(&schema, &mut map);
949
- assert_eq!(errors.len(), 1);
950
- assert_eq!(errors[0].0, "gradient");
951
- assert_eq!(
952
- map,
953
- json!({
954
- "color": "#ff0000",
955
- "palette": "var(--psp-user--palette-warm)",
956
- "flag": true,
957
- "foreign": "linear-gradient(red, blue)",
958
- })
959
- .as_object()
960
- .unwrap()
961
- .clone()
962
- );
963
- }
964
- }