Skip to main content

perspective_viewer/components/
window_editor.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::collections::HashSet;
14use std::rc::Rc;
15
16use perspective_client::config::{
17    ColumnType, WindowAggregate, WindowFrame, WindowSort, WindowSortDir, WindowSpec,
18};
19use wasm_bindgen::JsCast;
20use web_sys::{DragEvent, HtmlInputElement, MouseEvent};
21use yew::prelude::*;
22
23use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
24use crate::components::column_selector::{ColumnSelectorColumnRow, InPlaceColumn, PivotColumn};
25use crate::components::containers::dragdrop_list::{
26    DragContext, DragDropList, DragDropListItemProps,
27};
28use crate::components::containers::select::{Select, SelectItem};
29use crate::presentation::Presentation;
30use crate::session::{Session, SessionMetadataRc};
31use crate::utils::{AddListener, DragEffect, DragTarget, Subscription};
32
33fn 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
52fn 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
72fn 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
85fn is_positional(op: WindowAggregate) -> bool {
86    matches!(
87        op,
88        WindowAggregate::Lag | WindowAggregate::Lead | WindowAggregate::Diff
89    )
90}
91
92fn 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)]
100pub 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
129impl 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)]
146struct 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
159impl 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
176impl 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)]
361pub 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.
388struct WindowSourceContext;
389struct WindowOrderByContext;
390struct WindowPartitionByContext;
391
392impl 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
414impl 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
436impl 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)]
465pub 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
483impl 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
493impl DragDropListItemProps for WindowSlotColumnProps {
494    type Item = String;
495
496    fn get_item(&self) -> String {
497        self.column.clone()
498    }
499}
500
501pub struct WindowSlotColumn;
502
503impl 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
543pub struct WindowEditor {
544    draft: WindowDraft,
545    error: Option<String>,
546    column_dropdown: ColumnDropDownElement,
547    _drop_sub: Subscription,
548}
549
550impl 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
573impl 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}