@perspective-dev/viewer 4.5.2 → 5.1.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 (227) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/extensions.d.ts +22 -3
  24. package/dist/esm/perspective-viewer.d.ts +9 -0
  25. package/dist/esm/perspective-viewer.inline.js +20 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +20 -1
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/plugin.d.ts +71 -5
  30. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  32. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  33. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  34. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  35. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  36. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  37. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  38. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -0
  39. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  40. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  41. package/dist/esm/ts-rs/ViewerConfig.d.ts +8 -2
  42. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  43. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  44. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  45. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  46. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  47. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  50. package/dist/wasm/perspective-viewer.d.ts +299 -78
  51. package/dist/wasm/perspective-viewer.js +544 -186
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  54. package/dist/wasm/snippets/perspective-viewer-8a8c7d5d696b43ae/inline0.js +6 -0
  55. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-8a8c7d5d696b43ae/inline4.js} +5 -0
  56. package/package.json +3 -2
  57. package/src/css/_context_menu.css +14 -0
  58. package/src/css/column-selector.css +1 -0
  59. package/src/css/column-settings-panel.css +68 -3
  60. package/src/css/column-style.css +4 -3
  61. package/src/css/config-selector.css +22 -1
  62. package/src/css/containers/context-menu.css +107 -0
  63. package/src/css/containers/tabs.css +2 -2
  64. package/src/css/panel-tab.css +193 -0
  65. package/src/css/plugin-selector.css +7 -10
  66. package/src/css/status-bar.css +75 -81
  67. package/src/css/viewer.css +188 -70
  68. package/src/rust/components/column_dropdown.rs +40 -30
  69. package/src/rust/components/column_selector/active_column.rs +28 -45
  70. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  71. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  72. package/src/rust/components/column_selector/config_selector.rs +40 -82
  73. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  74. package/src/rust/components/column_selector/filter_column.rs +5 -15
  75. package/src/rust/components/column_selector/inactive_column.rs +11 -9
  76. package/src/rust/components/column_selector/sort_column.rs +3 -6
  77. package/src/rust/components/column_selector.rs +25 -14
  78. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  79. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  80. package/src/rust/components/column_settings_sidebar.rs +163 -16
  81. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  82. package/src/rust/components/containers/sidebar.rs +99 -22
  83. package/src/rust/components/containers/split_panel.rs +181 -62
  84. package/src/rust/components/context_menu.rs +130 -0
  85. package/src/rust/components/editable_header.rs +2 -0
  86. package/src/rust/components/form/debug.rs +3 -0
  87. package/src/rust/components/global_filter_bar.rs +76 -0
  88. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  89. package/src/rust/components/main_panel/msg.rs +62 -0
  90. package/src/rust/components/main_panel/presize.rs +220 -0
  91. package/src/rust/components/main_panel/reconcile.rs +224 -0
  92. package/src/rust/components/main_panel/render.rs +385 -0
  93. package/src/rust/components/main_panel/update.rs +184 -0
  94. package/src/rust/components/main_panel.rs +316 -71
  95. package/src/rust/components/mod.rs +5 -0
  96. package/src/rust/components/panel_menu.rs +493 -0
  97. package/src/rust/components/panel_tab.rs +510 -0
  98. package/src/rust/components/plugin_tab.rs +1 -1
  99. package/src/rust/components/portal.rs +7 -0
  100. package/src/rust/components/settings_panel.rs +13 -5
  101. package/src/rust/components/status_bar.rs +110 -178
  102. package/src/rust/components/status_indicator.rs +6 -9
  103. package/src/rust/components/style/mod.rs +5 -0
  104. package/src/rust/components/style/style_provider.rs +2 -0
  105. package/src/rust/components/viewer/filters.rs +133 -0
  106. package/src/rust/components/viewer/msg.rs +182 -0
  107. package/src/rust/components/viewer/panels.rs +326 -0
  108. package/src/rust/components/viewer/render.rs +255 -0
  109. package/src/rust/components/viewer/settings.rs +434 -0
  110. package/src/rust/components/viewer/snapshots.rs +134 -0
  111. package/src/rust/components/viewer/wiring.rs +505 -0
  112. package/src/rust/components/viewer.rs +245 -756
  113. package/src/rust/components/window_editor.rs +1013 -0
  114. package/src/rust/config/export_method.rs +1 -1
  115. package/src/rust/config/mod.rs +4 -0
  116. package/src/rust/config/options.rs +71 -0
  117. package/src/rust/config/viewer_config.rs +24 -2
  118. package/src/rust/config/workspace_config.rs +92 -0
  119. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  120. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  121. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  122. package/src/rust/custom_elements/viewer.rs +1015 -286
  123. package/src/rust/custom_events.rs +212 -61
  124. package/src/rust/js/mod.rs +2 -0
  125. package/src/rust/js/plugin.rs +297 -10
  126. package/src/rust/js/regular_layout.rs +258 -0
  127. package/src/rust/lib.rs +24 -2
  128. package/src/rust/presentation/column_locator.rs +16 -1
  129. package/src/rust/presentation.rs +66 -33
  130. package/src/rust/queries/columns_iter_set.rs +7 -1
  131. package/src/rust/queries/get_viewer_config.rs +29 -11
  132. package/src/rust/queries/plugin_column_styles.rs +2 -2
  133. package/src/rust/renderer/activate.rs +41 -14
  134. package/src/rust/renderer/dispatch.rs +463 -0
  135. package/src/rust/renderer/plugin_config.rs +495 -0
  136. package/src/rust/renderer/registry.rs +18 -6
  137. package/src/rust/renderer/render_timer.rs +29 -10
  138. package/src/rust/renderer.rs +384 -838
  139. package/src/rust/session/column_defaults_update.rs +26 -8
  140. package/src/rust/session/drag_drop_update.rs +18 -0
  141. package/src/rust/session/metadata.rs +66 -9
  142. package/src/rust/session/replace_expression_update.rs +1 -0
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +755 -264
  145. package/src/rust/tasks/apply_global_filters.rs +89 -0
  146. package/src/rust/tasks/auto_pause.rs +171 -0
  147. package/src/rust/tasks/copy_export.rs +29 -19
  148. package/src/rust/tasks/create_panel.rs +243 -0
  149. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  150. package/src/rust/tasks/edit_expression.rs +4 -4
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/eject.rs +64 -23
  153. package/src/rust/tasks/mod.rs +30 -4
  154. package/src/rust/tasks/pipeline.rs +411 -0
  155. package/src/rust/tasks/presize_panels.rs +324 -0
  156. package/src/rust/tasks/reset_all.rs +22 -12
  157. package/src/rust/tasks/resize_observer.rs +124 -87
  158. package/src/rust/tasks/restore_and_render.rs +80 -86
  159. package/src/rust/tasks/restore_panel.rs +145 -0
  160. package/src/rust/tasks/send_column_config.rs +5 -2
  161. package/src/rust/tasks/send_plugin_config.rs +5 -2
  162. package/src/rust/tasks/set_edit_mode.rs +59 -0
  163. package/src/rust/tasks/sync_update_panels.rs +152 -0
  164. package/src/rust/tasks/table_lifecycle.rs +158 -0
  165. package/src/rust/tasks/update_theme.rs +56 -12
  166. package/src/rust/utils/browser/dragdrop.rs +12 -0
  167. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  168. package/src/rust/utils/completion.rs +56 -0
  169. package/src/rust/utils/custom_element.rs +5 -0
  170. package/src/rust/utils/debounce.rs +189 -17
  171. package/src/rust/utils/mod.rs +6 -0
  172. package/src/rust/utils/pending_effects.rs +62 -0
  173. package/src/rust/utils/spawn.rs +33 -0
  174. package/src/rust/utils/weak_scope.rs +0 -10
  175. package/src/rust/workspace.rs +846 -0
  176. package/src/svg/active-indicator.svg +3 -0
  177. package/src/svg/pin-icon.svg +5 -0
  178. package/src/themes/botanical.css +5 -2
  179. package/src/themes/defaults.css +54 -2
  180. package/src/themes/dracula.css +6 -3
  181. package/src/themes/gruvbox-dark.css +5 -2
  182. package/src/themes/gruvbox.css +5 -2
  183. package/src/themes/icons.css +5 -2
  184. package/src/themes/intl/de.css +7 -0
  185. package/src/themes/intl/es.css +7 -0
  186. package/src/themes/intl/fr.css +7 -0
  187. package/src/themes/intl/ja.css +7 -0
  188. package/src/themes/intl/pt.css +7 -0
  189. package/src/themes/intl/zh.css +7 -0
  190. package/src/themes/intl.css +10 -1
  191. package/src/themes/monokai.css +5 -2
  192. package/src/themes/phosphor.css +5 -2
  193. package/src/themes/pro-dark.css +5 -2
  194. package/src/themes/pro.css +2 -1
  195. package/src/themes/solarized-dark.css +5 -2
  196. package/src/themes/solarized.css +5 -2
  197. package/src/themes/vaporwave.css +5 -2
  198. package/src/ts/bootstrap.ts +8 -7
  199. package/src/ts/extensions.ts +30 -2
  200. package/src/ts/perspective-viewer.ts +10 -0
  201. package/src/ts/plugin.ts +72 -5
  202. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  203. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  204. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  205. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  206. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  207. package/src/ts/ts-rs/Layout.ts +9 -0
  208. package/src/ts/ts-rs/Orientation.ts +6 -0
  209. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  210. package/src/ts/ts-rs/PanelViewerConfig.ts +16 -0
  211. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  212. package/src/ts/ts-rs/TabLayout.ts +11 -0
  213. package/src/ts/ts-rs/ViewerConfig.ts +7 -2
  214. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  215. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  216. package/src/ts/ts-rs/WindowSort.ts +9 -0
  217. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  218. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  219. package/src/ts/ts-rs/Windows.ts +10 -0
  220. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  221. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  222. package/src/rust/tasks/intersection_observer.rs +0 -98
  223. package/src/rust/tasks/update_and_render.rs +0 -110
  224. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-8a8c7d5d696b43ae/inline1.js} +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-8a8c7d5d696b43ae/inline2.js} +0 -0
  226. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-8a8c7d5d696b43ae/inline3.js} +0 -0
  227. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-8a8c7d5d696b43ae/inline5.js} +0 -0
@@ -0,0 +1,1013 @@
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
+
13
+ use std::collections::HashSet;
14
+ use std::rc::Rc;
15
+
16
+ use perspective_client::config::{
17
+ ColumnType, WindowAggregate, WindowFrame, WindowSort, WindowSortDir, WindowSpec,
18
+ };
19
+ use wasm_bindgen::JsCast;
20
+ use web_sys::{DragEvent, HtmlInputElement, MouseEvent};
21
+ use yew::prelude::*;
22
+
23
+ use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
24
+ use crate::components::column_selector::{ColumnSelectorColumnRow, InPlaceColumn, PivotColumn};
25
+ use crate::components::containers::dragdrop_list::{
26
+ DragContext, DragDropList, DragDropListItemProps,
27
+ };
28
+ use crate::components::containers::select::{Select, SelectItem};
29
+ use crate::presentation::Presentation;
30
+ use crate::session::{Session, SessionMetadataRc};
31
+ use crate::utils::{AddListener, DragEffect, DragTarget, Subscription};
32
+
33
+ fn op_label(op: WindowAggregate) -> &'static str {
34
+ match op {
35
+ WindowAggregate::Sum => "sum",
36
+ WindowAggregate::Avg => "avg",
37
+ WindowAggregate::Count => "count",
38
+ WindowAggregate::Min => "min",
39
+ WindowAggregate::Max => "max",
40
+ WindowAggregate::Stddev => "stddev",
41
+ WindowAggregate::Var => "var",
42
+ WindowAggregate::First => "first",
43
+ WindowAggregate::Last => "last",
44
+ WindowAggregate::Lag => "lag",
45
+ WindowAggregate::Lead => "lead",
46
+ WindowAggregate::Diff => "diff",
47
+ WindowAggregate::Rate => "rate",
48
+ WindowAggregate::Ema => "ema",
49
+ }
50
+ }
51
+
52
+ fn op_from_label(label: &str) -> Option<WindowAggregate> {
53
+ Some(match label {
54
+ "sum" => WindowAggregate::Sum,
55
+ "avg" => WindowAggregate::Avg,
56
+ "count" => WindowAggregate::Count,
57
+ "min" => WindowAggregate::Min,
58
+ "max" => WindowAggregate::Max,
59
+ "stddev" => WindowAggregate::Stddev,
60
+ "var" => WindowAggregate::Var,
61
+ "first" => WindowAggregate::First,
62
+ "last" => WindowAggregate::Last,
63
+ "lag" => WindowAggregate::Lag,
64
+ "lead" => WindowAggregate::Lead,
65
+ "diff" => WindowAggregate::Diff,
66
+ "rate" => WindowAggregate::Rate,
67
+ "ema" => WindowAggregate::Ema,
68
+ _ => return None,
69
+ })
70
+ }
71
+
72
+ fn is_aggregating(op: WindowAggregate) -> bool {
73
+ matches!(
74
+ op,
75
+ WindowAggregate::Sum
76
+ | WindowAggregate::Avg
77
+ | WindowAggregate::Count
78
+ | WindowAggregate::Min
79
+ | WindowAggregate::Max
80
+ | WindowAggregate::Stddev
81
+ | WindowAggregate::Var
82
+ )
83
+ }
84
+
85
+ fn is_positional(op: WindowAggregate) -> bool {
86
+ matches!(
87
+ op,
88
+ WindowAggregate::Lag | WindowAggregate::Lead | WindowAggregate::Diff
89
+ )
90
+ }
91
+
92
+ fn is_orderable_for_range(ty: ColumnType) -> bool {
93
+ matches!(
94
+ ty,
95
+ ColumnType::Integer | ColumnType::Float | ColumnType::Date | ColumnType::Datetime
96
+ )
97
+ }
98
+
99
+ #[derive(Clone, Properties)]
100
+ pub struct WindowEditorProps {
101
+ pub metadata: SessionMetadataRc,
102
+
103
+ /// The saved spec under edit, or `None` for the "new column" drawer.
104
+ pub initial: Option<WindowSpec>,
105
+
106
+ /// Fires on every form mutation with the draft spec when it validates,
107
+ /// else `None`. The spec's `name` is a placeholder - the editable header
108
+ /// owns naming.
109
+ pub on_change: Callback<Option<WindowSpec>>,
110
+
111
+ /// Incremented by the parent to discard the draft.
112
+ pub reset_count: u8,
113
+
114
+ /// Drag/drop state - the editor's column slots are STAGED drop targets
115
+ /// ([`DragTarget::is_staged`]): drops mutate the draft only, and the
116
+ /// drag origin never self-removes.
117
+ pub presentation: Presentation,
118
+
119
+ /// Threaded for the slots' autocomplete dropdown
120
+ /// ([`ColumnDropDownElement`]).
121
+ pub session: Session,
122
+
123
+ /// Selected theme name, threaded for the autocomplete dropdown's
124
+ /// `PortalModal`.
125
+ #[prop_or_default]
126
+ pub selected_theme: Option<String>,
127
+ }
128
+
129
+ impl PartialEq for WindowEditorProps {
130
+ fn eq(&self, other: &Self) -> bool {
131
+ self.metadata == other.metadata
132
+ && self.initial == other.initial
133
+ && self.reset_count == other.reset_count
134
+ && self.selected_theme == other.selected_theme
135
+ }
136
+ }
137
+
138
+ /// Input mirror of a draft [`WindowSpec`]. Free-text fields hold raw
139
+ /// strings so invalid intermediate input is representable; the NUMBER
140
+ /// fields are typed with default `1` and can never hold an invalid value -
141
+ /// like the Style tab's `NumberField`, a non-numeric or out-of-domain
142
+ /// input resets to the default at the keystroke. `validate` produces the
143
+ /// spec (or the error shown inline), applying the same rules as `View`
144
+ /// construction so a saveable draft cannot be rejected by the engine.
145
+ #[derive(Clone, PartialEq)]
146
+ struct WindowDraft {
147
+ op: String,
148
+ source: String,
149
+ order_by: String,
150
+ order_desc: bool,
151
+ partition_by: Vec<String>,
152
+ frame_type: String,
153
+ frame_rows: u32,
154
+ frame_range: f64,
155
+ offset: u32,
156
+ alpha: f64,
157
+ }
158
+
159
+ impl Default for WindowDraft {
160
+ fn default() -> Self {
161
+ Self {
162
+ op: String::default(),
163
+ source: String::default(),
164
+ order_by: String::default(),
165
+ order_desc: false,
166
+ partition_by: vec![],
167
+ frame_type: String::default(),
168
+ frame_rows: 1,
169
+ frame_range: 1.0,
170
+ offset: 1,
171
+ alpha: 1.0,
172
+ }
173
+ }
174
+ }
175
+
176
+ impl WindowDraft {
177
+ fn from_spec(spec: &WindowSpec) -> Self {
178
+ let (frame_type, frame_rows, frame_range) = match spec.frame {
179
+ Some(WindowFrame::Rows(n)) => ("Rows", n, 1.0),
180
+ Some(WindowFrame::Range(x)) => ("Range", 1, x),
181
+ Some(WindowFrame::Cumulative) | None => ("Cumulative", 1, 1.0),
182
+ };
183
+
184
+ Self {
185
+ op: op_label(spec.aggregate).to_string(),
186
+ source: spec.column.clone(),
187
+ order_by: spec
188
+ .order_by
189
+ .as_ref()
190
+ .map(|x| x.0.clone())
191
+ .unwrap_or_default(),
192
+ order_desc: spec
193
+ .order_by
194
+ .as_ref()
195
+ .map(|x| x.1 == WindowSortDir::Desc)
196
+ .unwrap_or_default(),
197
+ partition_by: spec.partition_by.clone(),
198
+ frame_type: frame_type.to_string(),
199
+ frame_rows,
200
+ frame_range,
201
+ offset: spec.offset.unwrap_or(1),
202
+ alpha: spec.alpha.unwrap_or(1.0),
203
+ }
204
+ }
205
+
206
+ fn new_default() -> Self {
207
+ Self {
208
+ op: "sum".to_string(),
209
+ frame_type: "Cumulative".to_string(),
210
+ ..Self::default()
211
+ }
212
+ }
213
+
214
+ fn validate(&self, metadata: &SessionMetadataRc) -> Result<WindowSpec, String> {
215
+ let op = op_from_label(&self.op).ok_or("Unknown op")?;
216
+
217
+ // Every slot takes true `Table` columns ONLY - expression aliases
218
+ // and other window columns would create dependency cycles (and
219
+ // force delete-blocking). The slots reject non-table drops with the
220
+ // invalid-X overlay, so these are backstops for API-authored specs
221
+ // opened in the editor.
222
+ let table_column = |col: &String| {
223
+ metadata
224
+ .get_table_columns()
225
+ .into_iter()
226
+ .flatten()
227
+ .any(|x| x == col)
228
+ };
229
+
230
+ if self.source.is_empty() {
231
+ return Err("Missing Column".to_string());
232
+ }
233
+
234
+ if !table_column(&self.source) {
235
+ // TODO I should not be
236
+ return Err(format!(
237
+ "\"{}\" must be a table column to source a window",
238
+ self.source
239
+ ));
240
+ }
241
+
242
+ let source_ty = metadata
243
+ .get_column_table_type(&self.source)
244
+ .ok_or_else(|| format!("Unknown source column \"{}\"", self.source))?;
245
+
246
+ // Backstop for API-authored specs opened in the editor - the op
247
+ // menu only offers the feature-declared set, so this is
248
+ // unreachable from the UI.
249
+ let available = metadata
250
+ .get_features()
251
+ .map(|x| x.get_window_aggregates(source_ty))
252
+ .unwrap_or_default();
253
+
254
+ if !available.contains(&op) {
255
+ return Err(format!(
256
+ "\"{}\" is not a supported window aggregate for this column",
257
+ op_label(op)
258
+ ));
259
+ }
260
+
261
+ // An EMPTY order slot is valid when the backend has a natural row
262
+ // order to fall back on (primary key order in the engine, `rowid`
263
+ // for SQL virtual servers); UNORDERED stores (`Features`) require
264
+ // an explicit order column.
265
+ let order_ty = if self.order_by.is_empty() {
266
+ if metadata
267
+ .get_features()
268
+ .map(|x| x.unordered)
269
+ .unwrap_or_default()
270
+ {
271
+ return Err("Missing Order By".to_string());
272
+ }
273
+
274
+ None
275
+ } else {
276
+ if !table_column(&self.order_by) {
277
+ // TODO I should not be
278
+ return Err(format!(
279
+ "\"{}\" must be a table column to order by",
280
+ self.order_by
281
+ ));
282
+ }
283
+
284
+ Some(
285
+ metadata
286
+ .get_column_table_type(&self.order_by)
287
+ .ok_or_else(|| format!("Unknown order by column \"{}\"", self.order_by))?,
288
+ )
289
+ };
290
+
291
+ for col in self.partition_by.iter() {
292
+ // TODO I should not be
293
+ if !table_column(col) {
294
+ return Err(format!(
295
+ "\"{}\" must be a table column to partition by",
296
+ col
297
+ ));
298
+ }
299
+ }
300
+
301
+ // The numeric fields are typed and input-clamped to their domains,
302
+ // so no parse or range errors are reachable here.
303
+ let frame = if is_aggregating(op) || op == WindowAggregate::Rate {
304
+ match self.frame_type.as_str() {
305
+ "Rows" | "Cumulative" if op == WindowAggregate::Rate => {
306
+ return Err("\"rate\" requires a range frame".to_string());
307
+ },
308
+ "Rows" => Some(WindowFrame::Rows(self.frame_rows)),
309
+ "Range" => {
310
+ // The natural-order fallback has no units, so `range`
311
+ // frames require an explicit order column.
312
+ let Some(order_ty) = order_ty else {
313
+ return Err("Range frames require an order by column".to_string());
314
+ };
315
+
316
+ if !is_orderable_for_range(order_ty) {
317
+ return Err("Range frames require a numeric, date or datetime order by \
318
+ column"
319
+ .to_string());
320
+ }
321
+
322
+ Some(WindowFrame::Range(self.frame_range))
323
+ },
324
+ _ => None,
325
+ }
326
+ } else {
327
+ None
328
+ };
329
+
330
+ // Emit `None` at the engine default so a spec saved without an
331
+ // explicit `offset` round-trips unchanged (the name-stripped
332
+ // change-detection baseline compares specs structurally).
333
+ let offset = (is_positional(op) && self.offset != 1).then_some(self.offset);
334
+ let alpha = (op == WindowAggregate::Ema).then_some(self.alpha);
335
+
336
+ let mut partition_by = self.partition_by.clone();
337
+ partition_by.retain(|col| !col.is_empty());
338
+
339
+ Ok(WindowSpec {
340
+ column: self.source.clone(),
341
+ aggregate: op,
342
+ partition_by,
343
+ order_by: (!self.order_by.is_empty()).then(|| {
344
+ WindowSort(
345
+ self.order_by.clone(),
346
+ if self.order_desc {
347
+ WindowSortDir::Desc
348
+ } else {
349
+ WindowSortDir::Asc
350
+ },
351
+ )
352
+ }),
353
+ frame,
354
+ offset,
355
+ alpha,
356
+ })
357
+ }
358
+ }
359
+
360
+ #[derive(Clone, Debug)]
361
+ pub enum WindowEditorMsg {
362
+ SetOp(String),
363
+ ClearSource,
364
+ ClearOrderBy,
365
+ ToggleOrderDir,
366
+ RemovePartition(usize),
367
+ SetFrameType(String),
368
+ /// Number-field messages carry the input's `value_as_number` (`NaN` for
369
+ /// empty or unparseable text); the handlers clamp to each field's
370
+ /// domain, resetting to the default `1` on invalid input.
371
+ SetFrameRows(f64),
372
+ SetFrameRange(f64),
373
+ SetOffset(f64),
374
+ SetAlpha(f64),
375
+ /// A completed drop concerning this editor: a staged target to fill,
376
+ /// and/or the staged origin slot (`DragEffect::Move` from a slot pill)
377
+ /// to clear.
378
+ Drop(String, DragTarget, Option<DragTarget>),
379
+ New(DragTarget, InPlaceColumn),
380
+ DragEnter(DragTarget, usize),
381
+ DragLeave(DragTarget),
382
+ }
383
+
384
+ /// [`DragContext`] bindings routing one [`DragDropList`] per staged target
385
+ /// into [`WindowEditorMsg`]s - the same composition the config selector
386
+ /// uses per zone, so the slots inherit its drop previews, autocomplete
387
+ /// (`EmptyColumn`) and pill styling.
388
+ struct WindowSourceContext;
389
+ struct WindowOrderByContext;
390
+ struct WindowPartitionByContext;
391
+
392
+ impl DragContext<WindowEditorMsg> for WindowSourceContext {
393
+ fn close(_index: usize) -> WindowEditorMsg {
394
+ WindowEditorMsg::ClearSource
395
+ }
396
+
397
+ fn dragenter(index: usize) -> WindowEditorMsg {
398
+ WindowEditorMsg::DragEnter(DragTarget::WindowSource, index)
399
+ }
400
+
401
+ fn dragleave() -> WindowEditorMsg {
402
+ WindowEditorMsg::DragLeave(DragTarget::WindowSource)
403
+ }
404
+
405
+ fn create(col: InPlaceColumn) -> WindowEditorMsg {
406
+ WindowEditorMsg::New(DragTarget::WindowSource, col)
407
+ }
408
+
409
+ fn is_self_move(effect: DragTarget) -> bool {
410
+ effect == DragTarget::WindowSource
411
+ }
412
+ }
413
+
414
+ impl DragContext<WindowEditorMsg> for WindowOrderByContext {
415
+ fn close(_index: usize) -> WindowEditorMsg {
416
+ WindowEditorMsg::ClearOrderBy
417
+ }
418
+
419
+ fn dragenter(index: usize) -> WindowEditorMsg {
420
+ WindowEditorMsg::DragEnter(DragTarget::WindowOrderBy, index)
421
+ }
422
+
423
+ fn dragleave() -> WindowEditorMsg {
424
+ WindowEditorMsg::DragLeave(DragTarget::WindowOrderBy)
425
+ }
426
+
427
+ fn create(col: InPlaceColumn) -> WindowEditorMsg {
428
+ WindowEditorMsg::New(DragTarget::WindowOrderBy, col)
429
+ }
430
+
431
+ fn is_self_move(effect: DragTarget) -> bool {
432
+ effect == DragTarget::WindowOrderBy
433
+ }
434
+ }
435
+
436
+ impl DragContext<WindowEditorMsg> for WindowPartitionByContext {
437
+ fn close(index: usize) -> WindowEditorMsg {
438
+ WindowEditorMsg::RemovePartition(index)
439
+ }
440
+
441
+ fn dragenter(index: usize) -> WindowEditorMsg {
442
+ WindowEditorMsg::DragEnter(DragTarget::WindowPartitionBy, index)
443
+ }
444
+
445
+ fn dragleave() -> WindowEditorMsg {
446
+ WindowEditorMsg::DragLeave(DragTarget::WindowPartitionBy)
447
+ }
448
+
449
+ fn create(col: InPlaceColumn) -> WindowEditorMsg {
450
+ WindowEditorMsg::New(DragTarget::WindowPartitionBy, col)
451
+ }
452
+
453
+ fn is_self_move(effect: DragTarget) -> bool {
454
+ effect == DragTarget::WindowPartitionBy
455
+ }
456
+ }
457
+
458
+ /// A slot's filled state: the shared `ColumnSelectorColumnRow`, with the
459
+ /// window `op` selector bound into its aggregate slot for the SOURCE slot -
460
+ /// the ONLY op control in the UI. The pill is a drag ORIGIN with
461
+ /// `DragEffect::Move(action)` (the same shape as `PivotColumn`) - the open
462
+ /// [`WindowEditor`] consumes the staged origin from `drop_received` to
463
+ /// complete the move by clearing this slot in its draft.
464
+ #[derive(Clone, Properties)]
465
+ pub struct WindowSlotColumnProps {
466
+ pub column: String,
467
+ pub column_type: Option<ColumnType>,
468
+
469
+ /// The staged slot this pill occupies - its drag origin.
470
+ pub action: DragTarget,
471
+
472
+ pub presentation: Presentation,
473
+
474
+ #[prop_or_default]
475
+ pub aggregate: Option<Html>,
476
+
477
+ /// Trailing affordance in the row (the order slot's staged sort-dir
478
+ /// toggle).
479
+ #[prop_or_default]
480
+ pub trailing: Html,
481
+ }
482
+
483
+ impl PartialEq for WindowSlotColumnProps {
484
+ fn eq(&self, other: &Self) -> bool {
485
+ self.column == other.column
486
+ && self.column_type == other.column_type
487
+ && self.action == other.action
488
+ && self.aggregate == other.aggregate
489
+ && self.trailing == other.trailing
490
+ }
491
+ }
492
+
493
+ impl DragDropListItemProps for WindowSlotColumnProps {
494
+ type Item = String;
495
+
496
+ fn get_item(&self) -> String {
497
+ self.column.clone()
498
+ }
499
+ }
500
+
501
+ pub struct WindowSlotColumn;
502
+
503
+ impl Component for WindowSlotColumn {
504
+ type Message = ();
505
+ type Properties = WindowSlotColumnProps;
506
+
507
+ fn create(_ctx: &Context<Self>) -> Self {
508
+ Self
509
+ }
510
+
511
+ fn view(&self, ctx: &Context<Self>) -> Html {
512
+ let dragstart = Callback::from({
513
+ let column = ctx.props().column.clone();
514
+ let presentation = ctx.props().presentation.clone();
515
+ let action = ctx.props().action;
516
+ move |event: DragEvent| {
517
+ presentation.set_drag_image(&event).unwrap();
518
+ presentation.notify_drag_start(column.clone(), DragEffect::Move(action))
519
+ }
520
+ });
521
+
522
+ let dragend = Callback::from({
523
+ let presentation = ctx.props().presentation.clone();
524
+ move |_: DragEvent| presentation.notify_drag_end()
525
+ });
526
+
527
+ html! {
528
+ <div class="column-selector-column">
529
+ <ColumnSelectorColumnRow
530
+ name={ctx.props().column.clone()}
531
+ col_type={ctx.props().column_type}
532
+ aggregate={ctx.props().aggregate.clone()}
533
+ trailing={ctx.props().trailing.clone()}
534
+ wrapper_class={classes!["column-selector-column-title"]}
535
+ ondragstart={Some(dragstart)}
536
+ ondragend={Some(dragend)}
537
+ />
538
+ </div>
539
+ }
540
+ }
541
+ }
542
+
543
+ pub struct WindowEditor {
544
+ draft: WindowDraft,
545
+ error: Option<String>,
546
+ column_dropdown: ColumnDropDownElement,
547
+ _drop_sub: Subscription,
548
+ }
549
+
550
+ impl WindowEditor {
551
+ fn initialize(ctx: &Context<Self>) -> WindowDraft {
552
+ ctx.props()
553
+ .initial
554
+ .as_ref()
555
+ .map(WindowDraft::from_spec)
556
+ .unwrap_or_else(WindowDraft::new_default)
557
+ }
558
+
559
+ fn emit(&mut self, ctx: &Context<Self>) {
560
+ match self.draft.validate(&ctx.props().metadata) {
561
+ Ok(spec) => {
562
+ self.error = None;
563
+ ctx.props().on_change.emit(Some(spec));
564
+ },
565
+ Err(msg) => {
566
+ self.error = Some(msg);
567
+ ctx.props().on_change.emit(None);
568
+ },
569
+ }
570
+ }
571
+ }
572
+
573
+ impl Component for WindowEditor {
574
+ type Message = WindowEditorMsg;
575
+ type Properties = WindowEditorProps;
576
+
577
+ fn create(ctx: &Context<Self>) -> Self {
578
+ let _drop_sub = {
579
+ let link = ctx.link().clone();
580
+ ctx.props().presentation.drop_received.add_listener(
581
+ move |(column, target, effect, _index): (String, DragTarget, DragEffect, usize)| {
582
+ let staged_origin = match effect {
583
+ DragEffect::Move(origin) if origin.is_staged() => Some(origin),
584
+ _ => None,
585
+ };
586
+
587
+ if target.is_staged() || staged_origin.is_some() {
588
+ link.send_message(WindowEditorMsg::Drop(column, target, staged_origin));
589
+ }
590
+ },
591
+ )
592
+ };
593
+
594
+ let mut this = Self {
595
+ draft: Self::initialize(ctx),
596
+ error: None,
597
+ column_dropdown: ColumnDropDownElement::new(ctx.props().session.clone()),
598
+ _drop_sub,
599
+ };
600
+
601
+ this.emit(ctx);
602
+ this
603
+ }
604
+
605
+ fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
606
+ if ctx.props().reset_count != old_props.reset_count
607
+ || ctx.props().initial != old_props.initial
608
+ {
609
+ self.draft = Self::initialize(ctx);
610
+ self.emit(ctx);
611
+ true
612
+ } else {
613
+ false
614
+ }
615
+ }
616
+
617
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
618
+ match msg {
619
+ WindowEditorMsg::DragEnter(target, index) => {
620
+ return ctx.props().presentation.notify_drag_enter(target, index);
621
+ },
622
+ WindowEditorMsg::DragLeave(target) => {
623
+ ctx.props().presentation.notify_drag_leave(target);
624
+ return true;
625
+ },
626
+ WindowEditorMsg::New(target, InPlaceColumn::Column(column)) => {
627
+ ctx.link()
628
+ .send_message(WindowEditorMsg::Drop(column, target, None));
629
+ return false;
630
+ },
631
+ WindowEditorMsg::New(_, InPlaceColumn::Expression(_)) => {
632
+ // In-place expression creation is a config-selector affordance;
633
+ // staged window slots take existing columns only.
634
+ return false;
635
+ },
636
+ WindowEditorMsg::Drop(column, target, origin) => {
637
+ // The origin slot clears FIRST so a self-move (origin ==
638
+ // target) re-inserts the same column and nets to no change.
639
+ match origin {
640
+ Some(DragTarget::WindowSource) => self.draft.source = String::default(),
641
+ Some(DragTarget::WindowOrderBy) => self.draft.order_by = String::default(),
642
+ Some(DragTarget::WindowPartitionBy) => {
643
+ self.draft.partition_by.retain(|x| x != &column)
644
+ },
645
+ _ => {},
646
+ }
647
+
648
+ // Slots take true `Table` columns ONLY - a staged drop of
649
+ // an expression or window column is rejected outright (the
650
+ // list already showed the invalid-X overlay during hover).
651
+ let is_table_column = ctx
652
+ .props()
653
+ .metadata
654
+ .get_table_columns()
655
+ .into_iter()
656
+ .flatten()
657
+ .any(|x| *x == column);
658
+
659
+ match target {
660
+ _ if target.is_staged() && !is_table_column => {},
661
+ DragTarget::WindowSource => {
662
+ self.draft.source = column;
663
+
664
+ // The op selector only offers the feature-declared
665
+ // set for the source's type, so an op orphaned by
666
+ // the new source coerces to that set's first entry.
667
+ let available = ctx
668
+ .props()
669
+ .metadata
670
+ .get_column_table_type(&self.draft.source)
671
+ .and_then(|ty| {
672
+ ctx.props()
673
+ .metadata
674
+ .get_features()
675
+ .map(|x| x.get_window_aggregates(ty))
676
+ })
677
+ .unwrap_or_default();
678
+
679
+ if !op_from_label(&self.draft.op)
680
+ .map(|op| available.contains(&op))
681
+ .unwrap_or_default()
682
+ && let Some(first) = available.first()
683
+ {
684
+ self.draft.op = op_label(*first).to_string();
685
+ }
686
+ },
687
+ DragTarget::WindowOrderBy => self.draft.order_by = column,
688
+ DragTarget::WindowPartitionBy
689
+ if !column.is_empty() && !self.draft.partition_by.contains(&column) =>
690
+ {
691
+ self.draft.partition_by.push(column);
692
+ },
693
+ // A committing target: the config zone handles its own
694
+ // insert; only the staged origin cleanup applies here.
695
+ _ => {},
696
+ }
697
+ },
698
+ WindowEditorMsg::SetOp(op) => {
699
+ self.draft.op = op;
700
+
701
+ // Keep the frame coherent as the op class changes (`rate`
702
+ // requires a Range frame; the frame-type dropdown omits the
703
+ // rest).
704
+ if op_from_label(&self.draft.op) == Some(WindowAggregate::Rate) {
705
+ self.draft.frame_type = "Range".to_string();
706
+ }
707
+ },
708
+ WindowEditorMsg::ClearSource => self.draft.source = String::default(),
709
+ WindowEditorMsg::ClearOrderBy => self.draft.order_by = String::default(),
710
+ WindowEditorMsg::ToggleOrderDir => self.draft.order_desc = !self.draft.order_desc,
711
+ WindowEditorMsg::RemovePartition(idx) => {
712
+ if idx < self.draft.partition_by.len() {
713
+ self.draft.partition_by.remove(idx);
714
+ }
715
+ },
716
+ WindowEditorMsg::SetFrameType(x) => self.draft.frame_type = x,
717
+ WindowEditorMsg::SetFrameRows(x) => {
718
+ self.draft.frame_rows = if x.is_finite() && x >= 0.0 {
719
+ x as u32
720
+ } else {
721
+ 1
722
+ };
723
+ },
724
+ WindowEditorMsg::SetFrameRange(x) => {
725
+ self.draft.frame_range = if x.is_finite() && x > 0.0 { x } else { 1.0 };
726
+ },
727
+ WindowEditorMsg::SetOffset(x) => {
728
+ self.draft.offset = if x.is_finite() && x >= 0.0 {
729
+ x as u32
730
+ } else {
731
+ 1
732
+ };
733
+ },
734
+ WindowEditorMsg::SetAlpha(x) => {
735
+ self.draft.alpha = if x.is_finite() && x > 0.0 && x <= 1.0 {
736
+ x
737
+ } else {
738
+ 1.0
739
+ };
740
+ },
741
+ }
742
+
743
+ self.emit(ctx);
744
+ true
745
+ }
746
+
747
+ fn view(&self, ctx: &Context<Self>) -> Html {
748
+ let op = op_from_label(&self.draft.op);
749
+
750
+ // The op selector renders the FEATURE-DECLARED window aggregates
751
+ // for the source column's type, in the server's declared order -
752
+ // capability and type-validity both come from the data model
753
+ // (`GetFeaturesResp::window_aggregates`), not a hardcoded list.
754
+ let ops: Rc<Vec<SelectItem<String>>> = Rc::new(
755
+ ctx.props()
756
+ .metadata
757
+ .get_column_table_type(&self.draft.source)
758
+ .and_then(|ty| {
759
+ ctx.props()
760
+ .metadata
761
+ .get_features()
762
+ .map(|x| x.get_window_aggregates(ty))
763
+ })
764
+ .unwrap_or_default()
765
+ .into_iter()
766
+ .map(|x| SelectItem::Option(op_label(x).to_string()))
767
+ .collect(),
768
+ );
769
+
770
+ // Number inputs report `value_as_number` (`NaN` for empty/garbage),
771
+ // the Style tab `NumberField` idiom - the handlers reset invalid
772
+ // values to the default.
773
+ let on_number_input = |f: fn(f64) -> WindowEditorMsg| {
774
+ ctx.link().callback(move |event: InputEvent| {
775
+ let value = event
776
+ .target()
777
+ .and_then(|t| t.dyn_into::<HtmlInputElement>().ok())
778
+ .map(|x| x.value_as_number())
779
+ .unwrap_or(f64::NAN);
780
+ f(value)
781
+ })
782
+ };
783
+
784
+ // Frame type as a dropdown; like the op selector, invalid choices
785
+ // are omitted rather than disabled (`rate` requires a Range frame,
786
+ // and `SetOp` already coerces the draft there).
787
+ let frame_types: Rc<Vec<SelectItem<String>>> = Rc::new(
788
+ if op == Some(WindowAggregate::Rate) {
789
+ vec!["Range"]
790
+ } else {
791
+ vec!["Rows", "Range", "Cumulative"]
792
+ }
793
+ .into_iter()
794
+ .map(|x| SelectItem::Option(x.to_string()))
795
+ .collect(),
796
+ );
797
+
798
+ // Slots take true `Table` columns ONLY - expression aliases and
799
+ // other window columns would create dependency cycles (and force
800
+ // delete-blocking). They join every slot's `exclude` set (removing
801
+ // them from the autocomplete dropdowns), the drop handler rejects
802
+ // them, and an invalid hover shows the X overlay below.
803
+ let non_table: HashSet<String> = ctx
804
+ .props()
805
+ .metadata
806
+ .get_expression_columns()
807
+ .chain(ctx.props().metadata.get_window_columns())
808
+ .cloned()
809
+ .collect();
810
+
811
+ let is_invalid_drag = |target: DragTarget| {
812
+ ctx.props()
813
+ .presentation
814
+ .is_dragover(target)
815
+ .map(|(_, col)| non_table.contains(&col))
816
+ .unwrap_or_default()
817
+ };
818
+
819
+ // The op selector renders in the source pill's aggregate-selector
820
+ // space - the ONLY place a window op control exists in the UI.
821
+ let source_exclude: HashSet<String> = std::iter::once(self.draft.source.clone())
822
+ .chain(non_table.iter().cloned())
823
+ .collect();
824
+
825
+ let source_list = html! {
826
+ <DragDropList<WindowEditor, WindowSlotColumn, WindowSourceContext>
827
+ name="window-source"
828
+ parent={ctx.link().clone()}
829
+ presentation={ctx.props().presentation.clone()}
830
+ column_dropdown={self.column_dropdown.clone()}
831
+ exclude={source_exclude}
832
+ is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowSource)}
833
+ is_invalid={is_invalid_drag(DragTarget::WindowSource)}
834
+ single_slot=true
835
+ >
836
+ { for (!self.draft.source.is_empty()).then(|| yew::html_nested! {
837
+ <WindowSlotColumn
838
+ column={self.draft.source.clone()}
839
+ column_type={ctx.props().metadata.get_column_table_type(&self.draft.source)}
840
+ action={DragTarget::WindowSource}
841
+ presentation={ctx.props().presentation.clone()}
842
+ aggregate={html! {
843
+ <div class="aggregate-selector-wrapper">
844
+ <Select<String>
845
+ wrapper_class="aggregate-selector"
846
+ values={ops.clone()}
847
+ selected={self.draft.op.clone()}
848
+ on_select={ctx.link().callback(WindowEditorMsg::SetOp)}
849
+ />
850
+ </div>
851
+ }}
852
+ />
853
+ }) }
854
+ </DragDropList<WindowEditor, WindowSlotColumn, WindowSourceContext>>
855
+ };
856
+
857
+ let order_exclude: HashSet<String> = std::iter::once(self.draft.order_by.clone())
858
+ .chain(non_table.iter().cloned())
859
+ .collect();
860
+ let order_list = html! {
861
+ <DragDropList<WindowEditor, WindowSlotColumn, WindowOrderByContext>
862
+ name="window-order-by"
863
+ parent={ctx.link().clone()}
864
+ presentation={ctx.props().presentation.clone()}
865
+ column_dropdown={self.column_dropdown.clone()}
866
+ exclude={order_exclude}
867
+ is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowOrderBy)}
868
+ is_invalid={is_invalid_drag(DragTarget::WindowOrderBy)}
869
+ single_slot=true
870
+ >
871
+ { for (!self.draft.order_by.is_empty()).then(|| {
872
+ // The sort pill's direction affordance, staged: the
873
+ // toggle mutates the draft only, committed by Save.
874
+ let dir = if self.draft.order_desc {
875
+ WindowSortDir::Desc
876
+ } else {
877
+ WindowSortDir::Asc
878
+ };
879
+
880
+ let onmousedown = ctx
881
+ .link()
882
+ .callback(|_: MouseEvent| WindowEditorMsg::ToggleOrderDir);
883
+
884
+ yew::html_nested! {
885
+ <WindowSlotColumn
886
+ column={self.draft.order_by.clone()}
887
+ column_type={ctx.props().metadata.get_column_table_type(&self.draft.order_by)}
888
+ action={DragTarget::WindowOrderBy}
889
+ presentation={ctx.props().presentation.clone()}
890
+ trailing={html! {
891
+ <span
892
+ class={format!("sort-icon {}", dir)}
893
+ {onmousedown}
894
+ />
895
+ }}
896
+ />
897
+ }
898
+ }) }
899
+ </DragDropList<WindowEditor, WindowSlotColumn, WindowOrderByContext>>
900
+ };
901
+
902
+ let partition_exclude: HashSet<String> = self
903
+ .draft
904
+ .partition_by
905
+ .iter()
906
+ .cloned()
907
+ .chain(non_table.iter().cloned())
908
+ .collect();
909
+ let partition_list = html! {
910
+ <DragDropList<WindowEditor, PivotColumn, WindowPartitionByContext>
911
+ name="window-partition-by"
912
+ parent={ctx.link().clone()}
913
+ presentation={ctx.props().presentation.clone()}
914
+ column_dropdown={self.column_dropdown.clone()}
915
+ exclude={partition_exclude}
916
+ is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowPartitionBy)}
917
+ is_invalid={is_invalid_drag(DragTarget::WindowPartitionBy)}
918
+ >
919
+ { for self.draft.partition_by.iter().map(|col| yew::html_nested! {
920
+ <PivotColumn
921
+ column={col.clone()}
922
+ column_type={ctx.props().metadata.get_column_table_type(col)}
923
+ action={DragTarget::WindowPartitionBy}
924
+ presentation={ctx.props().presentation.clone()}
925
+ />
926
+ }) }
927
+ </DragDropList<WindowEditor, PivotColumn, WindowPartitionByContext>>
928
+ };
929
+
930
+ let show_frame =
931
+ op.map(is_aggregating).unwrap_or_default() || op == Some(WindowAggregate::Rate);
932
+ let show_offset = op.map(is_positional).unwrap_or_default();
933
+ let show_alpha = op == Some(WindowAggregate::Ema);
934
+
935
+ html! {
936
+ <>
937
+ <div id="window-editor-slots">{ source_list }{ order_list }{ partition_list }</div>
938
+ <div id="window-editor-container">
939
+ if show_frame {
940
+ <div class="column-style-label"><label id="window-frame-label" /></div>
941
+ <div class="row">
942
+ <Select<String>
943
+ id="window-frame-type"
944
+ values={frame_types}
945
+ selected={self.draft.frame_type.clone()}
946
+ on_select={ctx.link().callback(WindowEditorMsg::SetFrameType)}
947
+ />
948
+ </div>
949
+ if self.draft.frame_type == "Rows" {
950
+ <div class="row">
951
+ <input
952
+ type="number"
953
+ class="parameter"
954
+ min="0"
955
+ value={self.draft.frame_rows.to_string()}
956
+ oninput={on_number_input(WindowEditorMsg::SetFrameRows)}
957
+ />
958
+ </div>
959
+ }
960
+ if self.draft.frame_type == "Range" {
961
+ <div class="row">
962
+ <input
963
+ type="number"
964
+ class="parameter"
965
+ min="0"
966
+ value={self.draft.frame_range.to_string()}
967
+ oninput={on_number_input(WindowEditorMsg::SetFrameRange)}
968
+ />
969
+ </div>
970
+ }
971
+ }
972
+ if show_offset {
973
+ <div class="column-style-label">
974
+ <label id="window-offset-label" class="indent" />
975
+ </div>
976
+ <div class="row">
977
+ <input
978
+ type="number"
979
+ class="parameter"
980
+ min="0"
981
+ value={self.draft.offset.to_string()}
982
+ oninput={on_number_input(WindowEditorMsg::SetOffset)}
983
+ />
984
+ </div>
985
+ }
986
+ if show_alpha {
987
+ <div class="column-style-label">
988
+ <label id="window-alpha-label" class="indent" />
989
+ </div>
990
+ <div class="row">
991
+ <input
992
+ type="number"
993
+ class="parameter"
994
+ step="0.05"
995
+ min="0"
996
+ max="1"
997
+ value={self.draft.alpha.to_string()}
998
+ oninput={on_number_input(WindowEditorMsg::SetAlpha)}
999
+ />
1000
+ </div>
1001
+ }
1002
+ if let Some(error) = &self.error {
1003
+ <div class="row window-editor-error">{ error.clone() }</div>
1004
+ }
1005
+ </div>
1006
+ <ColumnDropDownPortal
1007
+ element={self.column_dropdown.clone()}
1008
+ theme={ctx.props().selected_theme.clone().unwrap_or_default()}
1009
+ />
1010
+ </>
1011
+ }
1012
+ }
1013
+ }