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