diff --git a/Cargo.toml b/Cargo.toml index ac82cdc057..c1d88de108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,13 @@ codegen-units = 1 lto = true strip = true +[profile.fast] +inherits = "release" +opt-level = 1 +codegen-units = 256 +lto = "off" +incremental = true + # These are only respected when `cargo` is invoked from the project root [patch.crates-io] # simd-adler32 = { git = "https://github.com/mcountryman/simd-adler32.git", rev = "b279034d9eb554c3e5e0af523db044f08d8297ba" } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 923fb61f7b..b0609b152c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -98,6 +98,7 @@ Other useful options: | Variable | Effect | | --------------------- | ---------------------------------------------------------- | | `PSP_DEBUG=1` | Debug build | +| `PSP_FAST=1` | Release build with `-O1`, no LTO and no `wasm-opt` | | `PSP_BUILD_VERBOSE=1` | Verbose C++ build output | | `PSP_NUM_CPUS=` | Limit C++ build parallelism | | `PSP_WASM64=1` | Also build the `wasm64` (Memory64) engine | diff --git a/docs/md/explanation/derived_table.md b/docs/md/explanation/derived_table.md index f4ac9f4545..6b9fb329b6 100644 --- a/docs/md/explanation/derived_table.md +++ b/docs/md/explanation/derived_table.md @@ -62,10 +62,10 @@ An unpivoted `View` yields a `Table` with the `View`'s own columns, including `View`'s `columns`, and its `limit` is inherited as well. A pivoted `View` yields the table you see on screen. Each `group_by` column -becomes a typed key column of the same name, and each data column takes the -name `View::column_paths` gives it. A column that is both a `group_by` key and -one of the `View`'s `columns` would collide with itself, so that aggregate is -qualified by its aggregate, as in `"Region (count)"` or +becomes a typed key column of the same name, and each data column takes the name +`View::column_paths` gives it. A column that is both a `group_by` key and one of +the `View`'s `columns` would collide with itself, so that aggregate is qualified +by its aggregate, as in `"Region (count)"` or `"Sales (weighted mean by Weight)"`; the key is always the bare column name: ``` @@ -115,7 +115,7 @@ Declare the columns up front to accept values that have not arrived yet: ```javascript const derived = await client.table(view, { schema: { - "Region": "string", + Region: "string", "Mon|Sales": "float", "Tue|Sales": "float", "Wed|Sales": "float", @@ -290,6 +290,43 @@ On a [Virtual Server](./virtual_servers.md) a derived `Table` is a snapshot of the `View` taken when it is created, since a virtual `View` is itself a snapshot. +## In the viewer + +`` exposes derivation from the settings sidebar's query tab +and from the panel's right-click context menu. **Derive table** (at the bottom +of the settings panel) and the context menu's **Derive Table** each create a +derived `Table` under a generated name — `"superstore GROUP BY Region"`-style — +from whatever the panel currently displays, then rebind the panel to it. Both +require a pivoted view. + +A derived `Table` created this way is an ordinary named table in the workspace: +it appears in the table pickers and the "New" menu, other panels may bind to it, +and it outlives the panel that created it. The "New" menu marks derived rows, +shows how many panels and tables read each one, and offers a delete button once +nothing does. + +The panel's derivation chain appears above the control, one row per table from +the base up. The row for the panel's own table carries a close button: + +- **Click** rebinds the panel to the parent table, restoring the config the + derived table was created with. The derived table itself is untouched. +- **Shift+click** rebinds _and_ deletes the derived table, which is offered only + when that panel is the only thing reading it. + +A backend which cannot promote a `View` to a `Table` reports so through the +`view_derivations` capability, and the control is hidden for panels on that +client. Where a backend can derive but does not stream — DuckDB and PostgreSQL — +each level is badged as a snapshot, because its data is fixed at the moment it +was derived. + +Derived tables persist in `saveWorkspace()` output under the top-level +`derived_tables` key, each entry naming the `table` its source `View` reads and +that view's config. Workspace panels refer to tables by name; the single-panel +`save()` format instead inlines the definition recursively in its `table` field, +so the token is self-contained, and `restore()` rebinds to a structurally +identical derived table or creates one under a generated name. See +[Save & restore](../how_to/javascript/save_restore.md). + ## Changes to `client.table(view)` Previous releases copied the `View`'s data into an ordinary `Table` and diff --git a/docs/md/explanation/view/config/windows.md b/docs/md/explanation/view/config/windows.md index b582a38481..05a09f00ce 100644 --- a/docs/md/explanation/view/config/windows.md +++ b/docs/md/explanation/view/config/windows.md @@ -81,15 +81,20 @@ property does. | `diff` | This row's value minus the value `offset` rows behind | `float` | | `rate` | Rate of change across the frame | `float` | | `ema` | Exponential moving average, smoothed by `alpha` | `float` | +| `row_number` | 1-based position of the row in its partition's order | `integer` | `sum`, `avg`, `stddev`, `var`, `diff`, `rate` and `ema` require a numeric input column. +`row_number` numbers rows, not values: it ignores `column` (any column +satisfies it), never skips a row whose input is null, and breaks ties on +`order_by` by primary key. + ### Frame compatibility - `sum`, `avg`, `count`, `min`, `max`, `stddev` and `var` accept any frame. -- `lag`, `lead`, `diff` and `ema` are frame-independent — they are computed - from row offsets rather than a frame. +- `lag`, `lead`, `diff`, `ema` and `row_number` are frame-independent — they + are computed from row positions rather than a frame. - **`rate` requires a `range` frame**, and is invalid with `rows` or `cumulative`. diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md index 6eea847f0c..9ae4489c67 100644 --- a/docs/md/how_to/javascript/save_restore.md +++ b/docs/md/how_to/javascript/save_restore.md @@ -122,17 +122,63 @@ keys are ignored). Like `restore()`, `restoreWorkspace()` applies an _update_: a field that is absent leaves that part of the element unchanged, `null` resets it to its -default, and a value replaces it. `panels` is the whole panel set — when -present every existing panel is replaced (`{}` empties the element); when -absent the panels are kept, and `layout`, `active` and `masters` name them by -their existing ids. So a cross-filter can be applied to the current panels -without re-creating them: +default, and a value replaces it. `panels` is the whole panel set — when present +every existing panel is replaced (`{}` empties the element); when absent the +panels are kept, and `layout`, `active` and `masters` name them by their +existing ids. So a cross-filter can be applied to the current panels without +re-creating them: ```javascript await elem.restoreWorkspace({ global_filters: [["Region", "==", "West"]] }); await elem.restoreWorkspace({ global_filters: null }); // clear ``` +### Derived tables + +A workspace token carries a top-level `derived_tables` map, one entry per +derived `Table` the element owns, each naming the `table` its source `View` +reads plus that view's config: + +```javascript +await elem.restoreWorkspace({ + panels: { one: { table: "Region Totals", plugin: "Datagrid" } }, + derived_tables: { + "Region Totals": { table: "superstore", group_by: ["Region"] }, + }, +}); +``` + +Within a workspace token, panels refer to tables by name, so a panel entry never +contains a derivation of its own — `one` above simply binds `"Region Totals"`. +Entries may name another derived table as their `table`, and they are built +parent-first regardless of the order they appear in. + +`derived_tables` follows the same update rule as `panels`: absent keeps the +current set, a value replaces it entirely, and `{}` deletes every derived table. +An entry whose definition has not changed is reused rather than rebuilt. + +A derived table whose `table` is not hosted yet does not fail the restore — it +and anything under it wait, along with the panels bound to them, and are built +when the parent table appears. A dependency cycle is rejected outright. + +The single-panel format inlines instead: `save()` of a panel bound to a derived +table writes the whole definition recursively in the `table` field, so the token +is self-contained: + +```javascript +await elem.restore({ + plugin: "Datagrid", + table: { table: "superstore", group_by: ["Region"] }, +}); +``` + +`restore()` rebinds to an existing derived table when one was built from a +structurally identical definition, and otherwise creates one under a generated +name. Only the single-panel `restore()` accepts the inline form — in +`restoreWorkspace()` every `table` field is a name. + +See [Derived tables](../../explanation/derived_table.md). + ## Colors, palettes and gradients Per-column color styling lives in a panel's `columns_config`, keyed by column diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index 6c43fbf529..de1463a034 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -57,7 +57,7 @@ function window_output_type( source_column: string, table_schema: Schema, ): ColumnType { - if (aggregate === "count") { + if (aggregate === "count" || aggregate === "row_number") { return "integer"; } diff --git a/rust/bundle/main.rs b/rust/bundle/main.rs index bb9d6ac8c7..d522349d02 100644 --- a/rust/bundle/main.rs +++ b/rust/bundle/main.rs @@ -26,6 +26,10 @@ struct BundleArgs { #[arg(short, long)] release: bool, + /// Compile with the `fast` profile? + #[arg(long, conflicts_with = "release")] + fast: bool, + /// Extra features to build with #[arg(long)] features: Option, @@ -33,11 +37,39 @@ struct BundleArgs { use wasm_bindgen_cli_support::{Bindgen, EncodeInto}; +/// The `cargo` profile to build and bundle. +#[derive(Clone, Copy, PartialEq)] +enum Profile { + Dev, + Release, + Fast, +} + +impl Profile { + /// The `cargo build` arguments which select this profile. + fn cargo_args(self) -> &'static [&'static str] { + match self { + Profile::Dev => &[], + Profile::Release => &["--release"], + Profile::Fast => &["--profile", "fast"], + } + } + + /// The name of this profile's directory under the `cargo` target dir. + fn dir(self) -> &'static str { + match self { + Profile::Dev => "debug", + Profile::Release => "release", + Profile::Fast => "fast", + } + } +} + /// Run the packages `build` task with the appropriate flags. These can't be /// defined in the `/.cargo/config.toml` because they would define this build /// script's parameters also, and there is no way to reset e.g. the `target` /// field to the host platform. -fn build(pkg: Option<&str>, is_release: bool, features: Vec) { +fn build(pkg: Option<&str>, profile: Profile, features: Vec) { let features = format!("tracing/release_max_level_warn,{}", features.join(",")); // Build RUSTFLAGS including target-specific flags from config.toml and new @@ -56,11 +88,8 @@ fn build(pkg: Option<&str>, is_release: bool, features: Vec) { .args(["--lib"]) .args(["--features", &features]) .args(["--target", "wasm32-unknown-unknown"]) - .args(["-Z", "build-std=std,panic_abort"]); - - if is_release { - cmd.args(["--release"]); - } + .args(["-Z", "build-std=std,panic_abort"]) + .args(profile.cargo_args()); if let Some(pkg) = pkg { cmd.args(["-p", pkg]); @@ -93,16 +122,16 @@ fn target_dir() -> PathBuf { } /// Generate the `wasm-bindgen` JavaScript and WASM bindings. -fn bindgen(outdir: &Path, artifact: &str, is_release: bool) { +fn bindgen(outdir: &Path, artifact: &str, profile: Profile) { let input = target_dir() .join("wasm32-unknown-unknown") - .join(if is_release { "release" } else { "debug" }) + .join(profile.dir()) .join(format!("{artifact}.wasm")); Bindgen::new() .web(true) .unwrap() - .keep_debug(!is_release) + .keep_debug(profile == Profile::Dev) .input_path(input) .encode_into(EncodeInto::Always) .typescript(true) @@ -168,8 +197,8 @@ fn wasm_opt_version(bin: &Path) -> Option { } /// Run `wasm-opt` and output the new binary on top of the old one. -fn opt(outpath: &Path, is_release: bool) { - if !is_release { +fn opt(outpath: &Path, profile: Profile) { + if profile != Profile::Release { return; } @@ -208,7 +237,12 @@ fn opt(outpath: &Path, is_release: bool) { fn main() { let args = BundleArgs::parse(); let outdir = Path::new("dist/wasm"); - let is_release = args.release; + let profile = match (args.release, args.fast) { + (_, true) => Profile::Fast, + (true, _) => Profile::Release, + _ => Profile::Dev, + }; + let package = args.artifact.clone().replace('_', "-"); let outpath = &Path::new(outdir).join(format!( "{}.wasm", @@ -222,9 +256,9 @@ fn main() { .map(|x| x.to_string()) .collect(); - build(Some(package.as_str()), is_release, features); - bindgen(outdir, args.artifact.as_str(), is_release); - opt(outpath, is_release); + build(Some(package.as_str()), profile, features); + bindgen(outdir, args.artifact.as_str(), profile); + opt(outpath, profile); } trait SimpleCommand { diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs index 369a0da7cf..b5cf5cbbcb 100644 --- a/rust/perspective-js/build.mjs +++ b/rust/perspective-js/build.mjs @@ -21,6 +21,8 @@ import { compress } from "pro_self_extracting_wasm"; const IS_DEBUG = !!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0; +const IS_FAST = !IS_DEBUG && !!process.env.PSP_FAST; + const BUILD = [ { entryPoints: ["src/ts/perspective-server.worker.ts"], @@ -104,7 +106,7 @@ function get_host() { } async function build_rust() { - const release_flag = IS_DEBUG ? "" : "--release"; + const release_flag = IS_DEBUG ? "" : IS_FAST ? "--fast" : "--release"; execSync( `PSP_ROOT_DIR=../.. cargo bundle --target=${get_host()} -- perspective_js ${release_flag} --features="export-init,talc-allocator"`, INHERIT, diff --git a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts index 866ed92b86..cd9ef8dc05 100644 --- a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts +++ b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts @@ -92,6 +92,7 @@ const WINDOW_AGGREGATES: WindowAggSpec[] = [ { name: "lead", offset: true }, { name: "diff", offset: true, result_type: "float" }, { name: "rate", frames: ["range"], result_type: "float" }, + { name: "row_number", result_type: "float" }, ]; const WINDOW_AGGREGATES_ANY: WindowAggSpec[] = [ @@ -100,6 +101,7 @@ const WINDOW_AGGREGATES_ANY: WindowAggSpec[] = [ { name: "max", frames: FRAMES }, { name: "lag", offset: true }, { name: "lead", offset: true }, + { name: "row_number", result_type: "float" }, ]; const FILTER_OPS = [ diff --git a/rust/perspective-js/test/js/duckdb/windows.spec.js b/rust/perspective-js/test/js/duckdb/windows.spec.js index 9bdcbf41e4..cd2d75ebb9 100644 --- a/rust/perspective-js/test/js/duckdb/windows.spec.js +++ b/rust/perspective-js/test/js/duckdb/windows.spec.js @@ -303,4 +303,31 @@ describeDuckDB("windows", (getClient) => { expect(result[first].length).toBe(rows.length); await view.delete(); }); + + test("row_number partitioned by Region counts from one in Row ID order", async function () { + const rows = await raw_rows(getClient()); + const running = {}; + const expected = rows.map((r) => { + running[r.region] = (running[r.region] ?? 0) + 1; + return running[r.region]; + }); + + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Row ID", "rn"], + sort: [["Row ID", "asc"]], + windows: { + rn: { + column: "Sales", + aggregate: "row_number", + order_by: ["Row ID", "asc"], + partition_by: ["Region"], + }, + }, + }); + + const result = await view.to_columns(); + expect(result["rn"].map(Number)).toEqual(expected); + await view.delete(); + }); }); diff --git a/rust/perspective-js/test/js/expressions/updates.spec.js b/rust/perspective-js/test/js/expressions/updates.spec.js index 1193f30f6c..95de26fcfa 100644 --- a/rust/perspective-js/test/js/expressions/updates.spec.js +++ b/rust/perspective-js/test/js/expressions/updates.spec.js @@ -1467,4 +1467,100 @@ const pivot_data = [ table.delete(); }); }); + + test.describe("Expression aggregates over rows inserted while a view is live", function () { + const SCHEMA = { + id: "integer", + side: "string", + price: "float", + timestamp: "datetime", + }; + + async function insert_batches(table, n_batches, batch_size) { + let id = 0; + for (let b = 0; b < n_batches; b++) { + const rows = []; + for (let i = 0; i < batch_size; i++) { + rows.push({ + id, + side: id % 2 ? "buy" : "sell", + price: 20 + (id % 4) * 0.5, + timestamp: new Date(b * 60_000), + }); + id++; + } + + await table.update(rows); + } + } + + async function expect_oracle(config, n_batches = 4, batch_size = 5) { + const table = await perspective.table(SCHEMA, { index: "id" }); + const live = await table.view(config); + await insert_batches(table, n_batches, batch_size); + const oracle = await table.view(config); + const live_result = await live.to_columns(); + const oracle_result = await oracle.to_columns(); + expect(live_result).toEqual(oracle_result); + await oracle.delete(); + await live.delete(); + await table.delete(); + return oracle_result; + } + + test("String conditional expression sums under group_by", async function () { + await expect_oracle({ + group_by: ["side"], + columns: ["v"], + expressions: { v: `if("side"=='buy'){-1}else{1}` }, + }); + }); + + test("String conditional expression sums under split_by", async function () { + await expect_oracle({ + group_by: ["side"], + split_by: ["side"], + columns: ["v"], + expressions: { v: `if("side"=='buy'){-1}else{1}` }, + }); + }); + + test("Groups born after the first batch are not null", async function () { + const result = await expect_oracle({ + group_by: [`bucket("timestamp", 'm')`], + columns: ["v"], + expressions: { + [`bucket("timestamp", 'm')`]: `bucket("timestamp", 'm')`, + v: `if("side"=='buy'){-1}else{1}`, + }, + }); + + expect(result["__ROW_PATH__"].length).toEqual(5); + for (const value of result["v"]) { + expect(value).not.toEqual(null); + } + }); + + test("Expression group_by and split_by with conditional value", async function () { + await expect_oracle({ + group_by: [`bucket("timestamp", 'm')`], + split_by: [`bucket("price", 0.5)`], + columns: ["v"], + expressions: { + [`bucket("timestamp", 'm')`]: `bucket("timestamp", 'm')`, + [`bucket("price", 0.5)`]: `bucket("price", 0.5)`, + v: `if("side"=='buy'){-1}else{1}`, + }, + }); + }); + + test("String conditional expression as group_by", async function () { + await expect_oracle({ + group_by: ["g"], + columns: ["price"], + aggregates: { price: "sum" }, + expressions: { g: `if("side"=='buy'){'B'}else{'S'}` }, + }); + }); + }); })(perspective); diff --git a/rust/perspective-js/test/js/windows.spec.js b/rust/perspective-js/test/js/windows.spec.js index 3249bfe105..035f33be0d 100644 --- a/rust/perspective-js/test/js/windows.spec.js +++ b/rust/perspective-js/test/js/windows.spec.js @@ -1032,4 +1032,339 @@ test.describe("Window columns", function () { await view.delete(); await table.delete(); }); + + test("row_number orders by order_by, not insertion order", async function () { + const table = await perspective.table({ + t: "integer", + sym: "string", + price: "float", + }); + await table.update(data); + const view = await table.view({ + columns: ["t", "sym", "rn"], + sort: [ + ["t", "asc"], + ["sym", "asc"], + ], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + }, + }, + }); + + const schema = await view.schema(); + expect(schema["rn"]).toBe("integer"); + const result = await view.to_columns(); + expect(result["t"]).toEqual([1, 2, 2, 3, 4]); + expect(result["sym"]).toEqual(["a", "a", "b", "a", "b"]); + expect(result["rn"]).toEqual([1, 3, 2, 4, 5]); + await view.delete(); + await table.delete(); + }); + + test("row_number restarts per partition", async function () { + const table = await perspective.table({ + t: "integer", + sym: "string", + price: "float", + }); + await table.update(data); + const view = await table.view({ + columns: ["t", "sym", "rn"], + sort: [ + ["sym", "asc"], + ["t", "asc"], + ], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + partition_by: ["sym"], + }, + }, + }); + + const result = await view.to_columns(); + expect(result["sym"]).toEqual(["a", "a", "a", "b", "b"]); + expect(result["rn"]).toEqual([1, 2, 3, 1, 2]); + await view.delete(); + await table.delete(); + }); + + test("row_number descending numbers from the largest order key", async function () { + const table = await perspective.table({ + t: "integer", + sym: "string", + price: "float", + }); + await table.update(data); + const view = await table.view({ + columns: ["t", "rn"], + sort: [["t", "asc"]], + filter: [["sym", "==", "a"]], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "desc"], + partition_by: ["sym"], + }, + }, + }); + + const result = await view.to_columns(); + expect(result["t"]).toEqual([1, 2, 3]); + expect(result["rn"]).toEqual([3, 2, 1]); + await view.delete(); + await table.delete(); + }); + + test("row_number does not skip rows whose source value is null", async function () { + const table = await perspective.table({ + t: "integer", + price: "float", + }); + await table.update([ + { t: 1, price: 10 }, + { t: 2, price: null }, + { t: 3, price: 30 }, + ]); + const view = await table.view({ + columns: ["t", "rn", "n"], + sort: [["t", "asc"]], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + }, + n: { + column: "price", + aggregate: "count", + order_by: ["t", "asc"], + cumulative: true, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["rn"]).toEqual([1, 2, 3]); + expect(result["n"]).toEqual([1, 1, 2]); + await view.delete(); + await table.delete(); + }); + + test("row_number breaks order key ties by primary key in both directions", async function () { + const table = await perspective.table( + { id: "integer", t: "integer", price: "float" }, + { index: "id" }, + ); + await table.update([ + { id: 3, t: 1, price: 1 }, + { id: 1, t: 1, price: 2 }, + { id: 2, t: 0, price: 3 }, + ]); + const view = await table.view({ + columns: ["id", "asc", "desc"], + sort: [["id", "asc"]], + windows: { + asc: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + }, + desc: { + column: "price", + aggregate: "row_number", + order_by: ["t", "desc"], + }, + }, + }); + + const result = await view.to_columns(); + expect(result["id"]).toEqual([1, 2, 3]); + expect(result["asc"]).toEqual([2, 1, 3]); + expect(result["desc"]).toEqual([1, 3, 2]); + await view.delete(); + await table.delete(); + }); + + test("streaming insert renumbers successors and reports their delta", async function () { + const table = await perspective.table( + { t: "integer", sym: "string", price: "float" }, + { index: "t" }, + ); + await table.update([ + { t: 1, sym: "a", price: 10 }, + { t: 2, sym: "b", price: 20 }, + { t: 3, sym: "a", price: 30 }, + { t: 4, sym: "a", price: 40 }, + ]); + const view = await table.view({ + columns: ["t", "sym", "rn"], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + partition_by: ["sym"], + }, + }, + }); + await view.to_columns(); + + const delta = new Promise((resolve) => { + view.on_update((updated) => resolve(updated.delta), { + mode: "row", + }); + }); + await table.update([{ t: 0, sym: "a", price: 0 }]); + + const delta_table = await perspective.table(await delta); + const delta_view = await delta_table.view({ sort: [["t", "asc"]] }); + const result = await delta_view.to_columns(); + expect(result["t"]).toEqual([0, 1, 3, 4]); + expect(result["rn"]).toEqual([1, 2, 3, 4]); + await delta_view.delete(); + await delta_table.delete(); + + const after = await view.to_columns(); + const by_t = Object.fromEntries( + after["t"].map((t, i) => [t, after["rn"][i]]), + ); + expect(by_t).toEqual({ 0: 1, 1: 2, 2: 1, 3: 3, 4: 4 }); + await view.delete(); + await table.delete(); + }); + + test("remove renumbers successors without a gap", async function () { + const table = await perspective.table( + { t: "integer", price: "float" }, + { index: "t" }, + ); + await table.update([ + { t: 1, price: 10 }, + { t: 2, price: 20 }, + { t: 3, price: 30 }, + ]); + const view = await table.view({ + columns: ["t", "rn"], + sort: [["t", "asc"]], + windows: { + rn: { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + }, + }, + }); + await view.to_columns(); + await table.remove([1]); + + const result = await view.to_columns(); + expect(result["t"]).toEqual([2, 3]); + expect(result["rn"]).toEqual([1, 2]); + await view.delete(); + await table.delete(); + }); + + test("row_number with omitted order_by numbers in primary key order", async function () { + const table = await perspective.table( + { id: "integer", price: "float" }, + { index: "id" }, + ); + await table.update([ + { id: 30, price: 1 }, + { id: 10, price: 2 }, + { id: 20, price: 3 }, + ]); + const view = await table.view({ + columns: ["id", "rn"], + sort: [["id", "asc"]], + windows: { + rn: { + column: "price", + aggregate: "row_number", + }, + }, + }); + + const result = await view.to_columns(); + expect(result["id"]).toEqual([10, 20, 30]); + expect(result["rn"]).toEqual([1, 2, 3]); + await view.delete(); + await table.delete(); + }); + + test("row_number rejects rows and range frames but accepts cumulative", async function () { + const table = await perspective.table({ + t: "integer", + price: "float", + }); + await table.update(data.map(({ t, price }) => ({ t, price }))); + const rn = { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + }; + + await expect( + table.view({ windows: { rn: { ...rn, rows: 2 } } }), + ).rejects.toThrow(); + await expect( + table.view({ windows: { rn: { ...rn, range: 2 } } }), + ).rejects.toThrow(); + + const view = await table.view({ + columns: ["rn"], + sort: [["t", "asc"]], + windows: { rn: { ...rn, cumulative: true } }, + }); + const result = await view.to_columns(); + expect(result["rn"]).toEqual([1, 2, 3, 4, 5]); + await view.delete(); + await table.delete(); + }); + + test("row_number works as a group_by key and as an aggregate input", async function () { + const table = await perspective.table({ + t: "integer", + sym: "string", + price: "float", + }); + await table.update(data); + const rn = { + column: "price", + aggregate: "row_number", + order_by: ["t", "asc"], + partition_by: ["sym"], + }; + + const keyed = await table.view({ + group_by: ["rn"], + columns: ["price"], + aggregates: { price: "sum" }, + windows: { rn }, + }); + const keyed_result = await keyed.to_columns(); + expect(keyed_result["__ROW_PATH__"]).toEqual([[], [1], [2], [3]]); + expect(keyed_result["price"]).toEqual([360, 110, 220, 30]); + await keyed.delete(); + + const agg = await table.view({ + group_by: ["sym"], + columns: ["rn"], + aggregates: { rn: "max" }, + windows: { rn }, + }); + const agg_result = await agg.to_columns(); + expect(agg_result["__ROW_PATH__"]).toEqual([[], ["a"], ["b"]]); + expect(agg_result["rn"]).toEqual([3, 3, 2]); + await agg.delete(); + await table.delete(); + }); }); diff --git a/rust/perspective-python/build.mjs b/rust/perspective-python/build.mjs index da04eb8d0a..c585132d08 100644 --- a/rust/perspective-python/build.mjs +++ b/rust/perspective-python/build.mjs @@ -27,6 +27,8 @@ let flags = "--release"; let features = []; if (!!process.env.PSP_DEBUG) { flags = ""; +} else if (!!process.env.PSP_FAST) { + flags = "--profile fast"; } const is_pyodide = !!process.env.PSP_PYODIDE; diff --git a/rust/perspective-python/perspective/tests/table/test_view_expression.py b/rust/perspective-python/perspective/tests/table/test_view_expression.py index 8951f0e535..9f88f1ebea 100644 --- a/rust/perspective-python/perspective/tests/table/test_view_expression.py +++ b/rust/perspective-python/perspective/tests/table/test_view_expression.py @@ -2109,3 +2109,148 @@ def test_view_replace_invalid_variation(self): ] validate = table.validate_expressions(expressions) assert validate["expression_schema"] == {} + + +LIVE_INSERT_SCHEMA = { + "id": "integer", + "side": "string", + "price": "float", + "timestamp": "datetime", +} + + +class TestViewExpressionLiveInserts(object): + def _insert_batches(self, table, n_batches=4, batch_size=5): + next_id = 0 + for b in range(n_batches): + rows = [] + for _ in range(batch_size): + rows.append( + { + "id": next_id, + "side": "buy" if next_id % 2 else "sell", + "price": 20 + (next_id % 4) * 0.5, + "timestamp": datetime(2020, 1, 1, 0, b), + } + ) + next_id += 1 + + table.update(rows) + + def _expect_oracle(self, **config): + table = Table(LIVE_INSERT_SCHEMA, index="id") + live = table.view(**config) + self._insert_batches(table) + oracle = table.view(**config) + live_result = live.to_columns() + oracle_result = oracle.to_columns() + assert live_result == oracle_result + oracle.delete() + live.delete() + table.delete() + return oracle_result + + def test_string_conditional_sum_under_group_by(self): + self._expect_oracle( + group_by=["side"], + columns=["v"], + expressions={"v": "if(\"side\"=='buy'){-1}else{1}"}, + ) + + def test_string_conditional_sum_under_split_by(self): + self._expect_oracle( + group_by=["side"], + split_by=["side"], + columns=["v"], + expressions={"v": "if(\"side\"=='buy'){-1}else{1}"}, + ) + + def test_groups_born_after_first_batch_not_null(self): + result = self._expect_oracle( + group_by=["bucket(\"timestamp\", 'm')"], + columns=["v"], + expressions={ + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "v": "if(\"side\"=='buy'){-1}else{1}", + }, + ) + + assert len(result["__ROW_PATH__"]) == 5 + assert all(value is not None for value in result["v"]) + + def test_expression_pivots_with_conditional_value(self): + self._expect_oracle( + group_by=["bucket(\"timestamp\", 'm')"], + split_by=["bucket(\"price\", 0.5)"], + columns=["v"], + expressions={ + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)", + "v": "if(\"side\"=='buy'){-1}else{1}", + }, + ) + + def test_string_conditional_as_group_by(self): + self._expect_oracle( + group_by=["g"], + columns=["price"], + aggregates={"price": "sum"}, + expressions={"g": "if(\"side\"=='buy'){'B'}else{'S'}"}, + ) + + +class TestViewWindowRowNumber(object): + def test_view_window_row_number(self): + table = Table( + { + "t": [3, 1, 2, 4, 2], + "sym": ["a", "a", "b", "b", "a"], + "price": [30.0, 10.0, 100.0, 200.0, 20.0], + } + ) + view = table.view( + columns=["t", "sym", "rn"], + sort=[["t", "asc"], ["sym", "asc"]], + windows={ + "rn": { + "column": "price", + "aggregate": "row_number", + "order_by": ["t", "asc"], + } + }, + ) + + assert view.schema()["rn"] == "integer" + result = view.to_columns() + assert result["t"] == [1, 2, 2, 3, 4] + assert result["sym"] == ["a", "a", "b", "a", "b"] + assert result["rn"] == [1, 3, 2, 4, 5] + view.delete() + table.delete() + + def test_view_window_row_number_partitioned(self): + table = Table( + { + "t": [3, 1, 2, 4, 2], + "sym": ["a", "a", "b", "b", "a"], + "price": [30.0, 10.0, 100.0, 200.0, 20.0], + } + ) + view = table.view( + columns=["t", "sym", "rn"], + sort=[["sym", "asc"], ["t", "asc"]], + windows={ + "rn": { + "column": "price", + "aggregate": "row_number", + "order_by": ["t", "asc"], + "partition_by": ["sym"], + } + }, + ) + + result = view.to_columns() + assert result["sym"] == ["a", "a", "a", "b", "b"] + assert result["rn"] == [1, 2, 3, 1, 2] + view.delete() + table.delete() diff --git a/rust/perspective-python/perspective/virtual_servers/clickhouse.py b/rust/perspective-python/perspective/virtual_servers/clickhouse.py index 891e762326..5ab73cffc7 100644 --- a/rust/perspective-python/perspective/virtual_servers/clickhouse.py +++ b/rust/perspective-python/perspective/virtual_servers/clickhouse.py @@ -78,6 +78,7 @@ {"name": "lead", "offset": True}, {"name": "diff", "offset": True, "result_type": "float"}, {"name": "rate", "frames": ["range"], "result_type": "float"}, + {"name": "row_number", "result_type": "float"}, ] WINDOW_AGGREGATES_ANY = [ @@ -86,6 +87,7 @@ {"name": "max", "frames": FRAMES}, {"name": "lag", "offset": True}, {"name": "lead", "offset": True}, + {"name": "row_number", "result_type": "float"}, ] FILTER_OPS = [ diff --git a/rust/perspective-server/build.mjs b/rust/perspective-server/build.mjs index 437e9c44c4..841b412d37 100644 --- a/rust/perspective-server/build.mjs +++ b/rust/perspective-server/build.mjs @@ -19,7 +19,12 @@ import "zx/globals"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); -const env = process.env.PSP_DEBUG ? "debug" : "release"; +const env = process.env.PSP_DEBUG + ? "debug" + : process.env.PSP_FAST + ? "fast" + : "release"; + const cwd = path.join(process.cwd(), "build", env); const cmake_dir = path.join(__dirname, "cpp", "perspective"); const { compress } = await import("pro_self_extracting_wasm"); @@ -69,7 +74,7 @@ try { `cmake`, cmake_dir, ...cmake_flags, - `-DCMAKE_BUILD_TYPE=${env}`, + `-DCMAKE_BUILD_TYPE=${env === "debug" ? "debug" : "release"}`, `-DRAPIDJSON_BUILD_EXAMPLES=OFF`, `-DPSP_PROTO_PATH=${pp.dir}`, `-DCMAKE_COLOR_DIAGNOSTICS=ON`, diff --git a/rust/perspective-server/cpp/perspective/CMakeLists.txt b/rust/perspective-server/cpp/perspective/CMakeLists.txt index 0a996c327b..4a361d0757 100644 --- a/rust/perspective-server/cpp/perspective/CMakeLists.txt +++ b/rust/perspective-server/cpp/perspective/CMakeLists.txt @@ -111,6 +111,20 @@ else() endif() endif() +if(DEFINED ENV{PSP_FAST} AND NOT DEFINED ENV{PSP_DEBUG}) + set(PSP_FAST_BUILD ON) + set(PSP_OPT_LEVEL "-O1") + set(PSP_LTO_FLAGS "") + if(NOT WIN32) + set(CMAKE_C_FLAGS_RELEASE "-O1 -DNDEBUG") + set(CMAKE_CXX_FLAGS_RELEASE "-O1 -DNDEBUG") + endif() +else() + set(PSP_FAST_BUILD OFF) + set(PSP_OPT_LEVEL "-O3") + set(PSP_LTO_FLAGS "-flto") +endif() + if(DEFINED ENV{PSP_HEAP_INSTRUMENTS}) set(PSP_HEAP_INSTRUMENTS ON) else() @@ -196,6 +210,8 @@ if(CMAKE_BUILD_TYPE_LOWER STREQUAL debug) add_definitions(-DPSP_STORAGE_VERIFY) add_definitions(-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST) add_definitions(-D_GLIBCXX_ASSERTIONS) +elseif(PSP_FAST_BUILD) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Yellow}Building FAST${ColorReset}") else() set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building RELEASE${ColorReset}") endif() @@ -266,12 +282,12 @@ if(PSP_WASM_BUILD) endif() else() set(OPT_FLAGS " \ - -O3 -g${DEBUG_LEVEL} \ + ${PSP_OPT_LEVEL} -g${DEBUG_LEVEL} \ -mbulk-memory \ -msimd128 \ -mrelaxed-simd \ -fopenmp-simd \ - -flto \ + ${PSP_LTO_FLAGS} \ --emit-tsd=perspective-server.d.ts \ ") if (PSP_WASM_EXCEPTIONS) @@ -315,7 +331,7 @@ elseif(PSP_CPP_BUILD OR PSP_PYTHON_BUILD) endif () else() set(OPT_FLAGS " \ - -O3 \ + ${PSP_OPT_LEVEL} \ -fexceptions \ -g1 \ -fopenmp-simd \ @@ -343,17 +359,17 @@ endif() if (PSP_WASM_EXCEPTIONS) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} \ - -O3 \ + ${PSP_OPT_LEVEL} \ -g${DEBUG_LEVEL} \ ") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ -fwasm-exceptions \ - -O3 \ + ${PSP_OPT_LEVEL} \ -g${DEBUG_LEVEL} \ ") else() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ - -O3 \ + ${PSP_OPT_LEVEL} \ ") endif() @@ -380,7 +396,7 @@ if(PSP_PYODIDE) endif() if(PSP_WASM_BUILD AND NOT CMAKE_BUILD_TYPE_LOWER STREQUAL debug) - set(PSP_DEP_SIMD_FLAGS " -flto -mbulk-memory -msimd128 -mrelaxed-simd ") + set(PSP_DEP_SIMD_FLAGS " ${PSP_LTO_FLAGS} -mbulk-memory -msimd128 -mrelaxed-simd ") string(APPEND CMAKE_C_FLAGS "${PSP_DEP_SIMD_FLAGS}") string(APPEND CMAKE_CXX_FLAGS "${PSP_DEP_SIMD_FLAGS}") endif() diff --git a/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp b/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp index 441f4db788..0d9fba37cf 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp @@ -111,15 +111,21 @@ t_expression_tables::calculate_transitions( int(num_cols), [&column_names, &existed_column, this](int cidx) { const std::string& cname = column_names[cidx]; - const t_column& prev_column = *(m_prev->get_const_column(cname)); + t_column& prev_column = *(m_prev->get_column(cname)); const t_column& current_column = *(m_current->get_const_column(cname)); std::shared_ptr transition_column = m_transitions->get_column(cname); for (t_uindex ridx = 0; ridx < transition_column->size(); ++ridx) { - bool row_existed = - existed_column.get_nth(ridx) != nullptr; + bool row_existed = *(existed_column.get_nth(ridx)); + if (!row_existed) { + prev_column.clear(ridx); + transition_column->set_nth( + ridx, VALUE_TRANSITION_NEQ_FT + ); + continue; + } t_tscalar prev_value = prev_column.get_scalar(ridx); t_tscalar curr_value = current_column.get_scalar(ridx); @@ -130,29 +136,14 @@ t_expression_tables::calculate_transitions( prev_valid && curr_valid && (prev_value == curr_value); t_value_transition transition; - - // Use a small subset of `t_value_transitions` that are - // relevant - I have not implemented the code paths in - // `calc_transitions` that are not referenced elsewhere, i.e. - // by a context or by a tree implementation. - if (row_existed) { - if (prev_curr_eq) { - // Row existed before, and the current value is - // the same as the previous value. - transition = VALUE_TRANSITION_EQ_TT; - } else if (!prev_valid && curr_valid) { - // Previous value was a null, new value is valid. - transition = VALUE_TRANSITION_NEQ_FT; - } else if (prev_valid && !curr_valid) { - transition = VALUE_TRANSITION_NEQ_TF; - } else { - // Previous value was not null, new value is - // not null, and previous value != new value - transition = VALUE_TRANSITION_NEQ_TT; - } - } else { - // Row did not exist before and was added + if (prev_curr_eq) { + transition = VALUE_TRANSITION_EQ_TT; + } else if (!prev_valid && curr_valid) { transition = VALUE_TRANSITION_NEQ_FT; + } else if (prev_valid && !curr_valid) { + transition = VALUE_TRANSITION_NEQ_TF; + } else { + transition = VALUE_TRANSITION_NEQ_TT; } transition_column->set_nth(ridx, transition); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index 90d2038c56..8d1e2cb8e2 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -1842,6 +1842,7 @@ ProtoServer::build_view_config( {"diff", t_window_op::WINDOW_OP_DIFF}, {"rate", t_window_op::WINDOW_OP_RATE}, {"ema", t_window_op::WINDOW_OP_EMA}, + {"row_number", t_window_op::WINDOW_OP_ROW_NUMBER}, }; const auto op_entry = WINDOW_OPS.find(w.op()); @@ -1956,6 +1957,17 @@ ProtoServer::build_view_config( ); } break; + case t_window_op::WINDOW_OP_ROW_NUMBER: + if (has_frame + && frame_type + != t_window_frame_type:: + WINDOW_FRAME_CUMULATIVE) { + PSP_COMPLAIN_AND_ABORT( + "Window `row_number` is positional; it does " + "not accept a `rows` or `range` frame" + ); + } + break; default: break; } @@ -2278,6 +2290,9 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { // `ema` is recursive - a smoothing factor, never a frame. *numeric_aggs.add_options() = window_agg("ema", no_frames, false, true, proto::ColumnType::FLOAT); + *numeric_aggs.add_options() = window_agg( + "row_number", no_frames, false, false, proto::ColumnType::INTEGER + ); proto::GetFeaturesResp_WindowAggregateOptions any_aggs; *any_aggs.add_options() = @@ -2290,6 +2305,9 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { window_agg("lag", no_frames, true, false, std::nullopt); *any_aggs.add_options() = window_agg("lead", no_frames, true, false, std::nullopt); + *any_aggs.add_options() = window_agg( + "row_number", no_frames, false, false, proto::ColumnType::INTEGER + ); auto& window_aggs = *features->mutable_window_aggregates(); window_aggs[proto::ColumnType::INTEGER] = numeric_aggs; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/window.cpp b/rust/perspective-server/cpp/perspective/src/cpp/window.cpp index 80ff5ccdb3..22ae6341af 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/window.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/window.cpp @@ -588,6 +588,7 @@ t_window_engine::resolve_dtype(t_window_op op, t_dtype source_dtype) { case t_window_op::WINDOW_OP_DIFF: return numeric ? DTYPE_FLOAT64 : DTYPE_NONE; case t_window_op::WINDOW_OP_COUNT: + case t_window_op::WINDOW_OP_ROW_NUMBER: return DTYPE_INT64; case t_window_op::WINDOW_OP_MIN: case t_window_op::WINDOW_OP_MAX: @@ -617,6 +618,7 @@ t_window_engine::is_implemented(t_window_op op, t_window_frame_type frame) { case t_window_op::WINDOW_OP_LEAD: case t_window_op::WINDOW_OP_DIFF: case t_window_op::WINDOW_OP_EMA: + case t_window_op::WINDOW_OP_ROW_NUMBER: // Frame-independent; frame legality is enforced at `View` // construction. return true; @@ -632,6 +634,7 @@ t_window_class t_window_engine::class_of(const t_window_spec& spec) { switch (spec.m_op) { case t_window_op::WINDOW_OP_EMA: + case t_window_op::WINDOW_OP_ROW_NUMBER: return t_window_class::WINDOW_CLASS_CUMULATIVE; case t_window_op::WINDOW_OP_LAG: case t_window_op::WINDOW_OP_DIFF: @@ -877,6 +880,17 @@ t_window_engine::recompute_range( } } break; case t_window_class::WINDOW_CLASS_CUMULATIVE: { + if (spec.m_op == t_window_op::WINDOW_OP_ROW_NUMBER) { + for (std::size_t pos = lo; pos < rows.size(); ++pos) { + write_int( + out, + rows[pos].m_ridx, + static_cast(pos) + 1 + ); + } + break; + } + if (state.m_needs_prefix) { partition.m_prefix_sum.resize(rows.size()); partition.m_prefix_sumsq.resize(rows.size()); diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h index 98c3a6a1f1..e6ad74472f 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h @@ -51,7 +51,8 @@ struct t_expression_tables { void clear_transitional_tables() const; - // Calculate the `t_transitions` value for each row. + /// Calculates the `t_transitions` value for each row and clears the + /// `m_prev` cells of rows that did not exist before this update. void calculate_transitions(const std::shared_ptr& existed); void set_flattened(const std::shared_ptr& flattened) const; diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/window.h b/rust/perspective-server/cpp/perspective/src/include/perspective/window.h index fb26a4f922..ef92930b6f 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/window.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/window.h @@ -37,7 +37,8 @@ enum class t_window_op : std::uint8_t { WINDOW_OP_LEAD, WINDOW_OP_DIFF, WINDOW_OP_RATE, - WINDOW_OP_EMA + WINDOW_OP_EMA, + WINDOW_OP_ROW_NUMBER }; enum class t_window_frame_type : std::uint8_t { diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index aa01a9f371..9c4a83a4fd 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -32,6 +32,8 @@ import { const IS_DEBUG = !!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0; +const IS_FAST = !IS_DEBUG && !!process.env.PSP_FAST; + const INHERIT = { stdio: "inherit", stderr: "inherit", @@ -39,8 +41,9 @@ const INHERIT = { export async function build_all() { if (!process.env.PSP_SKIP_WASM) { + const release_flag = IS_DEBUG ? "" : IS_FAST ? "--fast" : "--release"; execSync( - `cargo bundle --target=${get_host()} -- perspective_viewer ${IS_DEBUG ? "" : "--release"}`, + `cargo bundle --target=${get_host()} -- perspective_viewer ${release_flag}`, { ...INHERIT, env: { ...process.env, PSP_ROOT_DIR: "../.." }, diff --git a/rust/perspective-viewer/src/css/column-selector.css b/rust/perspective-viewer/src/css/column-selector.css index 4f58113eea..cc4a1f2090 100644 --- a/rust/perspective-viewer/src/css/column-selector.css +++ b/rust/perspective-viewer/src/css/column-selector.css @@ -572,3 +572,156 @@ } } } + +:host { + #derived_stack { + display: flex; + flex-direction: column; + margin-bottom: 4px; + gap: 4px; + } + + .derived_level { + display: flex; + align-items: center; + /* height: 18px; */ + width: 100%; + /* padding: 0px 6px; */ + color: var(--psp-inactive--color, #ababab); + /* font-size: var(--label--font-size, 0.75em); */ + } + + #derived_stack:before { + content: "Subqueries"; + font-size: var(--label--font-size, 0.75em); + } + + .derived_icon { + background-repeat: no-repeat; + background-color: var(--psp-inactive--color); + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + flex-shrink: 0; + line-height: 0; + -webkit-mask-image: var(--psp-derived--mask-image); + mask-image: var(--psp-derived--mask-image); + &:before { + content: var(--psp-derived--mask-image); + visibility: hidden; + } + } + + .is-own .derived_icon { + background-color: var(--psp--color); + } + + .derived_level:hover .derived_label_wrapper { + border-color: var(--psp--color); + color: var(--psp--color); + } + + .derived_level:hover .derived_icon:before { + background-color: var(--psp--color); + } + + .derived_label_wrapper { + height: 24px; + border: 1px solid var(--psp-inactive--color); + border-radius: 2px; + flex: 1 1 0; + min-width: 0; + font-size: 12px; + display: flex; + align-items: center; + overflow: hidden; + padding-left: 7px; + padding-right: 4px; + gap: 6px; + } + + .derived_label { + flex: 1 1 0; + width: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .derived_consumers { + opacity: 0.6; + } + + .derived_consumers::before { + content: "x"; + } + + .derived_snapshot::before { + content: var( + --psp-label--derived-snapshot--content, + var(--psp-missing) + ); + opacity: 0.6; + } + + .derived_release { + cursor: pointer; + -webkit-mask-image: var(--psp-icon--close--mask-image); + mask-image: var(--psp-icon--close--mask-image); + mask-size: cover; + background-color: currentColor; + width: 14px; + height: 14px; + flex: 0 0 14px; + margin: 0 6.5px; + } + + .derived_release.disabled { + cursor: inherit; + pointer-events: none; + background-color: var(--psp-inactive--color); + } + + .derived_release:hover { + color: var(--psp--color, inherit); + } + + #derived_stack { + flex: 0 0 auto; + margin-top: auto; + margin-right: 0px; + padding-top: 12px; + bottom: 0px; + } + + .derived_level.is-own { + color: var(--psp--color, inherit); + } + + .derived_create:before { + content: var(--psp-label--derive-table--content, var(--psp-missing)); + } + + .derived_create { + cursor: pointer; + border-radius: 3px; + border: 1px solid var(--psp-inactive--color); + display: flex; + align-items: center; + height: 24px; + line-height: 18px; + flex-direction: row; + justify-content: center; + font-size: var(--label--font-size, 0.75em); + color: var(--psp-inactive--color); + text-transform: uppercase; + /* margin-top: 12px; */ + } + + .derived_create:hover { + color: var(--psp--background-color); + background-color: var(--psp--color); + border-color: var(--psp--color); + } +} diff --git a/rust/perspective-viewer/src/css/config-selector.css b/rust/perspective-viewer/src/css/config-selector.css index 473ce87a43..bc8aad659f 100644 --- a/rust/perspective-viewer/src/css/config-selector.css +++ b/rust/perspective-viewer/src/css/config-selector.css @@ -350,61 +350,6 @@ display: inline-block; } - #derived_stack { - display: flex; - flex-direction: column; - margin-bottom: 12px; - } - - .derived_level { - display: flex; - align-items: center; - gap: 4px; - height: 24px; - padding: 0px 6px; - border-left: 2px solid var(--psp-inactive--color, #ababab); - color: var(--psp-inactive--color, #ababab); - font-size: var(--label--font-size, 0.75em); - } - - .derived_label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .derived_snapshot::before { - content: "snapshot"; - opacity: 0.6; - } - - .derived_pop { - margin-left: auto; - cursor: pointer; - -webkit-mask-image: var(--psp-icon--close--mask-image); - mask-image: var(--psp-icon--close--mask-image); - background-color: currentColor; - width: 12px; - height: 12px; - } - - .derived_pop:hover { - color: var(--psp--color, inherit); - } - - .derived_push { - cursor: pointer; - height: 24px; - line-height: 24px; - padding: 0px 6px; - font-size: var(--label--font-size, 0.75em); - color: var(--psp-inactive--color, #ababab); - } - - .derived_push:hover { - color: var(--psp--color, inherit); - } - .pivot_controls { display: flex; justify-content: flex-end; diff --git a/rust/perspective-viewer/src/css/ui/containers/dropdown-menu-items.css b/rust/perspective-viewer/src/css/ui/containers/dropdown-menu-items.css index 8f433dd9c1..4fb857bd63 100644 --- a/rust/perspective-viewer/src/css/ui/containers/dropdown-menu-items.css +++ b/rust/perspective-viewer/src/css/ui/containers/dropdown-menu-items.css @@ -59,6 +59,35 @@ } } +.dropdown-menu-item.is-derived { + display: flex; + align-items: center; + gap: 4px; +} + +.dropdown-menu-item.is-derived::before { + content: "\21B3"; + opacity: 0.6; +} + +.dropdown-menu-item .derived_consumers { + opacity: 0.6; +} + +.dropdown-menu-item .derived_consumers::before { + content: "x"; +} + +.dropdown-menu-item .derived_delete { + margin-left: auto; + -webkit-mask-image: var(--psp-icon--close--mask-image); + mask-image: var(--psp-icon--close--mask-image); + mask-size: cover; + background-color: currentColor; + width: 12px; + height: 12px; +} + .no-results { font-style: italic; padding: 6px 24px; diff --git a/rust/perspective-viewer/src/rust/agent/tools.rs b/rust/perspective-viewer/src/rust/agent/tools.rs index 7f1adbce9f..090845ca20 100644 --- a/rust/perspective-viewer/src/rust/agent/tools.rs +++ b/rust/perspective-viewer/src/rust/agent/tools.rs @@ -482,7 +482,7 @@ pub struct ToolError(String); /// applied config. fn retyped_columns( session: &crate::session::Session, - config: &crate::config::ViewerConfig, + config: &crate::config::ViewerConfig, ) -> Vec { let metadata = session.metadata(); config diff --git a/rust/perspective-viewer/src/rust/components/column_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector.rs index 54fdffe82c..8797629a1d 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector.rs @@ -15,6 +15,7 @@ mod add_expression_button; mod aggregate_selector; mod column_selector_column_row; mod config_selector; +mod derived_control; mod empty_column; mod expr_edit_button; mod filter_column; @@ -37,6 +38,7 @@ use yew::prelude::*; use self::active_column::*; use self::add_expression_button::AddExpressionButton; use self::config_selector::ConfigSelector; +use self::derived_control::DerivedControl; use self::inactive_column::*; use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal}; use crate::config::PluginStaticConfig; @@ -50,6 +52,7 @@ use crate::session::*; use crate::tasks::apply_and_render; use crate::ui::{Orientation, ScrollPanel, ScrollPanelItem, SplitPanel}; use crate::utils::*; +use crate::workspace::Workspace; #[derive(Properties)] pub struct ColumnSelectorProps { @@ -83,6 +86,7 @@ pub struct ColumnSelectorProps { // State pub session: Session, pub renderer: Renderer, + pub workspace: Workspace, pub presentation: Presentation, /// Fires when this component is resized via the UI. @@ -391,6 +395,20 @@ impl Component for ColumnSelector { .unwrap_or_default(), ); + let derived_control = html_nested! { + + + + }; + let config_selector = html_nested! { >()} + children={std::iter::once(derived_control).chain(std::iter::once(config_selector)).chain(active_columns).collect::>()} /> }]; diff --git a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs index 91ba4f3648..e1ebfcb398 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs @@ -328,7 +328,7 @@ impl Component for ActiveColumn { let remove_column = if is_required { None } else { - Some(ctx.link().callback({ + Some(left_click(ctx.link().callback({ let event_name = name.to_owned(); move |event: MouseEvent| { ActiveColumnMsg::DeactivateColumn( @@ -336,7 +336,7 @@ impl Component for ActiveColumn { event.shift_key(), ) } - })) + }))) }; let ondragend = &ctx.props().ondragend.reform(|_| ()); diff --git a/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs b/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs index a5bcf056c5..96725f241f 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs @@ -13,6 +13,7 @@ use yew::prelude::*; use super::{ColumnLocator, ColumnSettingsTarget}; +use crate::utils::left_click; #[derive(Clone, PartialEq, Properties)] pub struct AddExpressionButtonProps { @@ -45,9 +46,10 @@ pub fn AddExpressionButton(p: &AddExpressionButtonProps) -> Html { mo.set(false); }); - let onmousedown = p - .on_open_expr_panel - .reform(|_| ColumnSettingsTarget::NewExpression); + let onmousedown = left_click( + p.on_open_expr_panel + .reform(|_| ColumnSettingsTarget::NewExpression), + ); let class = if *is_mouseover || matches!(p.selected_column, Some(ColumnLocator::NewExpression)) { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs index 0bb05a1171..8c8349d13d 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs @@ -600,7 +600,7 @@ impl Component for ConfigSelector { _ => None, }; - let transpose = ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots); + let transpose = left_click(ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots)); let column_dropdown = self.column_dropdown.clone(); let mut class = classes!(); diff --git a/rust/perspective-viewer/src/rust/components/column_selector/derived_control.rs b/rust/perspective-viewer/src/rust/components/column_selector/derived_control.rs new file mode 100644 index 0000000000..3d61d090cd --- /dev/null +++ b/rust/perspective-viewer/src/rust/components/column_selector/derived_control.rs @@ -0,0 +1,212 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use perspective_client::config::ViewConfig; +use perspective_js::utils::ApiFuture; +use yew::prelude::*; + +// use crate::derived::suggest_name; +use crate::presentation::Presentation; +// use crate::queries::fetch_hosted_tables; +use crate::renderer::Renderer; +use crate::session::{Session, SessionMetadataRc}; +use crate::tasks::release_derived; +// use crate::tasks::{can_derive, create_derived, release_derived}; +use crate::utils::{PtrEqRc, left_click, read_intl_string}; +use crate::workspace::Workspace; + +#[derive(Clone, Properties)] +pub struct DerivedControlProps { + pub session: Session, + pub renderer: Renderer, + pub presentation: Presentation, + pub workspace: Workspace, + pub metadata: SessionMetadataRc, + pub view_config: PtrEqRc, + + /// The `Table` this panel is bound to, threaded as a value prop so the + /// chain re-renders when the binding changes. + pub table: Option, +} + +impl PartialEq for DerivedControlProps { + fn eq(&self, other: &Self) -> bool { + self.table == other.table + && self.metadata == other.metadata + && self.view_config == other.view_config + } +} + +pub enum DerivedControlMsg { + // Create, + Release(bool), +} + +#[derive(Default)] +pub struct DerivedControl {} + +impl Component for DerivedControl { + type Message = DerivedControlMsg; + type Properties = DerivedControlProps; + + fn create(_ctx: &Context) -> Self { + Self::default() + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + // DerivedControlMsg::Create => { + // let props = ctx.props().clone(); + // ApiFuture::spawn(async move { + // let name = suggest_name( + // &fetch_hosted_tables(&props.workspace) + // .await + // .into_iter() + // .flat_map(|(_, tables)| tables) + // .collect(), + // &props.view_config, + // &props.table, + // ); + + // create_derived( + // &props.session, + // &props.renderer, + // &props.presentation, + // &props.workspace, + // name, + // ) + // .await + // }); + + // false + // }, + DerivedControlMsg::Release(delete) => { + let props = ctx.props().clone(); + ApiFuture::spawn(async move { + release_derived( + &props.session, + &props.renderer, + &props.presentation, + &props.workspace, + delete, + ) + .await + }); + + false + }, + } + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let workspace = &props.workspace; + let bound = props.table.clone().unwrap_or_default(); + let is_derived = workspace.is_derived(&bound); + // let is_pivoted = + // !props.view_config.group_by.is_empty() || + // !props.view_config.split_by.is_empty(); + + // let _can_create = is_pivoted && can_derive(&props.session); + // if !is_derived && !can_create { + // return html! {}; + // } + + if !is_derived { + return html! {}; + } + + let is_snapshot = !props.metadata.get_features().is_some_and(|x| x.on_update); + let mut chain = workspace.derived_ancestors(&bound); + chain.reverse(); + if is_derived { + chain.push(bound.clone()); + chain.remove(0); + } + + let sole_consumer = workspace.derived_consumers(&bound) == 1; + let release = left_click( + ctx.link() + .callback(|event: MouseEvent| DerivedControlMsg::Release(event.shift_key())), + ); + + // let create = ctx.link().callback(|_| DerivedControlMsg::Create); + // let last = chain.len().saturating_sub(1); + let viewer = props.presentation.viewer_elem(); + let intl = |slug: &str, fallback: &str| { + read_intl_string(viewer, slug).unwrap_or_else(|| fallback.to_owned()) + }; + + // let _create_title = intl("derive-table-title", "Derive a new table from this + // view"); + let snapshot_title = intl( + "derived-snapshot-title", + "This data source does not stream - this table's data is fixed at the point it was \ + derived.", + ); + + let release_title = if sole_consumer { + intl( + "derived-release-delete-title", + "Back to the parent table - shift-click to delete this one", + ) + } else { + intl("derived-release-title", "Back to the parent table") + }; + + let last = chain.len().saturating_sub(1); + let derived_tables = chain.iter().enumerate().map(|(idx, name)| { + let is_own = is_derived && idx == last; + // let consumers = workspace.derived_consumers(name); + let mut close_class = classes!("derived_release"); + if sole_consumer { + close_class.push("shift-alt-icon"); + } + + let mut level_class = classes!("derived_level"); + if is_own { + level_class.push("is-own"); + } + + html! { +
+
+ + { name } +
+ // if consumers > 1 { { consumers } } + if is_snapshot { + + } + if is_own { + + } else { + + } +
+ } + }); + + html! { +
+ // if can_create { + //
+ // } + { for derived_tables } +
+ } + } +} diff --git a/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs b/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs index a15c818238..6ba7e79fed 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs @@ -13,6 +13,7 @@ use yew::prelude::*; use super::ColumnSettingsTarget; +use crate::utils::left_click; #[derive(PartialEq, Clone, Properties)] pub struct ExprEditButtonProps { @@ -41,10 +42,10 @@ pub struct ExprEditButtonProps { /// when pressed, it opens up the expression editor side-panel. #[function_component] pub fn ExprEditButton(p: &ExprEditButtonProps) -> Html { - let onmousedown = yew::use_callback(p.clone(), |_, p| { + let onmousedown = left_click(yew::use_callback(p.clone(), |_, p| { p.on_open_expr_panel .emit(ColumnSettingsTarget::Column(p.name.clone())) - }); + })); let class = if p.is_disabled { "expression-edit-button disabled" diff --git a/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs index 9f30e8aa0b..d443f096a4 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs @@ -132,9 +132,10 @@ impl Component for InactiveColumn { .get_column_table_type(&ctx.props().name) .unwrap_or(ColumnType::String); - let add_column = ctx - .link() - .callback(|event: MouseEvent| InactiveColumnMsg::ActivateColumn(event.shift_key())); + let add_column = + left_click(ctx.link().callback(|event: MouseEvent| { + InactiveColumnMsg::ActivateColumn(event.shift_key()) + })); let ondragend = ctx.props().ondragend.reform(|_| {}); let ondragstart = ctx.link().callback({ diff --git a/rust/perspective-viewer/src/rust/components/column_selector/sort_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/sort_column.rs index bf74abfe48..883f21d2a8 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/sort_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/sort_column.rs @@ -96,9 +96,10 @@ impl Component for SortColumn { } fn view(&self, ctx: &Context) -> Html { - let onclick = ctx - .link() - .callback(|event: MouseEvent| SortColumnMsg::SortDirClick(event.shift_key())); + let onclick = left_click( + ctx.link() + .callback(|event: MouseEvent| SortColumnMsg::SortDirClick(event.shift_key())), + ); let dragstart = Callback::from({ let event_name = ctx.props().sort.0.to_owned(); diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs index ee0038d912..95a86ca20a 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs @@ -219,7 +219,9 @@ impl Component for ColumnSettingsPanel { fn changed(&mut self, ctx: &yew::prelude::Context, old_props: &Self::Properties) -> bool { let next = Initials::of(ctx.props()); - if ctx.props().selected_column != old_props.selected_column || next != self.initials() { + if ctx.props().selected_column != old_props.selected_column + || next != Initials::of(old_props) + { self.reset_to(ctx, next); } else { self.refresh_derived(ctx); @@ -542,15 +544,6 @@ impl ColumnSettingsPanel { self.save_enabled = changed && valid; } - fn initials(&self) -> Initials { - Initials { - column_name: self.column_name.clone(), - expr: self.initial_expr_value.clone(), - header: self.initial_header_value.clone(), - window: self.initial_window_value.clone(), - } - } - fn refresh_derived(&mut self, ctx: &yew::prelude::Context) { let view_ty = ctx .props() diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/save_settings.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/save_settings.rs index ac767a08a3..185249e946 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/save_settings.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/save_settings.rs @@ -12,6 +12,8 @@ use yew::{Callback, Html, Properties, function_component, html}; +use crate::utils::left_click; + #[derive(Properties, PartialEq, Clone)] pub struct SaveSettingsProps { pub save_enabled: bool, @@ -26,9 +28,9 @@ pub struct SaveSettingsProps { #[function_component(SaveSettings)] pub fn save_settings(props: &SaveSettingsProps) -> Html { - let reset = props.on_reset.reform(|_| ()); - let save = props.on_save.reform(|_| ()); - let delete = props.on_delete.reform(|_| ()); + let reset = left_click(props.on_reset.reform(|_| ())); + let save = left_click(props.on_save.reform(|_| ())); + let delete = left_click(props.on_delete.reform(|_| ())); html! {
if props.show_danger_zone { diff --git a/rust/perspective-viewer/src/rust/components/debug_panel.rs b/rust/perspective-viewer/src/rust/components/debug_panel.rs index e56591aabc..03e3fb7ee4 100644 --- a/rust/perspective-viewer/src/rust/components/debug_panel.rs +++ b/rust/perspective-viewer/src/rust/components/debug_panel.rs @@ -232,7 +232,8 @@ impl DebugPanelProps { &props.renderer, &props.presentation, ) - .await?; + .await? + .map_table(|name| props.workspace.inline_table_ref(&name)); let json = JsValue::from_serde_ext(&config)?; let js_string = js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?; diff --git a/rust/perspective-viewer/src/rust/components/dragdrop_list.rs b/rust/perspective-viewer/src/rust/components/dragdrop_list.rs index 92ff74c8a5..b9060a8b90 100644 --- a/rust/perspective-viewer/src/rust/components/dragdrop_list.rs +++ b/rust/perspective-viewer/src/rust/components/dragdrop_list.rs @@ -24,7 +24,7 @@ use crate::components::column_selector::{EmptyColumn, InPlaceColumn, InvalidColu use crate::components::type_icon::TypeIcon; use crate::presentation::{DragDropContainer, Presentation}; use crate::ui::intl_slug; -use crate::utils::DragTarget; +use crate::utils::{DragTarget, left_click}; #[derive(Properties, Derivative)] #[derivative(Clone(bound = ""))] @@ -244,7 +244,7 @@ where .map(|x| V::is_self_move(x)) .unwrap_or_default(); - let close = ctx.props().parent.callback(|_| V::close(0)); + let close = left_click(ctx.props().parent.callback(|_| V::close(0))); let dragenter = ctx.props().parent.callback({ let container_noderef = container_noderef.clone(); move |event: DragEvent| { @@ -332,7 +332,7 @@ where .into_iter() .enumerate() .map(|(idx, column)| { - let close = ctx.props().parent.callback(move |_| V::close(idx)); + let close = left_click(ctx.props().parent.callback(move |_| V::close(idx))); let dragenter = ctx.props().parent.callback({ let link = ctx.link().clone(); let container_noderef = container_noderef.clone(); @@ -441,7 +441,7 @@ where
} else if show_empty { diff --git a/rust/perspective-viewer/src/rust/components/main_panel.rs b/rust/perspective-viewer/src/rust/components/main_panel.rs index e2cbb97a78..80f82c286e 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel.rs @@ -185,14 +185,15 @@ pub struct MainPanel { /// and attached alongside the others. _layout_before_resize_listener: Closure, - /// `contextmenu` listener on the panel *container* — one stable attach - /// point covering the whole stage, independent of the layout reconcile; - /// at zero panels it opens the stage menu. Kept alive here and attached - /// once in `rendered`. Imperative — not a Yew `oncontextmenu` — because - /// the plugin body is light-DOM attached by the renderer (its DOM parent - /// is the host, not the frame), so Yew's delegated handler never matches - /// a right-click there. A native listener catches it via composed - /// bubbling and resolves the panel from the path. + /// `contextmenu` listener on the viewer HOST element — one stable attach + /// point covering every surface (panels, status bar, sidebars), + /// independent of the layout reconcile; attached once in `rendered` and + /// REMOVED in `destroy`, since the host outlives this component's Yew + /// app. Imperative — not a Yew `oncontextmenu` — because the plugin body + /// is light-DOM attached by the renderer (its DOM parent is the host, + /// not the frame), so Yew's delegated handler never matches a + /// right-click there; a native listener sees every surface — shadow + /// chrome and slotted light DOM alike — on the composed path. _contextmenu_listener: Closure, /// The `` ELEMENT the layout listeners are attached to. @@ -226,9 +227,9 @@ pub struct MainPanel { hidden_tabs: HashSet, /// Open context menu as `(client x, client y, target panel id)`; outer - /// `None` when closed. A `None` *panel id* is the empty-stage menu (zero - /// panels — "New" only). Rendered as a cursor-anchored - /// [`PanelMenu`](super::panel_menu::PanelMenu) overlay. + /// `None` when closed. A `None` *panel id* is the target-less menu (no + /// active panel to resolve to — "New" only). Rendered as a + /// cursor-anchored [`PanelMenu`](super::panel_menu::PanelMenu) overlay. context_menu: Option<(f64, f64, Option)>, /// Id of the currently maximized panel (via `regular-layout.maximize`), or @@ -288,19 +289,20 @@ impl Component for MainPanel { before_resize_cb.emit(event); }) as Box); - // Imperative `contextmenu` listener: a right-click anywhere in a panel - // (the plugin body included) opens the panel menu. The plugin body is - // light-DOM attached by the renderer, so a Yew `oncontextmenu` on the - // frame never matches it (Yew walks the vdom, not the composed path). - // This native listener resolves the panel from the - // `` on the event's composed path, then - // suppresses the browser menu and emits. On the EMPTY stage (zero - // panels — the persistent `` has no frame - // descendants), it opens the stage menu instead, whose "New" items - // create the first panel. + // Imperative `contextmenu` listener: a right-click anywhere in the + // element opens the panel menu — targeting the frame on the event's + // composed path, or `None` (resolved to the ACTIVE panel, else the + // stage menu, in `on_context_menu`, where props live) from any + // other surface. Two pass-throughs keep the NATIVE menu: editable + // targets (inputs, textareas, contenteditable — cut/copy/paste), + // and anything inside an open `PortalModal` (every modal host is a + // light-DOM viewer child in the modal slot — this menu included, so + // without the carve-out a right-click in any open dropdown would + // re-open the menu underneath it). let contextmenu_cb = ctx .link() .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y)); + let viewer_elem = ctx.props().presentation.viewer_elem().clone(); let contextmenu_listener = Closure::wrap(Box::new(move |event: web_sys::Event| { // Shift+right-click passes through to the native browser menu. if event.unchecked_ref::().shift_key() { @@ -308,11 +310,34 @@ impl Component for MainPanel { } let path = event.composed_path(); + let is_editable = path.get(0).dyn_ref::().is_some_and( + |el: &web_sys::HtmlElement| { + el.tag_name().eq_ignore_ascii_case("input") + || el.tag_name().eq_ignore_ascii_case("textarea") + || el.is_content_editable() + }, + ); + + if is_editable { + return; + } + let mut panel_id = None; for i in 0..path.length() { let node = path.get(i); - if let Some(el) = node.dyn_ref::() - && el.tag_name().eq_ignore_ascii_case("regular-layout-frame") + let Some(el) = node.dyn_ref::() else { + continue; + }; + + if el == viewer_elem.unchecked_ref::() { + break; + } + + if el.slot() == crate::ui::MODAL_SLOT { + return; + } + + if el.tag_name().eq_ignore_ascii_case("regular-layout-frame") && let Some(name) = el.get_attribute("name") { panel_id = Some(name); @@ -320,29 +345,9 @@ impl Component for MainPanel { } } - let is_empty_stage = || { - event - .current_target() - .and_then(|t| t.dyn_into::().ok()) - .is_some_and(|el| { - el.query_selector("regular-layout-frame") - .ok() - .flatten() - .is_none() - }) - }; - - if let Some(id) = panel_id { - event.prevent_default(); - let mouse = event.unchecked_ref::(); - contextmenu_cb.emit((Some(id), mouse.client_x() as f64, mouse.client_y() as f64)); - } else if is_empty_stage() { - event.prevent_default(); - let mouse = event.unchecked_ref::(); - contextmenu_cb.emit((None, mouse.client_x() as f64, mouse.client_y() as f64)); - } - // With panels present, a click outside every frame lets the - // native menu through. + event.prevent_default(); + let mouse = event.unchecked_ref::(); + contextmenu_cb.emit((panel_id, mouse.client_x() as f64, mouse.client_y() as f64)); }) as Box); let staged_sub = { @@ -404,16 +409,20 @@ impl Component for MainPanel { } fn rendered(&mut self, ctx: &Context, first_render: bool) { - // The `contextmenu` listener attaches to the panel CONTAINER — one - // stable attach point covering the whole stage, decoupled from the - // layout reconcile. Panel right-clicks bubble to it through the - // layout on the composed path; at zero panels it serves the stage - // menu. - if first_render && let Some(el) = self.main_panel_ref.cast::() { - let _ = el.add_event_listener_with_callback( - "contextmenu", - self._contextmenu_listener.as_ref().unchecked_ref(), - ); + // The `contextmenu` listener attaches to the viewer HOST element — + // one stable attach point covering every surface (panels, status + // bar, sidebars), decoupled from the layout reconcile. Panel + // right-clicks reach it through their frames on the composed path; + // everything else resolves to the active panel (or the stage menu). + if first_render { + let _ = ctx + .props() + .presentation + .viewer_elem() + .add_event_listener_with_callback( + "contextmenu", + self._contextmenu_listener.as_ref().unchecked_ref(), + ); } self.size_staging_wrappers(); @@ -425,7 +434,19 @@ impl Component for MainPanel { self.render(ctx) } - fn destroy(&mut self, _ctx: &Context) {} + fn destroy(&mut self, ctx: &Context) { + // The host element outlives this component (`delete()` tears down + // the Yew app, not the custom element), so the listener must not + // dangle into a dropped `Closure`. + let _ = ctx + .props() + .presentation + .viewer_elem() + .remove_event_listener_with_callback( + "contextmenu", + self._contextmenu_listener.as_ref().unchecked_ref(), + ); + } } impl MainPanel { diff --git a/rust/perspective-viewer/src/rust/components/main_panel/msg.rs b/rust/perspective-viewer/src/rust/components/main_panel/msg.rs index 1a3f8e804c..26d5251344 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/msg.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/msg.rs @@ -49,12 +49,14 @@ pub enum MainPanelMsg { BeforeResize(web_sys::Event), /// A right-click opened the panel context menu (target panel id, client - /// x, y). Fired by the imperative `contextmenu` listener on the panel - /// container — see `_contextmenu_listener` — and by each `PanelTab`. + /// x, y). Fired by the imperative `contextmenu` listener on the viewer + /// host — see `_contextmenu_listener` — and by each `PanelTab`. /// `Some(id)` activates that panel and opens its /// [`PanelMenu`](crate::components::panel_menu::PanelMenu) at the - /// cursor; `None` is the EMPTY stage (zero panels) — a target-less menu - /// offering only "New", whose items create the first panel. + /// cursor; `None` (no frame on the composed path — status bar, + /// sidebars, the empty stage) resolves to the ACTIVE panel, else to a + /// target-less menu offering only "New", whose items create the first + /// panel. ContextMenu(Option, f64, f64), /// Dismiss the panel context menu (the menu session ended). diff --git a/rust/perspective-viewer/src/rust/components/main_panel/update.rs b/rust/perspective-viewer/src/rust/components/main_panel/update.rs index 1e54f0be21..a984c6b139 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/update.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/update.rs @@ -160,6 +160,18 @@ impl MainPanel { x: f64, y: f64, ) -> bool { + // A frame-less right-click (status bar, sidebars) targets the + // ACTIVE panel, validated against the workspace so a stale slot + // name after an eject falls back to the target-less stage menu. + let id = id.or_else(|| { + ctx.props().renderer.slot_name().filter(|name| { + ctx.props() + .workspace + .panel(&crate::workspace::PanelId::from(name.as_str())) + .is_some() + }) + }); + // Make the right-clicked panel active so active-targeting // commands (e.g. Reset) act on it; then show the menu. if let Some(id) = &id { diff --git a/rust/perspective-viewer/src/rust/components/new_panel_menu.rs b/rust/perspective-viewer/src/rust/components/new_panel_menu.rs index 931e76ed54..5642e48032 100644 --- a/rust/perspective-viewer/src/rust/components/new_panel_menu.rs +++ b/rust/perspective-viewer/src/rust/components/new_panel_menu.rs @@ -29,12 +29,23 @@ pub type HostedTables = Rc)>>; /// `(panel id, title)` per panel, in layout-insertion order. pub type PanelLabels = Rc)>>; +/// Consumer count per derived `Table` name, which marks those rows and gates +/// their delete affordance. +pub type DerivedCounts = Rc>; + #[derive(Properties, PartialEq)] pub struct NewPanelMenuProps { /// `None` while the hosted-table fetch is in flight. pub tables: Option, pub panels: PanelLabels, pub callback: Callback, + + #[prop_or_default] + pub derived: DerivedCounts, + + /// Delete a derived `Table`, offered only where nothing reads it. + #[prop_or_default] + pub on_delete: Option>, } /// The "New" menu body shared by the status bar dropdown and the context @@ -58,7 +69,40 @@ pub fn NewPanelMenu(props: &NewPanelMenuProps) -> Html { client: client.clone(), table: table.clone(), }); - html! { { table } } + + let consumers = props.derived.get(table).copied(); + let mut class = classes!("dropdown-menu-item"); + if consumers.is_some() { + class.push("is-derived"); + } + + let on_delete = props + .on_delete + .clone() + .filter(|_| consumers == Some(0)) + .map(|cb| { + let table = table.clone(); + Callback::from(move |event: MouseEvent| { + event.stop_propagation(); + cb.emit(table.clone()); + }) + }); + + html! { + + { table } + if let Some(count) = consumers.filter(|x| *x > 0) { + { count } + } + if let Some(on_delete) = on_delete { + + } + + } }); html! { @@ -95,3 +139,19 @@ pub fn NewPanelMenu(props: &NewPanelMenuProps) -> Html { } } + +/// Consumer counts for every derived `Table` the workspace owns. +pub fn derived_counts( + workspace: &crate::workspace::Workspace, +) -> crate::components::new_panel_menu::DerivedCounts { + std::rc::Rc::new( + workspace + .derived_names() + .into_iter() + .map(|name| { + let count = workspace.derived_consumers(&name); + (name, count) + }) + .collect(), + ) +} diff --git a/rust/perspective-viewer/src/rust/components/panel_menu.rs b/rust/perspective-viewer/src/rust/components/panel_menu.rs index ea0f9a2b4c..d2f2df6a3c 100644 --- a/rust/perspective-viewer/src/rust/components/panel_menu.rs +++ b/rust/perspective-viewer/src/rust/components/panel_menu.rs @@ -35,13 +35,13 @@ use yew::prelude::*; use crate::components::copy_dropdown::CopyDropDownMenu; use crate::components::export_dropdown::ExportDropDownMenu; -use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick}; +use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick, derived_counts}; use crate::components::style::StyleSurface; use crate::config::*; use crate::js::copy_to_clipboard; use crate::presentation::Presentation; use crate::queries::fetch_hosted_tables; -use crate::tasks::export_method_to_blob; +use crate::tasks::{can_derive, delete_derived, export_method_to_blob}; use crate::ui::{ContextMenu, ContextMenuEntry, ContextMenuItem, MODAL_SLOT, PortalModal}; use crate::utils::*; use crate::workspace::{PanelId, Workspace}; @@ -59,6 +59,10 @@ pub enum PanelCommand { /// A fresh panel copied from the named panel. NewFromPanel(String), Duplicate, + + /// Promote the panel's current `View` to a derived `Table` under a + /// generated name, and rebind the panel to it. + Derive, Reset, Maximize, Restore, @@ -153,6 +157,10 @@ pub struct PanelMenu { /// loaded client, in registration order. `None` while the fetch spawned at /// menu open is still in flight. tables: Option, + + /// Keeps the [`Workspace::hosted_tables_changed`] listener attached for + /// the menu session, so the open listing tracks table creates/deletes. + _tables_sub: Subscription, } impl Component for PanelMenu { @@ -160,13 +168,25 @@ impl Component for PanelMenu { type Properties = PanelMenuProps; fn create(ctx: &Context) -> Self { - let workspace = ctx.props().workspace.clone(); - let link = ctx.link().clone(); - ApiFuture::spawn(async move { - let tables = fetch_hosted_tables(&workspace).await; - link.send_message(PanelMenuMsg::TablesLoaded(tables)); - Ok(()) - }); + let fetch = { + let workspace = ctx.props().workspace.clone(); + let link = ctx.link().clone(); + move || { + clone!(workspace, link); + ApiFuture::spawn(async move { + let tables = fetch_hosted_tables(&workspace).await; + link.send_message(PanelMenuMsg::TablesLoaded(tables)); + Ok(()) + }); + } + }; + + fetch(); + let _tables_sub = ctx + .props() + .workspace + .hosted_tables_changed() + .add_listener(move |_| fetch()); Self { anchor: session_anchor( @@ -176,6 +196,7 @@ impl Component for PanelMenu { ), picker: None, tables: None, + _tables_sub, } } @@ -251,6 +272,13 @@ impl PanelMenu { Some(panel_id) => { let can_close = ctx.props().workspace.len() > 1; let is_master = ctx.props().workspace.is_master(&PanelId::from(panel_id)); + let panel = ctx.props().workspace.panel(&PanelId::from(panel_id)); + let can_derive_view = panel.as_ref().is_some_and(|x| { + let config = x.session.get_view_config(); + let is_pivoted = !config.group_by.is_empty() || !config.split_by.is_empty(); + is_pivoted && can_derive(&x.session) + }); + vec![ ContextMenuEntry::Submenu { label: "New".to_owned(), @@ -258,6 +286,7 @@ impl PanelMenu { entries: vec![ContextMenuEntry::Custom(self.new_submenu_body(ctx))], }, item("Duplicate", cmd(PanelCommand::Duplicate), false), + item("Derive Table", cmd(PanelCommand::Derive), !can_derive_view), item("Reset", cmd(PanelCommand::Reset), false), item( "Export", @@ -335,7 +364,21 @@ impl PanelMenu { vec![cmd, PanelMenuMsg::MenuClosed] }); - html! { } + let workspace = ctx.props().workspace.clone(); + let on_delete = Callback::from(move |name: String| { + let workspace = workspace.clone(); + ApiFuture::spawn(async move { delete_derived(&workspace, &name).await }); + }); + + html! { + + } } /// Export/Copy format-picker spawned in place of the context menu, anchored @@ -364,18 +407,20 @@ impl PanelMenu { clone!(presentation); let session = panel.session.clone(); let renderer = panel.renderer.clone(); + let workspace = ctx.props().workspace.clone(); let link = ctx.link().clone(); Callback::from(move |file: ExportFile| { if file.name.is_empty() { return; } - clone!(session, renderer, presentation, link); + clone!(session, renderer, presentation, workspace, link); ApiFuture::spawn(async move { let blob = export_method_to_blob( &session, &renderer, &presentation, + &workspace, file.method, ) .await?; @@ -400,14 +445,16 @@ impl PanelMenu { clone!(presentation); let session = panel.session.clone(); let renderer = panel.renderer.clone(); + let workspace = ctx.props().workspace.clone(); let link = ctx.link().clone(); Callback::from(move |file: ExportFile| { - clone!(session, renderer, presentation, link); + clone!(session, renderer, presentation, workspace, link); ApiFuture::spawn(async move { let task = export_method_to_blob( &session, &renderer, &presentation, + &workspace, file.method, ); copy_to_clipboard(task, file.method.mimetype(file.is_chart)).await?; diff --git a/rust/perspective-viewer/src/rust/components/render_warning.rs b/rust/perspective-viewer/src/rust/components/render_warning.rs index 3519e88b81..b23fbfc02c 100644 --- a/rust/perspective-viewer/src/rust/components/render_warning.rs +++ b/rust/perspective-viewer/src/rust/components/render_warning.rs @@ -13,6 +13,7 @@ use yew::prelude::*; use crate::renderer::limits::RenderLimits; +use crate::utils::left_click; #[derive(Properties, PartialEq)] pub struct RenderWarningProps { @@ -76,7 +77,7 @@ pub fn render_warning(props: &RenderWarningProps) -> Html { }; let on_dismiss = props.on_dismiss.clone(); - let onclick = Callback::from(move |_: MouseEvent| on_dismiss.emit(())); + let onclick = left_click(Callback::from(move |_: MouseEvent| on_dismiss.emit(()))); html! { <>
Html { presentation={presentation.clone()} renderer={renderer.clone()} session={session.clone()} + workspace={props.workspace.clone()} initial_width={width} on_auto_width={on_auto_width.clone()} on_dimensions_reset={&props.on_dimensions_reset} diff --git a/rust/perspective-viewer/src/rust/components/status_bar.rs b/rust/perspective-viewer/src/rust/components/status_bar.rs index a4e8c7de45..1a5c7930b5 100644 --- a/rust/perspective-viewer/src/rust/components/status_bar.rs +++ b/rust/perspective-viewer/src/rust/components/status_bar.rs @@ -21,7 +21,9 @@ use super::status_indicator::StatusIndicator; use crate::components::copy_dropdown::CopyDropDownMenu; use crate::components::export_dropdown::ExportDropDownMenu; use crate::components::global_filter_bar::GlobalFilterBar; -use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick, PanelLabels}; +use crate::components::new_panel_menu::{ + HostedTables, NewPanelMenu, NewPanelPick, PanelLabels, derived_counts, +}; use crate::components::style::StyleSurface; use crate::config::*; use crate::js::*; @@ -109,6 +111,10 @@ pub enum StatusBarMsg { /// A pick in the "New" dropdown. NewPanelPick(NewPanelPick), + + /// The hosted-table set changed: refresh the "New" dropdown's listing + /// in place, if it is open. + RefreshTables, Export, Copy, CloseExport, @@ -131,16 +137,41 @@ pub struct StatusBar { /// The "New" dropdown's anchor and listings while it is open. new_menu: Option<(HtmlElement, HostedTables, PanelLabels)>, + /// Keeps the [`Workspace::hosted_tables_changed`] listener attached for + /// the component's lifetime. + _tables_sub: Subscription, + /// Bumped per "New" click; a resolving fetch opens only if it is still /// the newest one. new_generation: u32, } +impl StatusBar { + /// Fetch the hosted-table listing and (re)open the "New" dropdown with + /// it, guarded by `new_generation` against stale resolutions. + fn spawn_tables_fetch(&mut self, ctx: &Context) { + self.new_generation = self.new_generation.wrapping_add(1); + let generation = self.new_generation; + let workspace = ctx.props().workspace.clone(); + let link = ctx.link().clone(); + ApiFuture::spawn(async move { + let tables = fetch_hosted_tables(&workspace).await; + link.send_message(StatusBarMsg::OpenNewPanel(generation, Rc::new(tables))); + Ok(()) + }); + } +} + impl Component for StatusBar { type Message = StatusBarMsg; type Properties = StatusBarProps; - fn create(_ctx: &Context) -> Self { + fn create(ctx: &Context) -> Self { + let _tables_sub = ctx.props().workspace.hosted_tables_changed().add_listener({ + let link = ctx.link().clone(); + move |_| link.send_message(StatusBarMsg::RefreshTables) + }); + Self { copy_ref: NodeRef::default(), export_ref: NodeRef::default(), @@ -149,6 +180,7 @@ impl Component for StatusBar { copy_target: None, export_target: None, new_menu: None, + _tables_sub, new_generation: 0, } } @@ -161,15 +193,13 @@ impl Component for StatusBar { false }, StatusBarMsg::NewPanel => { - self.new_generation = self.new_generation.wrapping_add(1); - let generation = self.new_generation; - let workspace = ctx.props().workspace.clone(); - let link = ctx.link().clone(); - ApiFuture::spawn(async move { - let tables = fetch_hosted_tables(&workspace).await; - link.send_message(StatusBarMsg::OpenNewPanel(generation, Rc::new(tables))); - Ok(()) - }); + self.spawn_tables_fetch(ctx); + false + }, + StatusBarMsg::RefreshTables => { + if self.new_menu.is_some() { + self.spawn_tables_fetch(ctx); + } false }, @@ -201,11 +231,13 @@ impl Component for StatusBar { }, StatusBarMsg::CloseNewPanel => { self.new_menu = None; + self.new_generation = self.new_generation.wrapping_add(1); true }, StatusBarMsg::NewPanelPick(pick) => { ctx.props().on_new_panel.emit(pick); self.new_menu = None; + self.new_generation = self.new_generation.wrapping_add(1); true }, StatusBarMsg::ResetTheme => { @@ -280,23 +312,30 @@ impl Component for StatusBar { } // TODO Memoizing these would reduce some vdom diffing later on - let onclose = ctx.link().callback(|_| StatusBarMsg::Eject); + let onclose = left_click(ctx.link().callback(|_| StatusBarMsg::Eject)); let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent); - let onexport = ctx.link().callback(|event: MouseEvent| { + let onexport = left_click(ctx.link().callback(|event: MouseEvent| { event.prevent_default(); StatusBarMsg::Export - }); - let oncopy = ctx.link().callback(|event: MouseEvent| { + })); + let oncopy = left_click(ctx.link().callback(|event: MouseEvent| { event.prevent_default(); StatusBarMsg::Copy - }); - let onreset = ctx.link().callback(StatusBarMsg::Reset); - let onnew = ctx.link().callback(|event: MouseEvent| { + })); + let onreset = left_click(ctx.link().callback(StatusBarMsg::Reset)); + let onnew = left_click(ctx.link().callback(|event: MouseEvent| { event.prevent_default(); StatusBarMsg::NewPanel - }); + })); let on_new_select = ctx.link().callback(StatusBarMsg::NewPanelPick); let on_close_new = ctx.link().callback(|_| StatusBarMsg::CloseNewPanel); + let on_delete_derived = { + let workspace = ctx.props().workspace.clone(); + Callback::from(move |name: String| { + let workspace = workspace.clone(); + ApiFuture::spawn(async move { delete_derived(&workspace, &name).await }); + }) + }; // Project only the *active* panel's plugin toolbar into the shared status // bar. Each panel's toolbar slots into `statusbar-extra-{its-panel-id}` @@ -325,6 +364,7 @@ impl Component for StatusBar { &props.session, &props.renderer, &props.presentation, + &props.workspace, x.method, ); let result = copy_to_clipboard(task, mime).await; @@ -351,6 +391,7 @@ impl Component for StatusBar { &props.session, &props.renderer, &props.presentation, + &props.workspace, x.method, ) .await @@ -456,6 +497,8 @@ impl Component for StatusBar { tables={Some(tables.clone())} panels={panels.clone()} callback={on_new_select} + derived={derived_counts(&ctx.props().workspace)} + on_delete={on_delete_derived} /> } diff --git a/rust/perspective-viewer/src/rust/components/viewer.rs b/rust/perspective-viewer/src/rust/components/viewer.rs index 97d0458083..f3584dd60a 100644 --- a/rust/perspective-viewer/src/rust/components/viewer.rs +++ b/rust/perspective-viewer/src/rust/components/viewer.rs @@ -315,6 +315,7 @@ impl Component for PerspectiveViewer { PanelClosed(id) => self.on_panel_closed(ctx, id), CommitWorkspaceRestore(id) => self.on_commit_workspace_restore(ctx, id), DuplicatePanel(id) => self.on_duplicate_panel(ctx, id), + DerivePanel(id) => self.on_derive_panel(ctx, id), NewPanel(id) => self.on_new_panel(ctx, id), NewPanelFrom { client, table } => self.on_new_panel_from(ctx, client, table), diff --git a/rust/perspective-viewer/src/rust/components/viewer/msg.rs b/rust/perspective-viewer/src/rust/components/viewer/msg.rs index 8d506ffe5c..c0f973b7d1 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/msg.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/msg.rs @@ -132,6 +132,10 @@ pub enum PerspectiveViewerMsg { /// panel appended to the layout. DuplicatePanel(String), + /// Promote the named panel's current `View` to a derived `Table` under a + /// generated name, rebinding the panel to it. + DerivePanel(String), + /// New panel: a fresh (default-config) panel bound to the named panel's /// table (from the default client). NewPanel(String), diff --git a/rust/perspective-viewer/src/rust/components/viewer/panels.rs b/rust/perspective-viewer/src/rust/components/viewer/panels.rs index 33f8943f96..d895d3fd0c 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/panels.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/panels.rs @@ -28,11 +28,12 @@ use super::wiring::{ subscribe_panel_titles, }; use crate::config::ViewerConfigInitial; +use crate::derived::suggest_name; use crate::queries::*; use crate::renderer::Renderer; use crate::session::*; use crate::tasks::*; -use crate::utils::{Completion, spawn_owned}; +use crate::utils::{Completion, PtrEqRc, spawn_owned}; use crate::workspace::{Panel, PanelId}; impl PerspectiveViewer { @@ -247,6 +248,37 @@ impl PerspectiveViewer { false } + pub(super) fn on_derive_panel(&mut self, ctx: &Context, id: String) -> bool { + if let Some(panel) = ctx.props().workspace.panel(&PanelId::from(id)) { + let presentation = ctx.props().presentation.clone(); + let workspace = ctx.props().workspace.clone(); + ApiFuture::spawn(async move { + let config = PtrEqRc::new(panel.session.get_view_config().clone()); + let table = panel.session.get_table().map(|x| x.get_name().to_owned()); + let name = suggest_name( + &fetch_hosted_tables(&workspace) + .await + .into_iter() + .flat_map(|(_, tables)| tables) + .collect(), + &config, + &table, + ); + + create_derived( + &panel.session, + &panel.renderer, + &presentation, + &workspace, + name, + ) + .await + }); + } + + false + } + pub(super) fn on_new_panel(&mut self, ctx: &Context, id: String) -> bool { if let Some(panel) = ctx.props().workspace.panel(&PanelId::from(id)) { let Some(table_name) = panel.session.get_table().map(|t| t.get_name().to_owned()) diff --git a/rust/perspective-viewer/src/rust/components/viewer/render.rs b/rust/perspective-viewer/src/rust/components/viewer/render.rs index 81cbefc7d6..7a4f308888 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/render.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/render.rs @@ -179,6 +179,7 @@ impl PerspectiveViewer { vec![NewPanelFrom { client, table }] }, PanelCommand::Duplicate => vec![DuplicatePanel(id)], + PanelCommand::Derive => vec![DerivePanel(id)], PanelCommand::NewFromPanel(source) => vec![DuplicatePanel(source)], PanelCommand::Reset => vec![ResetPanel(Some(id), false, None)], PanelCommand::ToggleMaster => vec![ToggleMaster(id)], diff --git a/rust/perspective-viewer/src/rust/components/window_editor.rs b/rust/perspective-viewer/src/rust/components/window_editor.rs index e4da3bc7e6..d1ae6e38d5 100644 --- a/rust/perspective-viewer/src/rust/components/window_editor.rs +++ b/rust/perspective-viewer/src/rust/components/window_editor.rs @@ -24,7 +24,7 @@ use crate::components::dragdrop_list::{DragContext, DragDropList, DragDropListIt use crate::presentation::Presentation; use crate::session::{Session, SessionMetadataRc}; use crate::ui::{IntlLabel, Select, SelectItem}; -use crate::utils::{AddListener, DragEffect, DragTarget, Subscription}; +use crate::utils::{AddListener, DragEffect, DragTarget, Subscription, left_click}; /// The declared capabilities of one window aggregate, for a `source` column /// type. Which controls an aggregate needs is the data model's to state - the @@ -846,9 +846,10 @@ impl Component for WindowEditor { WindowSortDir::Asc }; - let onmousedown = ctx - .link() - .callback(|_: MouseEvent| WindowEditorMsg::ToggleOrderDir); + let onmousedown = left_click( + ctx.link() + .callback(|_: MouseEvent| WindowEditorMsg::ToggleOrderDir), + ); yew::html_nested! { { +pub struct ViewerConfig { pub settings: bool, #[serde(flatten)] - pub panel: PanelViewerConfig, + pub panel: PanelViewerConfig, } /// The per-panel state of a [`ViewerConfig`] — everything except the /// element-level `settings` flag. This is the `panels` entry type of the /// workspace config format. #[derive(Debug, Default, Serialize, PartialEq, TS)] -pub struct PanelViewerConfig { +pub struct PanelViewerConfig { /// The `@perspective-dev/viewer` version that wrote this config, /// stamped on save and used to migrate older tokens. Callers do not /// set it. @@ -66,11 +67,11 @@ pub struct PanelViewerConfig { /// the active plugin; see `get_style_schema`. pub plugin_config: serde_json::Map, - /// Name of the `Table` this panel renders, as hosted on the panel's - /// `Client`. Every placed panel has a table binding (creation - /// requires one by type — [`ViewerConfigInitial`]), so the saved - /// config carries it unconditionally. - pub table: String, + /// The `Table` this panel renders: its hosted name in a + /// [`WorkspaceConfig`] `panels` entry (`T = String`), and a + /// [`TableRef`] — a name, or a recursively inlined derived-table + /// definition — in the single-panel `save()` format. + pub table: T, /// Selected theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid /// names are the Perspective themes loaded on the page, which @@ -84,14 +85,40 @@ pub struct PanelViewerConfig { pub view_config: ViewConfig, } -impl Deref for ViewerConfig { - type Target = PanelViewerConfig; +impl Deref for ViewerConfig { + type Target = PanelViewerConfig; fn deref(&self) -> &Self::Target { &self.panel } } +impl PanelViewerConfig { + /// The same config with the `table` slot mapped, which is how the + /// single-panel emitters convert a bound name to its inline form. + pub fn map_table(self, f: impl FnOnce(T) -> U) -> PanelViewerConfig { + PanelViewerConfig { + version: self.version, + columns_config: self.columns_config, + plugin: self.plugin, + plugin_config: self.plugin_config, + table: f(self.table), + theme: self.theme, + title: self.title, + view_config: self.view_config, + } + } +} + +impl ViewerConfig { + pub fn map_table(self, f: impl FnOnce(T) -> U) -> ViewerConfig { + ViewerConfig { + settings: self.settings, + panel: self.panel.map_table(f), + } + } +} + pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| { #[derive(Deserialize)] struct Package { @@ -102,7 +129,7 @@ pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| { pkg.version }); -impl ViewerConfig { +impl ViewerConfig { /// Encode a `ViewerConfig` to a `JsValue` in a supported type. pub fn encode(&self) -> ApiResult { Ok(JsValue::from_serde_ext(self)?) @@ -150,10 +177,11 @@ pub struct ViewerConfigUpdate { #[ts(optional)] pub title: TitleUpdate, - /// Name of the `Table` to render, as hosted on this panel's - /// `Client`. Rebinding an existing panel to another table keeps the - /// rest of the config, so column names that do not exist in the new - /// table will fail validation. + /// The `Table` to render: a name as hosted on this panel's `Client`, + /// or an inline derived-table definition to materialize (reusing a + /// structurally identical derived `Table` when one exists). Rebinding + /// an existing panel keeps the rest of the config, so column names + /// that do not exist in the new table will fail validation. #[serde(default)] #[ts(as = "Option<_>")] #[ts(optional)] @@ -305,7 +333,7 @@ impl From for ViewerConfigUpdate { version: up(value.version), plugin: up(value.plugin), title: up(value.title), - table: OptionalUpdate::Update(value.table), + table: OptionalUpdate::Update(value.table.into()), theme: up(value.theme), settings: OptionalUpdate::Missing, plugin_config: up(value.plugin_config), @@ -350,6 +378,8 @@ impl TryFrom for ViewerConfigInitial { return Err(ApiError::new(CREATE_REQUIRES_TABLE)); }; + let table = table.name()?.to_owned(); + Ok(Self { table, version: down(version), @@ -393,7 +423,7 @@ pub type PluginUpdate = OptionalUpdate; pub type SettingsUpdate = OptionalUpdate; pub type ThemeUpdate = OptionalUpdate; pub type TitleUpdate = OptionalUpdate; -pub type TableUpdate = OptionalUpdate; +pub type TableUpdate = OptionalUpdate; pub type VersionUpdate = OptionalUpdate; pub type ColumnConfigUpdate = OptionalUpdate; pub type PluginConfigUpdate = OptionalUpdate>; diff --git a/rust/perspective-viewer/src/rust/config/workspace_config.rs b/rust/perspective-viewer/src/rust/config/workspace_config.rs index add9b55ffc..6dfb8859ff 100644 --- a/rust/perspective-viewer/src/rust/config/workspace_config.rs +++ b/rust/perspective-viewer/src/rust/config/workspace_config.rs @@ -12,11 +12,153 @@ use std::collections::BTreeMap; -use perspective_client::config::Filter; +use perspective_client::config::{Filter, ViewConfig}; use crate::config::{OptionalUpdate, PanelViewerConfig, ViewerConfigInitial}; use crate::utils::CssKind; +/// A derived `Table`'s definition: the `Table` its source `View` reads, and +/// the config of that `View`; generic over the `table` slot, which is a name +/// in the workspace `derived_tables` map and a recursive [`TableRef`] when +/// inlined in a single-panel config. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +pub struct DerivedTableConfig { + /// The `Table` this derivation reads, which may itself be another + /// derived `Table`. + pub table: T, + + #[serde(flatten)] + pub view_config: ViewConfig, +} + +/// What may occupy a `table` position in a single-panel config: a hosted +/// `Table`'s name, or an inline derived-table definition recursing through +/// its own `table` field until a concrete name. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +#[serde(untagged)] +pub enum TableRef { + Name(String), + Derived(Box>), +} + +/// Hand-written because the derived decl's recursion sits in a generic alias +/// instantiation (`DerivedTableConfig`), which TypeScript expands +/// eagerly and rejects as circular; recursion is legal only deferred through +/// an object-literal node. +impl ts_rs::TS for TableRef { + type OptionInnerType = Self; + type WithoutGenerics = Self; + + fn docs() -> Option { + Some( + "/**\n * What may occupy a `table` position in a single-panel config: a hosted\n * \ + `Table`'s name, or an inline derived-table definition recursing through\n * its own \ + `table` field until a concrete name.\n */\n" + .to_owned(), + ) + } + + fn name() -> String { + "TableRef".to_owned() + } + + fn decl() -> String { + format!( + "type TableRef = string | (Omit<{}, \"table\"> & {{ table: TableRef }});", + ::ident() + ) + } + + fn decl_concrete() -> String { + ::decl() + } + + fn inline() -> String { + format!( + "string | (Omit<{}, \"table\"> & {{ table: TableRef }})", + ::ident() + ) + } + + fn inline_flattened() -> String { + panic!("`TableRef` cannot be flattened") + } + + fn visit_dependencies(visitor: &mut impl ts_rs::TypeVisitor) + where + Self: 'static, + { + visitor.visit::(); + } + + fn output_path() -> Option { + Some(std::path::PathBuf::from("TableRef.ts")) + } +} + +impl Default for TableRef { + fn default() -> Self { + Self::Name(String::default()) + } +} + +impl From for TableRef { + fn from(value: String) -> Self { + Self::Name(value) + } +} + +impl TableRef { + /// The concrete `Table` name, or an error for an inline definition which + /// a restore boundary has not resolved. + pub fn name(&self) -> Result<&str, perspective_js::utils::ApiError> { + match self { + Self::Name(name) => Ok(name), + Self::Derived(_) => Err(perspective_js::utils::ApiError::new( + "Unresolved inline derived table", + )), + } + } +} + +impl<'de> serde::Deserialize<'de> for TableRef { + /// Hand-written (not `untagged`) so a malformed inline definition + /// reports its own error instead of "data did not match any variant". + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct TableRefVisitor; + impl<'de> serde::de::Visitor<'de> for TableRefVisitor { + type Value = TableRef; + + fn expecting(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt.write_str("a table name or an inline derived table definition") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(TableRef::Name(value.to_owned())) + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let config = as serde::Deserialize>::deserialize( + serde::de::value::MapAccessDeserializer::new(map), + )?; + + Ok(TableRef::Derived(Box::new(config))) + } + } + + deserializer.deserialize_any(TableRefVisitor) + } +} + /// The workspace config format (`{version, active?, layout, panels}`) — /// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as /// emitted by [`PerspectiveViewerElement::save`]. @@ -39,7 +181,14 @@ pub struct WorkspaceConfig { /// DETERMINISTIC (sorted) key order — a fresh `HashMap` per call /// iterates in a per-instance random order, which made consecutive /// `save()` outputs byte-unstable. - pub panels: BTreeMap, + pub panels: BTreeMap>, + + /// Named derived `Table`s, each a `View` of its `parent` promoted to a + /// `Table`. A `BTreeMap` for the same determinism reason as `panels`. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[ts(as = "Option<_>")] + #[ts(optional)] + pub derived_tables: BTreeMap, /// The element-level global (master/detail cross-) filters. A transient /// overlay on every detail panel's view — persisted here, never in a @@ -92,6 +241,13 @@ pub struct WorkspaceConfigUpdate { #[ts(optional)] pub panels: Option>, + /// The complete replacement set of derived `Table`s, in any order (they + /// are built parent-first); absent keeps the current ones, `{}` deletes + /// them all. + #[serde(default)] + #[ts(optional)] + pub derived_tables: Option>, + /// The element-level cross-filter set applied to every detail panel as /// one unattributed bucket, replacing the current set; `null` clears it. #[serde(default)] diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index 66c44877d8..1735dbaa46 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -236,7 +236,8 @@ impl PerspectiveViewerElement { async fn workspace_config(this: Self, full_palette: bool) -> ApiResult { this.workspace.effects().settle().await; - let mut panels: std::collections::BTreeMap = Default::default(); + let mut panels: std::collections::BTreeMap> = + Default::default(); for id in &this.workspace.panel_ids() { let panel = this.workspace.panel(id).into_apierror()?; let config = panel @@ -287,6 +288,7 @@ impl PerspectiveViewerElement { active, layout, panels, + derived_tables: this.workspace.derived_configs(), global_filters: this.workspace.global_filters(), masters: this .workspace @@ -1129,6 +1131,7 @@ impl PerspectiveViewerElement { Create(Box), } + crate::tasks::resolve_table_ref(&this.workspace, &mut update, missing).await?; let notify = this.layout_changed_notify(); let target = match this.workspace.panel_or_active(id.as_ref()) { // An existing (or the active) panel — update it in place. @@ -1259,7 +1262,11 @@ impl PerspectiveViewerElement { let _effect = effect; let RestoreWorkspaceOptions { wait_for_table } = parse_options(options)?; let missing = MissingTable::from_wait(wait_for_table); - let (contents, eject_tasks) = sync_update_panels(&this, update)?; + let (contents, eject_tasks, derived_tables) = sync_update_panels(&this, update)?; + if let Some(derived_tables) = derived_tables.clone() { + crate::tasks::apply_derived_tables(&this.workspace, derived_tables).await?; + } + let fresh = contents .iter() .map(|(id, ..)| id.clone()) @@ -1297,6 +1304,11 @@ impl PerspectiveViewerElement { .await; results.into_iter().collect::>>()?; + if let Some(derived_tables) = derived_tables { + let keep = derived_tables.keys().cloned().collect(); + crate::tasks::prune_derived(&this.workspace, &keep).await?; + } + this.rerender_retained_overlays(&fresh).await?; join_all(eject_tasks) .await @@ -1383,7 +1395,9 @@ impl PerspectiveViewerElement { }) .await?; - viewer_config.encode() + viewer_config + .map_table(|name| this.workspace.inline_table_ref(&name)) + .encode() }); js_sys::Promise::from(fut).unchecked_into() @@ -1447,9 +1461,14 @@ impl PerspectiveViewerElement { }; let panel = this.resolve_panel(name)?; - let blob = - export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method) - .await?; + let blob = export_method_to_blob( + &panel.session, + &panel.renderer, + &this.presentation, + &this.workspace, + method, + ) + .await?; let is_chart = panel.renderer.is_chart(); download( format!("untitled{}", method.as_filename(is_chart)).as_ref(), @@ -1495,8 +1514,14 @@ impl PerspectiveViewerElement { }; let panel = this.resolve_panel(name)?; - export_method_to_jsvalue(&panel.session, &panel.renderer, &this.presentation, method) - .await + export_method_to_jsvalue( + &panel.session, + &panel.renderer, + &this.presentation, + &this.workspace, + method, + ) + .await }) } @@ -1534,8 +1559,13 @@ impl PerspectiveViewerElement { }; let panel = this.resolve_panel(name)?; - let js_task = - export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method); + let js_task = export_method_to_blob( + &panel.session, + &panel.renderer, + &this.presentation, + &this.workspace, + method, + ); copy_to_clipboard(js_task, MimeType::TextPlain).await }) } diff --git a/rust/perspective-viewer/src/rust/custom_events.rs b/rust/perspective-viewer/src/rust/custom_events.rs index d3189cd637..546e6a26fc 100644 --- a/rust/perspective-viewer/src/rust/custom_events.rs +++ b/rust/perspective-viewer/src/rust/custom_events.rs @@ -149,19 +149,21 @@ fn dispatch_config_update( session: &Session, renderer: &Renderer, presentation: &Presentation, + workspace: &Workspace, ) { if session.get_table().is_none() && session.pending_table().is_none() { return; } - clone!(session, renderer, presentation); + clone!(session, renderer, presentation, workspace); let elem = elem.clone(); let tracker = session.clone(); tracker.track_dispatch(async move { let viewer_config = renderer .clone() .with_lock(async { get_viewer_config(&session, &renderer, &presentation).await }) - .await?; + .await? + .map_table(|name| workspace.inline_table_ref(&name)); if viewer_config.view_config != Default::default() && Some(&viewer_config) != session.last_dispatched_config.borrow().as_deref() @@ -215,7 +217,13 @@ pub fn wire_element_events( clone!(elem, presentation, workspace); move |_| { if let Some(panel) = workspace.active_panel() { - dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + dispatch_config_update( + &elem, + &panel.session, + &panel.renderer, + &presentation, + &workspace, + ); } } }); @@ -234,7 +242,13 @@ pub fn wire_element_events( dispatch_event(&elem, "toggle-settings", open).unwrap(); if let Some(panel) = workspace.active_panel() { - dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + dispatch_config_update( + &elem, + &panel.session, + &panel.renderer, + &presentation, + &workspace, + ); } } }); @@ -328,12 +342,13 @@ pub fn wire_panel_events( session: &Session, renderer: &Renderer, presentation: &Presentation, + workspace: &Workspace, ) -> Vec { let plugin_sub = renderer.plugin_changed.add_listener({ - clone!(elem, session, renderer, presentation); + clone!(elem, session, renderer, presentation, workspace); move |plugin| { dispatch_plugin_changed(&elem, &renderer, &plugin); - dispatch_config_update(&elem, &session, &renderer, &presentation); + dispatch_config_update(&elem, &session, &renderer, &presentation, &workspace); } }); @@ -343,13 +358,13 @@ pub fn wire_panel_events( // paused-`load()` case). The dispatcher dedups, so REBUILD's pairing // with `view_created` can't double-fire it. let view_sub = session.commit_reconciled.add_listener({ - clone!(elem, session, renderer, presentation); - move |_| dispatch_config_update(&elem, &session, &renderer, &presentation) + clone!(elem, session, renderer, presentation, workspace); + move |_| dispatch_config_update(&elem, &session, &renderer, &presentation, &workspace) }); let title_sub = session.title_changed.add_listener({ - clone!(elem, session, renderer, presentation); - move |_| dispatch_config_update(&elem, &session, &renderer, &presentation) + clone!(elem, session, renderer, presentation, workspace); + move |_| dispatch_config_update(&elem, &session, &renderer, &presentation, &workspace) }); let unload_sub = session.table_unloaded.add_listener({ @@ -365,22 +380,22 @@ pub fn wire_panel_events( }); let select_sub = renderer.selection_changed.add_listener({ - clone!(elem, session, renderer, presentation); + clone!(elem, session, renderer, presentation, workspace); move |window: Option| { let detail = JsValue::from_serde_ext(&window).unwrap(); annotate_panel(&detail, &renderer); let _ = dispatch_panel_event(&elem, &renderer, "select", &detail); - dispatch_config_update(&elem, &session, &renderer, &presentation); + dispatch_config_update(&elem, &session, &renderer, &presentation, &workspace); } }); let column_style_sub = renderer.column_style_changed.add_listener({ - clone!(elem, session, renderer, presentation); + clone!(elem, session, renderer, presentation, workspace); move |cfg: ColumnConfigMap| { let detail = JsValue::from_serde_ext(&cfg).unwrap(); annotate_panel(&detail, &renderer); let _ = dispatch_panel_event(&elem, &renderer, "column-style-change", &detail); - dispatch_config_update(&elem, &session, &renderer, &presentation); + dispatch_config_update(&elem, &session, &renderer, &presentation, &workspace); } }); diff --git a/rust/perspective-viewer/src/rust/derived.rs b/rust/perspective-viewer/src/rust/derived.rs new file mode 100644 index 0000000000..6fc7f1b902 --- /dev/null +++ b/rust/perspective-viewer/src/rust/derived.rs @@ -0,0 +1,266 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::collections::{BTreeMap, BTreeSet}; + +use perspective_client::config::ViewConfig; +use perspective_client::{DeleteOptions, Table, View}; +use perspective_js::utils::*; + +use crate::config::DerivedTableConfig; +use crate::session::ViewSubscription; +use crate::utils::PtrEqRc; + +/// The `View` a derived `Table` reads, which is a panel's own subscription when +/// the derivation consumed it and a bare `View` when a restore rebuilt it. +pub enum DerivedSource { + Panel(ViewSubscription), + Restored(View), +} + +impl DerivedSource { + async fn delete(self) -> ApiResult<()> { + match self { + Self::Panel(sub) => sub.delete().await, + Self::Restored(view) => Ok(view.delete().await?), + } + } +} + +/// A live derived `Table` and the `View` it reads. +pub struct DerivedTable { + parent: String, + view_config: ViewConfig, + source: DerivedSource, + table: Table, +} + +impl DerivedTable { + pub fn new( + parent: String, + view_config: ViewConfig, + source: DerivedSource, + table: Table, + ) -> Self { + Self { + parent, + view_config, + source, + table, + } + } + + pub fn parent(&self) -> &str { + &self.parent + } + + pub fn view_config(&self) -> &ViewConfig { + &self.view_config + } + + pub fn table(&self) -> &Table { + &self.table + } + + /// The persisted definition of this derivation. + pub fn config(&self) -> DerivedTableConfig { + DerivedTableConfig { + table: self.parent.clone(), + view_config: self.view_config.clone(), + } + } + + /// Whether this derivation was built from `config`, and so may be reused. + pub fn matches(&self, config: &DerivedTableConfig) -> bool { + self.parent == config.table && self.view_config == config.view_config + } + + /// Delete the `Table` before the `View` it reads, which the engine + /// requires in that order. + pub async fn delete(self) -> ApiResult<()> { + self.table.delete(DeleteOptions::default()).await?; + self.source.delete().await + } +} + +/// The workspace's derived `Table`s: those live, and those whose parent is not +/// yet hosted. +#[derive(Default)] +pub struct DerivedRegistry { + live: BTreeMap, + pending: BTreeMap, +} + +impl DerivedRegistry { + pub fn get(&self, name: &str) -> Option<&DerivedTable> { + self.live.get(name) + } + + pub fn contains(&self, name: &str) -> bool { + self.live.contains_key(name) + } + + pub fn insert(&mut self, name: String, table: DerivedTable) { + self.pending.remove(&name); + self.live.insert(name, table); + } + + pub fn remove(&mut self, name: &str) -> Option { + self.pending.remove(name); + self.live.remove(name) + } + + pub fn names(&self) -> Vec { + self.live.keys().cloned().collect() + } + + /// The name of a live derivation built from `config`, regardless of what + /// it is named, for inline-definition restores to rebind to. + pub fn find_structural(&self, config: &DerivedTableConfig) -> Option { + self.live + .iter() + .find(|(_, table)| table.matches(config)) + .map(|(name, _)| name.clone()) + } + + /// The definitions waiting on a parent that is not yet hosted. + pub fn pending(&self) -> BTreeMap { + self.pending.clone() + } + + pub fn set_pending(&mut self, pending: BTreeMap) { + self.pending = pending; + } + + /// Every derivation's persisted definition, live or pending, for + /// `saveWorkspace`. + pub fn configs(&self) -> BTreeMap { + let mut out = self.pending.clone(); + out.extend( + self.live + .iter() + .map(|(name, table)| (name.clone(), table.config())), + ); + + out + } + + /// The names whose `parent` is `name`. + pub fn children(&self, name: &str) -> Vec { + self.live + .iter() + .filter(|(_, table)| table.parent == name) + .map(|(child, _)| child.clone()) + .chain( + self.pending + .iter() + .filter(|(_, config)| config.table == name) + .map(|(child, _)| child.clone()), + ) + .collect() + } + + /// `name`'s ancestors, nearest first, stopping at the first non-derived + /// `Table`. + pub fn ancestors(&self, name: &str) -> Vec { + let mut out = vec![]; + let mut seen = BTreeSet::new(); + let mut cursor = name.to_owned(); + while let Some(entry) = self.live.get(&cursor) { + if !seen.insert(cursor.clone()) { + break; + } + + out.push(entry.parent.clone()); + cursor = entry.parent.clone(); + } + + out + } +} + +/// Order `configs` parents-first, or report the names forming a cycle. +pub fn topo_order(configs: &BTreeMap) -> Result, String> { + let mut pending = configs.keys().cloned().collect::>(); + let mut out = Vec::with_capacity(configs.len()); + while !pending.is_empty() { + let ready = pending + .iter() + .filter(|name| { + let parent = &configs[*name].table; + !pending.contains(parent) + }) + .cloned() + .collect::>(); + + if ready.is_empty() { + let cycle = pending.into_iter().collect::>().join("`, `"); + return Err(format!( + "`derived_tables` has a dependency cycle among `{cycle}`" + )); + } + + for name in ready { + pending.remove(&name); + out.push(name); + } + } + + Ok(out) +} + +/// The subset of `order` that may be reused from `registry`, which requires +/// every ancestor within `configs` to be reusable too. +pub fn reusable( + order: &[String], + configs: &BTreeMap, + registry: &DerivedRegistry, +) -> BTreeSet { + let mut out = BTreeSet::new(); + for name in order { + let config = &configs[name]; + let parent_ok = !configs.contains_key(&config.table) || out.contains(&config.table); + if parent_ok + && registry + .get(name) + .is_some_and(|table| table.matches(config)) + { + out.insert(name.clone()); + } + } + + out +} + +/// A ` GROUP BY `-style name that collides with nothing in +/// `taken`. +pub fn suggest_name( + taken: &BTreeSet, + config: &PtrEqRc, + table: &Option, +) -> String { + let table_name = table.as_deref().unwrap_or("..."); + let name = if config.group_by.is_empty() { + table_name.to_owned() + } else { + format!("{} GROUP BY {}", table_name, config.group_by.join(", ")) + }; + + if taken.contains(&name) { + (1..) + .map(|n| format!("{name} {n}")) + .find(|name| !taken.contains(name)) + .unwrap() + } else { + name + } +} diff --git a/rust/perspective-viewer/src/rust/lib.rs b/rust/perspective-viewer/src/rust/lib.rs index ea7f69e66a..8816543096 100644 --- a/rust/perspective-viewer/src/rust/lib.rs +++ b/rust/perspective-viewer/src/rust/lib.rs @@ -34,6 +34,7 @@ pub mod components; pub mod config; pub mod custom_elements; mod custom_events; +mod derived; pub mod exprtk; mod js; mod presentation; @@ -89,6 +90,8 @@ export type * from "../../src/ts/ts-rs/ViewerConfigUpdate.d.ts"; export type * from "../../src/ts/ts-rs/ViewerConfigInitial.d.ts"; export type * from "../../src/ts/ts-rs/PluginStaticConfig.d.ts"; export type * from "../../src/ts/ts-rs/WorkspaceConfig.d.ts"; +export type * from "../../src/ts/ts-rs/TableRef.d.ts"; +export type * from "../../src/ts/ts-rs/DerivedTableConfig.d.ts"; export type * from "../../src/ts/ts-rs/WorkspaceConfigUpdate.d.ts"; export type * from "../../src/ts/ts-rs/ExportMethod.d.ts"; export type * from "../../src/ts/ts-rs/PanelOptions.d.ts"; diff --git a/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs b/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs index c86f1a3cc5..ca9525c306 100644 --- a/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs +++ b/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs @@ -26,7 +26,7 @@ pub async fn get_viewer_config( session: &Session, renderer: &Renderer, presentation: &Presentation, -) -> ApiResult { +) -> ApiResult> { let version = config::API_VERSION.to_string(); let mut view_config = if let Some(rendered) = session.get_rendered_view_config() { (*rendered).clone() diff --git a/rust/perspective-viewer/src/rust/session.rs b/rust/perspective-viewer/src/rust/session.rs index e5ade9bbfb..64692a515d 100644 --- a/rust/perspective-viewer/src/rust/session.rs +++ b/rust/perspective-viewer/src/rust/session.rs @@ -43,8 +43,8 @@ pub use self::op_queue::{EditDelta, OpCtx, OpKind, StepFuture, Ticket}; use self::panel_state::effective; pub use self::panel_state::{Binding, OverlayClause, PanelState}; pub use self::props::{SessionProps, TableLoadState}; -pub use self::view_subscription::ViewStats; use self::view_subscription::*; +pub use self::view_subscription::{ViewStats, ViewSubscription}; use crate::config::PluginStaticConfig; use crate::utils::*; @@ -606,6 +606,18 @@ impl Session { self.0.queue.reject_all(disposal.into()); } + /// Take the bound `View` out WITHOUT deleting it, so it can feed a derived + /// `Table`, leaving the next bind nothing to dispose of. + pub fn take_view_sub(&self) -> Option { + self.borrow_mut().view_sub.take() + } + + /// Put a subscription taken by [`Self::take_view_sub`] back, for a + /// derivation which could not complete. + pub fn put_view_sub(&self, sub: ViewSubscription) { + self.borrow_mut().view_sub = Some(sub); + } + pub(crate) fn is_disposed(&self) -> bool { self.disposal().is_some() } @@ -1609,7 +1621,7 @@ impl Session { match agg { Err(_) => { - tracing::warn!("No default aggregate for column '{}' found, skipping", col) + tracing::debug!("No default aggregate for column '{}' found, skipping", col) }, Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg), }; diff --git a/rust/perspective-viewer/src/rust/tasks/copy_export.rs b/rust/perspective-viewer/src/rust/tasks/copy_export.rs index a6edf0a551..912533dbf0 100644 --- a/rust/perspective-viewer/src/rust/tasks/copy_export.rs +++ b/rust/perspective-viewer/src/rust/tasks/copy_export.rs @@ -29,6 +29,7 @@ use crate::queries::{export_app, get_viewer_config}; use crate::renderer::Renderer; use crate::session::Session; use crate::utils::*; +use crate::workspace::Workspace; fn tag_name_to_package(plugin: &JsPerspectiveViewerPlugin) -> String { let tag_name = plugin.unchecked_ref::().tag_name(); @@ -118,6 +119,7 @@ pub async fn export_method_to_blob( session: &Session, renderer: &Renderer, presentation: &Presentation, + workspace: &Workspace, method: ExportMethod, ) -> ApiResult { let viewport = renderer.get_selection(); @@ -197,6 +199,7 @@ pub async fn export_method_to_blob( ExportMethod::JsonConfig => js_sys::JSON::stringify( &get_viewer_config(session, renderer, presentation) .await? + .map_table(|name| workspace.inline_table_ref(&name)) .encode()?, )? .as_blob(), @@ -208,6 +211,7 @@ pub async fn export_method_to_jsvalue( session: &Session, renderer: &Renderer, presentation: &Presentation, + workspace: &Workspace, method: ExportMethod, ) -> ApiResult { let viewport = renderer.get_selection(); @@ -286,6 +290,7 @@ pub async fn export_method_to_jsvalue( ExportMethod::Plugin => txt_as_jsvalue(session, renderer, viewport).await?.into(), ExportMethod::JsonConfig => get_viewer_config(session, renderer, presentation) .await? + .map_table(|name| workspace.inline_table_ref(&name)) .encode()?, }) } diff --git a/rust/perspective-viewer/src/rust/tasks/create_panel.rs b/rust/perspective-viewer/src/rust/tasks/create_panel.rs index 35fdc60e10..603ef49537 100644 --- a/rust/perspective-viewer/src/rust/tasks/create_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/create_panel.rs @@ -31,11 +31,18 @@ use crate::*; pub(crate) fn wire_panel_subs( elem: &HtmlElement, presentation: &Presentation, + workspace: &Workspace, session: &Session, renderer: &Renderer, ) -> Vec { let mut subs = vec![wire_panel_render_sub(session, renderer)]; - subs.extend(wire_panel_events(elem, session, renderer, presentation)); + subs.extend(wire_panel_events( + elem, + session, + renderer, + presentation, + workspace, + )); subs } @@ -192,7 +199,7 @@ pub(crate) fn create_panel_model( let renderer = Renderer::new(elem); let id = id.unwrap_or_else(|| workspace.generate_id()); renderer.set_slot_name(id.as_str()); - let subs = wire_panel_subs(elem, presentation, &session, &renderer); + let subs = wire_panel_subs(elem, presentation, workspace, &session, &renderer); let panel = Panel::new(id.clone(), session.clone(), renderer.clone(), subs); match placement { Placement::Placed => workspace.insert_panel(panel, PanelPhase::Placed), diff --git a/rust/perspective-viewer/src/rust/tasks/derive.rs b/rust/perspective-viewer/src/rust/tasks/derive.rs new file mode 100644 index 0000000000..5e61ee99a3 --- /dev/null +++ b/rust/perspective-viewer/src/rust/tasks/derive.rs @@ -0,0 +1,295 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Create a derived `Table` from a panel's view, and release a panel back to +//! the `Table` its own derived one reads. + +use perspective_client::config::ViewConfig; +use perspective_client::{Client, TableData, TableInitOptions}; +use perspective_js::utils::*; + +use crate::config::{DerivedTableConfig, OptionalUpdate, TableRef, ViewerConfigUpdate}; +use crate::derived::{DerivedSource, DerivedTable, suggest_name}; +use crate::presentation::Presentation; +use crate::queries::fetch_hosted_tables; +use crate::renderer::Renderer; +use crate::session::{MissingTable, Session, probe_table}; +use crate::tasks::{RestoreErrors, RestoreMode, restore_panel}; +use crate::utils::PtrEqRc; +use crate::workspace::Workspace; + +/// Whether this panel's `Client` can derive a `Table` from a `View`. +pub fn can_derive(session: &Session) -> bool { + session + .metadata() + .get_features() + .is_some_and(|x| x.view_derivations) +} + +/// Rewrite an inline derived-table definition in `update.table` to a concrete +/// hosted name, rebinding to a structurally identical live derivation where +/// one exists and building (or pending, per `missing`) the rest. +pub async fn resolve_table_ref( + workspace: &Workspace, + update: &mut ViewerConfigUpdate, + missing: MissingTable, +) -> ApiResult<()> { + if !matches!(&update.table, OptionalUpdate::Update(TableRef::Derived(_))) { + return Ok(()); + } + + let OptionalUpdate::Update(table_ref) = + std::mem::replace(&mut update.table, OptionalUpdate::Missing) + else { + return Ok(()); + }; + + let mut levels = vec![]; + let mut cursor = table_ref; + let base = loop { + match cursor { + TableRef::Name(name) => break name, + TableRef::Derived(config) => { + levels.push(config.view_config); + cursor = config.table; + }, + } + }; + + let mut pending = workspace.derived_pending(); + let mut taken = fetch_hosted_tables(workspace) + .await + .into_iter() + .flat_map(|(_, tables)| tables) + .collect::>(); + + taken.extend(pending.keys().cloned()); + let mut parent = base; + for view_config in levels.into_iter().rev() { + let config = DerivedTableConfig { + table: parent.clone(), + view_config, + }; + + if let Some(existing) = workspace.derived_find_structural(&config) { + parent = existing; + continue; + } + + if let Some(existing) = pending.iter().find(|(_, x)| **x == config) { + parent = existing.0.clone(); + continue; + } + + let name = suggest_name( + &taken, + &PtrEqRc::new(config.view_config.clone()), + &Some(config.table.clone()), + ); + + taken.insert(name.clone()); + let built = match workspace + .resolve_client_for_table(&config.table, None) + .await + { + Some(client) => { + build_derived( + &client, + workspace, + &name, + &config.table, + &config.view_config, + ) + .await? + }, + None => false, + }; + + if !built { + if matches!(missing, MissingTable::Error) { + return Err(ApiError::new(format!("Unknown table \"{}\"", config.table))); + } + + pending.insert(name.clone(), config); + } + + parent = name; + } + + workspace.set_derived_pending(pending); + update.table = OptionalUpdate::Update(TableRef::Name(parent)); + Ok(()) +} + +/// Build a derived `Table` named `name` from `parent`'s `view_config`, and +/// register it on `workspace`. +pub async fn build_derived( + client: &Client, + workspace: &Workspace, + name: &str, + parent: &str, + view_config: &ViewConfig, +) -> ApiResult { + let Some(parent_table) = probe_table(client, parent, MissingTable::Pend).await? else { + return Ok(false); + }; + + let view = parent_table.view(Some(view_config.clone().into())).await?; + let derived = match client + .table(TableData::View(view.clone()), TableInitOptions { + name: Some(name.to_owned()), + ..TableInitOptions::default() + }) + .await + { + Ok(derived) => derived, + Err(err) => { + view.delete().await?; + return Err(err.into()); + }, + }; + + workspace.register_derived( + name.to_owned(), + DerivedTable::new( + parent.to_owned(), + view_config.clone(), + DerivedSource::Restored(view), + derived, + ), + ); + + Ok(true) +} + +/// Promote the panel's current `View` to a derived `Table` named `name`, +/// consuming that `View` and rebinding the panel to the result. +pub async fn create_derived( + session: &Session, + renderer: &Renderer, + presentation: &Presentation, + workspace: &Workspace, + name: String, +) -> ApiResult<()> { + if !can_derive(session) { + return Err("This data source cannot derive a Table from a View".into()); + } + + let client = session.get_client().ok_or("`load()` has not been called")?; + let table = session.get_table().ok_or("`load()` has not been called")?; + let parent = table.get_name().to_owned(); + let plugin = renderer.metadata().name.clone(); + let sub = session + .take_view_sub() + .ok_or("Panel has no View to derive from")?; + + let view_config = (*sub.get_view_config()).clone(); + let derived = match client + .table(TableData::View(sub.get_view().clone()), TableInitOptions { + name: Some(name.clone()), + ..TableInitOptions::default() + }) + .await + { + Ok(derived) => derived, + Err(err) => { + session.put_view_sub(sub); + return Err(err.into()); + }, + }; + + workspace.register_derived( + name.clone(), + DerivedTable::new(parent, view_config, DerivedSource::Panel(sub), derived), + ); + + let update = ViewerConfigUpdate { + table: OptionalUpdate::Update(name.clone().into()), + plugin: OptionalUpdate::Update(plugin), + ..ViewerConfigUpdate::default() + }; + + let restored = restore_panel( + session, + renderer, + presentation, + workspace, + RestoreMode::Existing { active: true }, + update, + RestoreErrors::Publish, + MissingTable::Error, + None, + ) + .await; + + if restored.is_err() + && let Some(derived) = workspace.take_derived(&name) + { + derived.delete().await?; + } + + restored +} + +/// Rebind the panel to the `Table` its derived one reads, deleting that +/// derivation when `delete` and nothing else consumes it. +pub async fn release_derived( + session: &Session, + renderer: &Renderer, + presentation: &Presentation, + workspace: &Workspace, + delete: bool, +) -> ApiResult<()> { + let table = session.get_table().ok_or("Panel has no table")?; + let name = table.get_name().to_owned(); + let config = workspace + .derived_config(&name) + .ok_or("Panel is not bound to a derived table")?; + + let update = ViewerConfigUpdate { + table: OptionalUpdate::Update(config.table.clone().into()), + view_config: config.view_config.into(), + ..ViewerConfigUpdate::default() + }; + + restore_panel( + session, + renderer, + presentation, + workspace, + RestoreMode::Existing { active: true }, + update, + RestoreErrors::Publish, + MissingTable::Error, + None, + ) + .await?; + + if delete { + delete_derived(workspace, &name).await?; + } + + Ok(()) +} + +/// Delete a derived `Table` that nothing consumes. +pub async fn delete_derived(workspace: &Workspace, name: &str) -> ApiResult<()> { + let consumers = workspace.derived_consumers(name); + if consumers > 0 { + return Err(format!("`{name}` is still read by {consumers} panels or tables").into()); + } + + match workspace.take_derived(name) { + Some(derived) => derived.delete().await, + None => Err(format!("`{name}` is not a derived table").into()), + } +} diff --git a/rust/perspective-viewer/src/rust/tasks/mod.rs b/rust/perspective-viewer/src/rust/tasks/mod.rs index 07f9d47285..4e260dcff2 100644 --- a/rust/perspective-viewer/src/rust/tasks/mod.rs +++ b/rust/perspective-viewer/src/rust/tasks/mod.rs @@ -25,6 +25,7 @@ mod apply_global_filters; mod auto_pause; mod copy_export; mod create_panel; +mod derive; mod dismiss_render_warning; mod edit_expression; mod edit_window; @@ -33,6 +34,7 @@ mod pipeline; mod presize_panels; mod reset_all; mod resize_observer; +mod restore_derived; mod restore_panel; mod send_column_config; mod send_plugin_config; @@ -57,6 +59,7 @@ pub use self::apply_global_filters::*; pub use self::auto_pause::*; pub use self::copy_export::*; pub(crate) use self::create_panel::*; +pub use self::derive::*; pub use self::dismiss_render_warning::*; pub use self::edit_expression::*; pub use self::edit_window::*; @@ -65,6 +68,7 @@ pub use self::pipeline::*; pub use self::presize_panels::*; pub use self::reset_all::*; pub use self::resize_observer::*; +pub use self::restore_derived::*; pub(crate) use self::restore_panel::*; pub use self::send_column_config::*; pub use self::send_plugin_config::*; diff --git a/rust/perspective-viewer/src/rust/tasks/restore_derived.rs b/rust/perspective-viewer/src/rust/tasks/restore_derived.rs new file mode 100644 index 0000000000..9de03a0720 --- /dev/null +++ b/rust/perspective-viewer/src/rust/tasks/restore_derived.rs @@ -0,0 +1,110 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Reconcile the workspace's derived `Table`s with a restored +//! `derived_tables` map. + +use std::collections::{BTreeMap, BTreeSet}; + +use perspective_js::utils::*; + +use crate::config::DerivedTableConfig; +use crate::derived::topo_order; +use crate::tasks::build_derived; +use crate::workspace::Workspace; + +/// Build every derivation in `configs` that is not already reusable, +/// parents-first, deferring any whose parent is not yet hosted. +pub async fn apply_derived_tables( + workspace: &Workspace, + configs: BTreeMap, +) -> ApiResult<()> { + let order = topo_order(&configs).map_err(ApiError::new)?; + let reuse = workspace.derived_reusable(&order, &configs); + let mut deferred = BTreeMap::new(); + for name in &order { + let config = &configs[name]; + if reuse.contains(name) { + continue; + } + + if deferred.contains_key(&config.table) { + deferred.insert(name.clone(), config.clone()); + continue; + } + + if workspace.is_derived(name) { + let consumers = workspace.derived_consumers(name); + if consumers > 0 { + return Err(ApiError::new(format!( + "Cannot redefine `{name}` while {consumers} panels or tables read it" + ))); + } + + if let Some(stale) = workspace.take_derived(name) { + stale.delete().await?; + } + } + + let Some(client) = workspace + .resolve_client_for_table(&config.table, None) + .await + else { + deferred.insert(name.clone(), config.clone()); + continue; + }; + + if !build_derived(&client, workspace, name, &config.table, &config.view_config).await? { + deferred.insert(name.clone(), config.clone()); + } + } + + workspace.set_derived_pending(deferred); + Ok(()) +} + +/// Delete every registered derivation absent from `keep`, children first. +pub async fn prune_derived(workspace: &Workspace, keep: &BTreeSet) -> ApiResult<()> { + let mut doomed = workspace + .derived_names() + .into_iter() + .filter(|name| !keep.contains(name)) + .collect::>(); + + doomed.sort_by_key(|name| std::cmp::Reverse(workspace.derived_ancestors(name).len())); + for name in doomed { + if workspace.derived_consumers(&name) > 0 { + tracing::warn!("Keeping derived table \"{name}\", which is still read"); + continue; + } + + if let Some(derived) = workspace.take_derived(&name) + && let Err(err) = derived.delete().await + { + tracing::warn!("Failed to delete derived table \"{name}\": {err}"); + } + } + + Ok(()) +} + +/// Retry the derivations whose parent was not hosted when they were restored. +pub async fn resume_derived(workspace: &Workspace) -> ApiResult<()> { + let pending = workspace.derived_pending(); + if pending.is_empty() { + return Ok(()); + } + + let mut configs = workspace.derived_configs(); + configs.extend(pending); + apply_derived_tables(workspace, configs).await +} diff --git a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs index ffd1680df3..7f88697829 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs @@ -90,13 +90,12 @@ async fn plan_binding( rebind: Rebind, missing: MissingTable, ) -> ApiResult> { - let OptionalUpdate::Update(name) = table else { + let OptionalUpdate::Update(table) = table else { return Ok(Some(BindPlan::Keep)); }; - let same = session - .get_table() - .is_some_and(|t| t.get_name() == name.as_str()); + let name = table.name()?; + let same = session.get_table().is_some_and(|t| t.get_name() == name); if same && !session.is_errored() && renderer.failure().is_none() { return Ok(Some(BindPlan::Keep)); @@ -109,7 +108,7 @@ async fn plan_binding( if table.is_none() && rebind == Rebind::Complete - && session.pending_table().as_deref() == Some(name.as_str()) + && session.pending_table().as_deref() == Some(name) { return Ok(None); } @@ -123,7 +122,7 @@ async fn plan_binding( }, None => BindPlan::Pend { client, - name: name.clone(), + name: name.to_owned(), reset, }, })) @@ -191,13 +190,14 @@ pub(crate) async fn restore_panel_step( mode: RestoreMode, rebind: Rebind, origin: RunOrigin, - update: ViewerConfigUpdate, + mut update: ViewerConfigUpdate, errors: RestoreErrors, missing: MissingTable, ) -> ApiResult<()> { let active = matches!(mode, RestoreMode::Existing { active: true }); let fresh = matches!(mode, RestoreMode::Fresh); + crate::tasks::resolve_table_ref(workspace, &mut update, missing).await?; renderer.check_plugin_update(&update.plugin)?; let Some(plan) = plan_binding( ctx, diff --git a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs index 44a06eed62..98b56a671c 100644 --- a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs @@ -34,6 +34,7 @@ extern "C" { pub type SyncUpdatePanelsResult = ( Vec<(PanelId, Session, Renderer, ViewerConfigUpdate)>, std::vec::Vec>, + Option>, ); /// Synchronously apply a `WorkspaceConfigUpdate` to the element's models @@ -47,6 +48,7 @@ pub fn sync_update_panels( active, layout, panels, + derived_tables, global_filters, masters, palette, @@ -222,5 +224,5 @@ pub fn sync_update_panels( OptionalUpdate::Update(filters) => this.workspace.set_global_filters(filters), } - Ok((contents, eject_tasks)) + Ok((contents, eject_tasks, derived_tables)) } diff --git a/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs b/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs index 3b8ca11ae8..ceac419125 100644 --- a/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs +++ b/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs @@ -86,6 +86,10 @@ pub(crate) async fn sweep_table_bindings( workspace: &Workspace, presentation: &Presentation, ) -> ApiResult<()> { + if let Err(err) = super::resume_derived(workspace).await { + tracing::warn!("Failed to resume derived tables: {err}"); + } + for panel in workspace.panels() { if let Some(name) = panel.session.get_table().map(|t| t.get_name().to_owned()) && let Some(client) = panel.session.get_client() @@ -104,6 +108,7 @@ pub(crate) async fn sweep_table_bindings( } } + workspace.hosted_tables_changed().emit(()); Ok(()) } @@ -158,7 +163,7 @@ async fn bind_pending( }; let update = ViewerConfigUpdate { - table: OptionalUpdate::Update(name), + table: OptionalUpdate::Update(name.into()), plugin_config: OptionalUpdate::Update(renderer.committed_plugin_config()), columns_config: OptionalUpdate::Update(renderer.committed_columns_configs()), ..ViewerConfigUpdate::default() diff --git a/rust/perspective-viewer/src/rust/utils/browser/mod.rs b/rust/perspective-viewer/src/rust/utils/browser/mod.rs index f0bba3a979..978167a611 100644 --- a/rust/perspective-viewer/src/rust/utils/browser/mod.rs +++ b/rust/perspective-viewer/src/rust/utils/browser/mod.rs @@ -15,11 +15,13 @@ mod blob; mod download; mod dragdrop; +mod mouse; mod request_animation_frame; mod selection; pub use self::blob::*; pub use self::download::*; pub use self::dragdrop::*; +pub use self::mouse::*; pub use self::request_animation_frame::*; pub use self::selection::*; diff --git a/rust/perspective-viewer/src/rust/utils/browser/mouse.rs b/rust/perspective-viewer/src/rust/utils/browser/mouse.rs new file mode 100644 index 0000000000..d707cd7a4b --- /dev/null +++ b/rust/perspective-viewer/src/rust/utils/browser/mouse.rs @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use web_sys::MouseEvent; +use yew::Callback; + +/// Gate a `mousedown` callback to the primary button, so a right-click (which +/// opens the viewer context menu) never also triggers the control under it. +pub fn left_click(callback: Callback) -> Callback { + Callback::from(move |event: MouseEvent| { + if event.button() == 0 { + callback.emit(event); + } + }) +} diff --git a/rust/perspective-viewer/src/rust/workspace.rs b/rust/perspective-viewer/src/rust/workspace.rs index 24449de9cd..410c4e5a16 100644 --- a/rust/perspective-viewer/src/rust/workspace.rs +++ b/rust/perspective-viewer/src/rust/workspace.rs @@ -13,11 +13,14 @@ //! The multi-panel model backing a single ``. use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; use std::rc::Rc; use perspective_client::Client; use perspective_client::config::Filter; +use crate::config::{DerivedTableConfig, TableRef}; +use crate::derived::{DerivedRegistry, DerivedTable}; use crate::renderer::Renderer; use crate::session::{OverlayClause, Session}; use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned}; @@ -324,9 +327,17 @@ struct WorkspaceData { filters: GlobalFilterSet, filters_changed: Rc>, + /// The set of hosted `Table`s changed: a client pushed a hosted-tables + /// update (observed post-sweep), or a derived `Table` was registered or + /// removed. + hosted_tables_changed: Rc>, + /// The loaded-clients registry + default designation. clients: ClientRegistry, + /// The live derived `Table`s this workspace owns. + derived: DerivedRegistry, + /// Monotonic counter backing [`Workspace::generate_id`]. next_id: usize, @@ -372,7 +383,9 @@ impl Workspace { active: None, filters: GlobalFilterSet::default(), filters_changed: Rc::new(PubSub::default()), + hosted_tables_changed: Rc::new(PubSub::default()), clients: ClientRegistry::default(), + derived: DerivedRegistry::default(), next_id: 0, reservation: Reservation::Idle, pending_layout: None, @@ -505,6 +518,10 @@ impl Workspace { self.0.borrow().filters_changed.clone() } + pub fn hosted_tables_changed(&self) -> Rc> { + self.0.borrow().hosted_tables_changed.clone() + } + /// Run `f` against the filter set inside the borrow, then emit /// `filters_changed` — outside it — iff the effective set changed. /// Returns `f`'s auxiliary payload. @@ -1037,4 +1054,113 @@ impl Workspace { None => current.is_none().then(|| self.default_client()).flatten(), } } + + /// Every derivation's persisted definition, for `saveWorkspace`. + pub fn derived_configs(&self) -> BTreeMap { + self.0.borrow().derived.configs() + } + + pub fn is_derived(&self, name: &str) -> bool { + self.0.borrow().derived.contains(name) + } + + pub fn derived_names(&self) -> Vec { + self.0.borrow().derived.names() + } + + /// The recorded definition of a derived `Table`. + pub fn derived_config(&self, name: &str) -> Option { + self.0.borrow().derived.get(name).map(|x| x.config()) + } + + /// The name of a live derivation structurally equal to `config`. + pub fn derived_find_structural(&self, config: &DerivedTableConfig) -> Option { + self.0.borrow().derived.find_structural(config) + } + + /// `name`'s definition as a self-contained [`TableRef`], recursively + /// inlining each derived ancestor down to a concrete table name. + pub fn inline_table_ref(&self, name: &str) -> TableRef { + let ancestors = self.derived_ancestors(name); + let Some(base) = ancestors.last() else { + return TableRef::Name(name.to_owned()); + }; + + let mut out = TableRef::Name(base.clone()); + let chain = std::iter::once(name) + .chain(ancestors[..ancestors.len() - 1].iter().map(String::as_str)) + .collect::>(); + + for level in chain.into_iter().rev() { + match self.derived_config(level) { + Some(config) => { + out = TableRef::Derived(Box::new(DerivedTableConfig { + table: out, + view_config: config.view_config, + })); + }, + None => return TableRef::Name(name.to_owned()), + } + } + + out + } + + /// The `Table` names `name` derives from, nearest first. + pub fn derived_ancestors(&self, name: &str) -> Vec { + self.0.borrow().derived.ancestors(name) + } + + /// How many panels and derivations read `name`. + pub fn derived_consumers(&self, name: &str) -> usize { + let panels = self.panels(); + let data = self.0.borrow(); + let bound = panels + .iter() + .filter(|panel| { + panel + .session + .get_table() + .map(|table| table.get_name().to_owned()) + .or_else(|| panel.session.pending_table()) + .is_some_and(|bound| bound == name) + }) + .count(); + + bound + data.derived.children(name).len() + } + + pub fn register_derived(&self, name: String, table: DerivedTable) { + self.0.borrow_mut().derived.insert(name, table); + self.hosted_tables_changed().emit(()); + } + + /// Take a derivation out of the registry so the caller can await its + /// deletion outside the borrow. + pub fn take_derived(&self, name: &str) -> Option { + let derived = self.0.borrow_mut().derived.remove(name); + if derived.is_some() { + self.hosted_tables_changed().emit(()); + } + + derived + } + + /// Which of `order` may be reused rather than rebuilt. + pub fn derived_reusable( + &self, + order: &[String], + configs: &BTreeMap, + ) -> std::collections::BTreeSet { + crate::derived::reusable(order, configs, &self.0.borrow().derived) + } + + /// The definitions waiting on a parent that is not yet hosted. + pub fn derived_pending(&self) -> BTreeMap { + self.0.borrow().derived.pending() + } + + pub fn set_derived_pending(&self, pending: BTreeMap) { + self.0.borrow_mut().derived.set_pending(pending); + } } diff --git a/rust/perspective-viewer/src/svg/derived.svg b/rust/perspective-viewer/src/svg/derived.svg new file mode 100644 index 0000000000..2308ae3564 --- /dev/null +++ b/rust/perspective-viewer/src/svg/derived.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/rust/perspective-viewer/src/themes/icons.css b/rust/perspective-viewer/src/themes/icons.css index 5097d8ce78..a22c3972dd 100644 --- a/rust/perspective-viewer/src/themes/icons.css +++ b/rust/perspective-viewer/src/themes/icons.css @@ -63,6 +63,7 @@ perspective-viewer-tab { --psp-icon--reset--mask-image: url("../svg/revert-icon.svg"); --psp-icon--copy--mask-image: url("../svg/duplicate-icon.svg"); --psp-icon--new-panel--mask-image: url("../svg/new-panel-icon.svg"); + --psp-derived--mask-image: url("../svg/derived.svg"); --psp-icon--theme--mask-image: url("../svg/theme-icon.svg"); --psp-icon--drawer-tab--mask-image: url(../svg/drawer-tab.svg); diff --git a/rust/perspective-viewer/src/themes/intl.css b/rust/perspective-viewer/src/themes/intl.css index 7ded20d041..19a3044c15 100644 --- a/rust/perspective-viewer/src/themes/intl.css +++ b/rust/perspective-viewer/src/themes/intl.css @@ -208,4 +208,12 @@ perspective-viewer { --psp-label--legend-opacity--content: "Opacity"; --psp-label--tooltip-max-column-px--content: "Max column width"; --psp-label--tooltip-opacity--content: "Opacity"; + + /* Derived tables */ + --psp-label--derive-table--content: "Derive table"; + --psp-label--derive-table-title--content: "Derive a new table from this view"; + --psp-label--derived-snapshot--content: "snapshot"; + --psp-label--derived-snapshot-title--content: "This data source does not stream - this table's data is fixed at the point it was derived."; + --psp-label--derived-release-title--content: "Back to the parent table"; + --psp-label--derived-release-delete-title--content: "Back to the parent table - shift-click to delete this one"; } diff --git a/rust/perspective-viewer/src/themes/intl/ar.css b/rust/perspective-viewer/src/themes/intl/ar.css index 6365117a7b..29e56bff88 100644 --- a/rust/perspective-viewer/src/themes/intl/ar.css +++ b/rust/perspective-viewer/src/themes/intl/ar.css @@ -197,4 +197,12 @@ perspective-viewer { --psp-label--legend-opacity--content: "العتامة"; --psp-label--tooltip-max-column-px--content: "أقصى عرض للعمود"; --psp-label--tooltip-opacity--content: "العتامة"; + + /* Derived tables */ + --psp-label--derive-table--content: "اشتقاق جدول"; + --psp-label--derive-table-title--content: "اشتقاق جدول جديد من هذا العرض"; + --psp-label--derived-snapshot--content: "لقطة"; + --psp-label--derived-snapshot-title--content: "مصدر البيانات هذا لا يبث التحديثات - بيانات هذا الجدول ثابتة عند لحظة اشتقاقه."; + --psp-label--derived-release-title--content: "العودة إلى الجدول الأصل"; + --psp-label--derived-release-delete-title--content: "العودة إلى الجدول الأصل - انقر مع الضغط على Shift لحذفه"; } diff --git a/rust/perspective-viewer/src/themes/intl/de.css b/rust/perspective-viewer/src/themes/intl/de.css index d58cd2f2e3..e872c874ea 100644 --- a/rust/perspective-viewer/src/themes/intl/de.css +++ b/rust/perspective-viewer/src/themes/intl/de.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "Spalte"; --psp-label--group-format--content: "Format"; --psp-label--column-size-override--content: "Spaltenbreite"; + + /* Derived tables */ + --psp-label--derive-table--content: "Tabelle ableiten"; + --psp-label--derive-table-title--content: "Eine neue Tabelle aus dieser Ansicht ableiten"; + --psp-label--derived-snapshot--content: "Snapshot"; + --psp-label--derived-snapshot-title--content: "Diese Datenquelle streamt nicht - die Daten dieser Tabelle sind auf den Stand ihrer Ableitung eingefroren."; + --psp-label--derived-release-title--content: "Zurück zur übergeordneten Tabelle"; + --psp-label--derived-release-delete-title--content: "Zurück zur übergeordneten Tabelle - Umschalt-Klick löscht sie"; } diff --git a/rust/perspective-viewer/src/themes/intl/es.css b/rust/perspective-viewer/src/themes/intl/es.css index 496eed3258..7ed9245c62 100644 --- a/rust/perspective-viewer/src/themes/intl/es.css +++ b/rust/perspective-viewer/src/themes/intl/es.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "Columna"; --psp-label--group-format--content: "Formato"; --psp-label--column-size-override--content: "Ancho de columna"; + + /* Derived tables */ + --psp-label--derive-table--content: "Derivar tabla"; + --psp-label--derive-table-title--content: "Derivar una nueva tabla a partir de esta vista"; + --psp-label--derived-snapshot--content: "instantánea"; + --psp-label--derived-snapshot-title--content: "Esta fuente de datos no transmite en continuo - los datos de esta tabla quedan fijados en el momento de su derivación."; + --psp-label--derived-release-title--content: "Volver a la tabla padre"; + --psp-label--derived-release-delete-title--content: "Volver a la tabla padre - mayús+clic para eliminarla"; } diff --git a/rust/perspective-viewer/src/themes/intl/fr.css b/rust/perspective-viewer/src/themes/intl/fr.css index 15d2be4339..509c544658 100644 --- a/rust/perspective-viewer/src/themes/intl/fr.css +++ b/rust/perspective-viewer/src/themes/intl/fr.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "Colonne"; --psp-label--group-format--content: "Format"; --psp-label--column-size-override--content: "Largeur de colonne"; + + /* Derived tables */ + --psp-label--derive-table--content: "Dériver une table"; + --psp-label--derive-table-title--content: "Dériver une nouvelle table à partir de cette vue"; + --psp-label--derived-snapshot--content: "instantané"; + --psp-label--derived-snapshot-title--content: "Cette source de données ne diffuse pas en continu - les données de cette table sont figées au moment de sa dérivation."; + --psp-label--derived-release-title--content: "Revenir à la table parente"; + --psp-label--derived-release-delete-title--content: "Revenir à la table parente - maj-clic pour la supprimer"; } diff --git a/rust/perspective-viewer/src/themes/intl/ja.css b/rust/perspective-viewer/src/themes/intl/ja.css index 9d98ec8ccf..930b3117c1 100644 --- a/rust/perspective-viewer/src/themes/intl/ja.css +++ b/rust/perspective-viewer/src/themes/intl/ja.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "列"; --psp-label--group-format--content: "書式"; --psp-label--column-size-override--content: "列の幅"; + + /* Derived tables */ + --psp-label--derive-table--content: "テーブルを派生"; + --psp-label--derive-table-title--content: "このビューから新しいテーブルを派生します"; + --psp-label--derived-snapshot--content: "スナップショット"; + --psp-label--derived-snapshot-title--content: "このデータソースはストリーミングしません - このテーブルのデータは派生した時点で固定されます。"; + --psp-label--derived-release-title--content: "親テーブルに戻る"; + --psp-label--derived-release-delete-title--content: "親テーブルに戻る - Shiftクリックで削除"; } diff --git a/rust/perspective-viewer/src/themes/intl/pt.css b/rust/perspective-viewer/src/themes/intl/pt.css index ba4a817c04..b6afe0c8c3 100644 --- a/rust/perspective-viewer/src/themes/intl/pt.css +++ b/rust/perspective-viewer/src/themes/intl/pt.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "Coluna"; --psp-label--group-format--content: "Formato"; --psp-label--column-size-override--content: "Largura da coluna"; + + /* Derived tables */ + --psp-label--derive-table--content: "Derivar tabela"; + --psp-label--derive-table-title--content: "Derivar uma nova tabela a partir desta visualização"; + --psp-label--derived-snapshot--content: "instantâneo"; + --psp-label--derived-snapshot-title--content: "Esta fonte de dados não transmite em contínuo - os dados desta tabela ficam fixados no momento da derivação."; + --psp-label--derived-release-title--content: "Voltar à tabela pai"; + --psp-label--derived-release-delete-title--content: "Voltar à tabela pai - shift+clique para excluí-la"; } diff --git a/rust/perspective-viewer/src/themes/intl/zh.css b/rust/perspective-viewer/src/themes/intl/zh.css index 8026ff65e2..13dfc4fff7 100644 --- a/rust/perspective-viewer/src/themes/intl/zh.css +++ b/rust/perspective-viewer/src/themes/intl/zh.css @@ -206,4 +206,12 @@ perspective-viewer { --psp-label--group-column--content: "列"; --psp-label--group-format--content: "格式"; --psp-label--column-size-override--content: "列宽"; + + /* Derived tables */ + --psp-label--derive-table--content: "派生表"; + --psp-label--derive-table-title--content: "从此视图派生新表"; + --psp-label--derived-snapshot--content: "快照"; + --psp-label--derived-snapshot-title--content: "此数据源不支持流式更新 - 该表的数据固定在派生时的状态。"; + --psp-label--derived-release-title--content: "返回父表"; + --psp-label--derived-release-delete-title--content: "返回父表 - Shift+点击可将其删除"; } diff --git a/rust/perspective-viewer/test/js/column_settings/editor_save_flash.spec.ts b/rust/perspective-viewer/test/js/column_settings/editor_save_flash.spec.ts new file mode 100644 index 0000000000..cf50db462c --- /dev/null +++ b/rust/perspective-viewer/test/js/column_settings/editor_save_flash.spec.ts @@ -0,0 +1,105 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { PageView, test, expect } from "../helpers.ts"; +import type { Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore-debug.html"); + + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function sampleTextarea(page: Page, selector: string) { + await page.evaluate((selector) => { + const root = document.querySelector("perspective-viewer")!.shadowRoot!; + const area = root.querySelector(selector) as HTMLTextAreaElement; + const samples: string[] = []; + window["__EDITOR_SAMPLES__"] = samples; + let frames = 0; + const tick = () => { + samples.push(area.value); + if (++frames < 180) { + requestAnimationFrame(tick); + } + }; + + requestAnimationFrame(tick); + }, selector); +} + +async function readSamples(page: Page): Promise { + return await page.evaluate(() => window["__EDITOR_SAMPLES__"]); +} + +test.describe("Editor save does not flash the previous value", () => { + test("expression editor holds the saved text across the commit", async ({ + page, + }) => { + const view = new PageView(page); + await page.evaluate(async () => { + const elem = document.querySelector("perspective-viewer")!; + // @ts-ignore + await elem.restore({ settings: true, expressions: { abc: "'a'" } }); + }); + + const column = + await view.settingsPanel.inactiveColumns.getColumnByName("abc"); + + await column.editBtn.click(); + const sidebar = view.columnSettingsSidebar; + await sidebar.container.waitFor({ state: "visible" }); + const textarea = sidebar.attributesTab.expressionEditor.textarea; + await textarea.fill("'b'"); + await expect(textarea).toHaveValue("'b'"); + await sampleTextarea(page, "#column_settings_sidebar textarea"); + await sidebar.attributesTab.saveBtn.click(); + await page.waitForTimeout(2000); + + const samples = await readSamples(page); + expect(samples.length).toBeGreaterThan(20); + expect(samples.at(-1)).toBe("'b'"); + expect([...new Set(samples)]).toEqual(["'b'"]); + }); + + test("debug panel holds the applied text across the commit", async ({ + page, + }) => { + const view = new PageView(page); + await page.evaluate(async () => { + const elem = document.querySelector("perspective-viewer")!; + // @ts-ignore + await elem.restore({ settings: true, title: "before" }); + }); + + await view.settingsPanel.container.locator("#debug_tabbar_tab").click(); + + const textarea = view.container.locator("#debug-panel-editor textarea"); + await expect(textarea).not.toHaveValue(""); + const before = await textarea.inputValue(); + const next = before.replace('"before"', '"after"'); + expect(next).not.toBe(before); + await textarea.fill(next); + await sampleTextarea(page, "#debug-panel-editor textarea"); + await view.container.locator("#debug-panel-apply").click(); + await page.waitForTimeout(2000); + + const samples = await readSamples(page); + expect(samples.length).toBeGreaterThan(20); + expect(samples.at(-1)).toBe(next); + expect(samples.filter((x) => x === before)).toEqual([]); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/back_delete.spec.ts b/rust/perspective-viewer/test/js/derived/back_delete.spec.ts new file mode 100644 index 0000000000..1135004887 --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/back_delete.spec.ts @@ -0,0 +1,127 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import type { Page } from "@playwright/test"; +import { TABLE, hostedTables, restoreWorkspace, ready } from "./helpers.ts"; + +test.beforeEach(ready); + +const ONE_PANEL = { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + active: "one", + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, +}; + +const TWO_PANELS = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + ], + }, + panels: { + one: { table: "Region Totals", plugin: "Debug" }, + two: { table: "Region Totals", plugin: "Debug" }, + }, + active: "one", + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, +}; + +function release(page: Page) { + return page + .locator("perspective-viewer") + .locator("#derived_stack .derived_level.is-own .derived_release"); +} + +async function boundTable(page: Page) { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return (await viewer.save()).table; + }); +} + +test.describe("back to parent and delete", () => { + test("click rebinds to the parent and keeps the table", async ({ + page, + }) => { + await restoreWorkspace(page, ONE_PANEL); + await release(page).click(); + expect(await boundTable(page)).toBe(TABLE); + expect(await hostedTables(page)).toContain("Region Totals"); + }); + + test("click restores the config the table was created with", async ({ + page, + }) => { + await restoreWorkspace(page, ONE_PANEL); + await release(page).click(); + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return await viewer.save(); + }); + + expect(saved.group_by).toEqual(["Region"]); + }); + + test("shift+click rebinds and deletes", async ({ page }) => { + await restoreWorkspace(page, ONE_PANEL); + await release(page).click({ modifiers: ["Shift"] }); + expect(await boundTable(page)).toBe(TABLE); + expect(await hostedTables(page)).not.toContain("Region Totals"); + }); + + test("the shift affordance is offered to a sole consumer", async ({ + page, + }) => { + await restoreWorkspace(page, ONE_PANEL); + await expect(release(page)).toHaveClass(/shift-alt-icon/); + }); + + test("the shift affordance is withheld at two consumers", async ({ + page, + }) => { + await restoreWorkspace(page, TWO_PANELS); + await expect(release(page).first()).not.toHaveClass(/shift-alt-icon/); + }); + + test("shift+click keeps the table when another panel reads it", async ({ + page, + }) => { + await restoreWorkspace(page, TWO_PANELS); + await release(page) + .first() + .click({ modifiers: ["Shift"] }); + expect(await hostedTables(page)).toContain("Region Totals"); + }); + + test("the consumer count shows when shared", async ({ page }) => { + await restoreWorkspace(page, TWO_PANELS); + await expect( + page + .locator("perspective-viewer") + .locator( + "#derived_stack .derived_level.is-own .derived_consumers", + ) + .first(), + ).toHaveText("2"); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/create.spec.ts b/rust/perspective-viewer/test/js/derived/create.spec.ts new file mode 100644 index 0000000000..57ee5c1bd1 --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/create.spec.ts @@ -0,0 +1,163 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import type { Page } from "@playwright/test"; +import { TABLE, hostedTables, restoreWorkspace, ready } from "./helpers.ts"; + +test.beforeEach(ready); + +function stack(page: Page) { + return page.locator("perspective-viewer").locator("#derived_stack"); +} + +async function panel(page: Page, extra: Record = {}) { + await restoreWorkspace(page, { + panels: { one: { table: TABLE, plugin: "Debug", ...extra } }, + active: "one", + }); +} + +async function create(page: Page) { + await stack(page).locator(".derived_create").click(); +} + +async function boundTable(page: Page) { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return (await viewer.save()).table; + }); +} + +test.describe("derive a table from the settings sidebar", () => { + test("the group is absent with no pivots and no derivation", async ({ + page, + }) => { + await panel(page); + await expect(stack(page)).toHaveCount(0); + }); + + test("the create button appears once group_by is set", async ({ page }) => { + await panel(page, { group_by: ["Region"] }); + await expect(stack(page).locator(".derived_create")).toHaveCount(1); + }); + + test("the create button appears once split_by is set", async ({ page }) => { + await panel(page, { split_by: ["Region"] }); + await expect(stack(page).locator(".derived_create")).toHaveCount(1); + }); + + test("the create button disappears when the pivots are cleared", async ({ + page, + }) => { + await panel(page, { group_by: ["Region"] }); + await expect(stack(page).locator(".derived_create")).toHaveCount(1); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ group_by: [] }); + // @ts-ignore + await viewer.flush(); + }); + + await expect(stack(page)).toHaveCount(0); + }); + + test("the group renders below the inactive columns", async ({ page }) => { + await panel(page, { group_by: ["Region"] }); + const order = await page.evaluate(() => { + const root = document + .querySelector("perspective-viewer")! + .shadowRoot!.querySelector("#settings_panel")!; + + return Array.from(root.children).map((x) => x.id); + }); + + expect(order.at(-1)).toBe("derived_stack"); + }); + + test("creates under a generated name and rebinds the panel", async ({ + page, + }) => { + await panel(page, { group_by: ["Region"] }); + await create(page); + await expect( + stack(page).locator(".derived_level.is-own .derived_label"), + ).toHaveText("New Table 1"); + + expect(await hostedTables(page)).toContain("New Table 1"); + expect(await boundTable(page)).toBe("New Table 1"); + }); + + test("a second creation generates the next free name", async ({ page }) => { + await panel(page, { group_by: ["Region"] }); + await create(page); + await expect( + stack(page).locator(".derived_level.is-own .derived_label"), + ).toHaveText("New Table 1"); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ group_by: ["State"] }); + // @ts-ignore + await viewer.flush(); + }); + + await create(page); + await expect( + stack(page).locator(".derived_level.is-own .derived_label"), + ).toHaveText("New Table 2"); + + await expect(stack(page).locator(".derived_level")).toHaveCount(2); + expect(await boundTable(page)).toBe("New Table 2"); + }); + + test("the created table carries the group_by as key columns", async ({ + page, + }) => { + await panel(page, { group_by: ["Region"] }); + await create(page); + await expect( + stack(page).locator(".derived_level.is-own .derived_label"), + ).toHaveText("New Table 1"); + + const columns = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + const table = await viewer.getTable(); + return Object.keys(await table.schema()); + }); + + expect(columns.join(" ")).toContain("Region (Group by 1)"); + }); + + test("a derived panel with no pivots still shows its chain", async ({ + page, + }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + active: "one", + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + await expect( + stack(page).locator(".derived_level.is-own .derived_label"), + ).toHaveText("Region Totals"); + + await expect(stack(page).locator(".derived_create")).toHaveCount(0); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/helpers.ts b/rust/perspective-viewer/test/js/derived/helpers.ts new file mode 100644 index 0000000000..b66379f856 --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/helpers.ts @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Page } from "@playwright/test"; + +export const TABLE = "load-viewer-csv"; + +export async function ready({ page }: { page: Page }) { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +} + +export async function restoreWorkspace( + page: Page, + config: Record, +) { + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restoreWorkspace(config); + // @ts-ignore + await viewer.flush(); + }, config); +} + +export async function restoreWorkspaceError( + page: Page, + config: Record, +): Promise { + return await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer")!; + try { + // @ts-ignore + await viewer.restoreWorkspace(config); + return ""; + } catch (err) { + return `${(err as Error).message ?? err}`; + } + }, config); +} + +export async function saveWorkspace(page: Page): Promise { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return await viewer.saveWorkspace(); + }); +} + +export async function hostedTables(page: Page): Promise { + return await page.evaluate(async () => { + return await window["__TEST_WORKER__"].get_hosted_table_names(); + }); +} + +export async function addTable(page: Page, name: string) { + await page.evaluate(async (name) => { + const worker = window["__TEST_WORKER__"]; + await worker.table( + '[{"Region":"East","Sales":100},{"Region":"West","Sales":200}]', + { + name, + }, + ); + }, name); +} diff --git a/rust/perspective-viewer/test/js/derived/inline_config.spec.ts b/rust/perspective-viewer/test/js/derived/inline_config.spec.ts new file mode 100644 index 0000000000..60df1664ea --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/inline_config.spec.ts @@ -0,0 +1,163 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Page } from "@playwright/test"; + +import { test, expect } from "../helpers.ts"; +import { + TABLE, + hostedTables, + ready, + restoreWorkspace, + restoreWorkspaceError, + saveWorkspace, +} from "./helpers.ts"; + +test.beforeEach(ready); + +const CHAIN = { + panels: { one: { table: "Big Regions", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + "Big Regions": { + table: "Region Totals", + filter: [["Sales", ">", 100]], + }, + }, +}; + +async function save(page: Page): Promise { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + return await viewer.save(); + }); +} + +async function restore(page: Page, config: Record) { + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restore(config); + await viewer.flush(); + }, config); +} + +test.describe("inline derived table configs", () => { + test("save() inlines a derived chain recursively", async ({ page }) => { + await restoreWorkspace(page, CHAIN); + const saved = await save(page); + expect(saved.table.filter).toEqual([["Sales", ">", 100]]); + expect(saved.table.table.group_by).toEqual(["Region"]); + expect(saved.table.table.table).toBe(TABLE); + }); + + test("a non-derived panel's table stays a string", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: TABLE, plugin: "Debug" } }, + }); + + const saved = await save(page); + expect(saved.table).toBe(TABLE); + }); + + test("saveWorkspace() keeps panel tables as strings", async ({ page }) => { + await restoreWorkspace(page, CHAIN); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).toBe("Big Regions"); + expect(saved.derived_tables["Big Regions"].table).toBe("Region Totals"); + }); + + test("an identical inline chain rebinds to the existing tables", async ({ + page, + }) => { + await restoreWorkspace(page, CHAIN); + const before = await hostedTables(page); + await restore(page, { + table: { + table: { table: TABLE, group_by: ["Region"] }, + filter: [["Sales", ">", 100]], + }, + }); + + expect(await hostedTables(page)).toEqual(before); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).toBe("Big Regions"); + }); + + test("a modified inline chain creates a new derived table", async ({ + page, + }) => { + await restoreWorkspace(page, CHAIN); + const before = await hostedTables(page); + await restore(page, { + table: { + table: { table: TABLE, group_by: ["Region"] }, + filter: [["Sales", ">", 500]], + }, + }); + + const after = await hostedTables(page); + expect(after.length).toBe(before.length + 1); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).not.toBe("Big Regions"); + expect(Object.keys(saved.derived_tables).length).toBe(3); + }); + + test("restore(save()) is stable", async ({ page }) => { + await restoreWorkspace(page, CHAIN); + const first = await save(page); + const before = await hostedTables(page); + await restore(page, first); + const second = await save(page); + expect(second.table).toEqual(first.table); + expect(await hostedTables(page)).toEqual(before); + }); + + test("restoreWorkspace rejects an inline panel table", async ({ page }) => { + const error = await restoreWorkspaceError(page, { + panels: { + one: { + table: { table: TABLE, group_by: ["Region"] }, + plugin: "Debug", + }, + }, + }); + + expect(error).not.toBe(""); + expect(error).toContain("string"); + }); + + test("the DebugPanel shows and round-trips the inline form", async ({ + page, + }) => { + await restoreWorkspace(page, CHAIN); + await restore(page, { settings: true }); + await page.locator("perspective-viewer #debug_tabbar_tab").click(); + const textarea = page.locator( + "perspective-viewer #debug-panel-editor textarea", + ); + + await textarea.waitFor(); + const config = JSON.parse(await textarea.inputValue()); + expect(config.table.table.table).toBe(TABLE); + const before = await hostedTables(page); + await textarea.fill(JSON.stringify(config, null, 2)); + await page.locator("perspective-viewer #debug-panel-apply").click(); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.flush(); + }); + + expect(await hostedTables(page)).toEqual(before); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).toBe("Big Regions"); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/new_menu.spec.ts b/rust/perspective-viewer/test/js/derived/new_menu.spec.ts new file mode 100644 index 0000000000..43ffcc0dfb --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/new_menu.spec.ts @@ -0,0 +1,112 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import type { Page } from "@playwright/test"; +import { + TABLE, + addTable, + hostedTables, + restoreWorkspace, + ready, +} from "./helpers.ts"; + +test.beforeEach(ready); + +const CONFIG = { + panels: { one: { table: TABLE, plugin: "Debug" } }, + active: "one", + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, +}; + +async function openNewMenu(page: Page) { + await page.locator("perspective-viewer").locator("#new_panel").click(); + return page.locator("perspective-new-panel-menu, .new-panel-menu").first(); +} + +test.describe("the New menu marks derived tables", () => { + test("a derived row is marked and a base row is not", async ({ page }) => { + await restoreWorkspace(page, CONFIG); + const menu = await openNewMenu(page); + await expect( + menu.locator(".dropdown-menu-item", { hasText: "Region Totals" }), + ).toHaveClass(/is-derived/); + + await expect( + menu.locator(".dropdown-menu-item", { hasText: TABLE }), + ).not.toHaveClass(/is-derived/); + }); + + test("delete is offered at zero consumers", async ({ page }) => { + await restoreWorkspace(page, CONFIG); + const menu = await openNewMenu(page); + await menu + .locator(".dropdown-menu-item", { hasText: "Region Totals" }) + .locator(".derived_delete") + .click(); + + await expect + .poll(async () => await hostedTables(page)) + .not.toContain("Region Totals"); + }); + + test("delete is withheld while a panel reads it", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + active: "one", + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + const menu = await openNewMenu(page); + const row = menu.locator(".dropdown-menu-item", { + hasText: "Region Totals", + }); + + await expect(row.locator(".derived_delete")).toHaveCount(0); + await expect(row.locator(".derived_consumers")).toHaveText("1"); + }); +}); + +test.describe("the New menu tracks the hosted set while open", () => { + test("a deleted row leaves the open menu", async ({ page }) => { + await restoreWorkspace(page, CONFIG); + const menu = await openNewMenu(page); + const row = menu.locator(".dropdown-menu-item", { + hasText: "Region Totals", + }); + + await row.locator(".derived_delete").click(); + await expect(row).toHaveCount(0); + await expect( + menu.locator(".dropdown-menu-item", { hasText: TABLE }), + ).toHaveCount(1); + + expect(await hostedTables(page)).not.toContain("Region Totals"); + }); + + test("a table created while open appears", async ({ page }) => { + await restoreWorkspace(page, CONFIG); + const menu = await openNewMenu(page); + await expect( + menu.locator(".dropdown-menu-item", { hasText: TABLE }), + ).toHaveCount(1); + + await addTable(page, "latecomer"); + await expect( + menu.locator(".dropdown-menu-item", { hasText: "latecomer" }), + ).toHaveCount(1); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/pend.spec.ts b/rust/perspective-viewer/test/js/derived/pend.spec.ts new file mode 100644 index 0000000000..d70805dc67 --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/pend.spec.ts @@ -0,0 +1,87 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import { + TABLE, + addTable, + hostedTables, + restoreWorkspace, + saveWorkspace, + ready, +} from "./helpers.ts"; + +test.beforeEach(ready); + +test.describe("derived_tables pending on a missing parent", () => { + test("a missing parent pends instead of erroring", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Later Totals", plugin: "Debug" } }, + derived_tables: { + "Later Totals": { table: "Later", group_by: ["Region"] }, + }, + }); + + expect(await hostedTables(page)).not.toContain("Later Totals"); + const saved = await saveWorkspace(page); + expect(saved.derived_tables["Later Totals"].table).toBe("Later"); + }); + + test("it builds when the parent arrives", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Later Totals", plugin: "Debug" } }, + derived_tables: { + "Later Totals": { table: "Later", group_by: ["Region"] }, + }, + }); + + await addTable(page, "Later"); + await expect + .poll(async () => await hostedTables(page)) + .toContain("Later Totals"); + }); + + test("a pending subgraph builds parents first", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Big Later", plugin: "Debug" } }, + derived_tables: { + "Big Later": { + table: "Later Totals", + filter: [["Sales", ">", 0]], + }, + "Later Totals": { table: "Later", group_by: ["Region"] }, + }, + }); + + expect(await hostedTables(page)).not.toContain("Later Totals"); + await addTable(page, "Later"); + await expect + .poll(async () => await hostedTables(page)) + .toContain("Big Later"); + }); + + test("an unrelated table restores normally alongside a pending one", async ({ + page, + }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + "Later Totals": { table: "Later", group_by: ["Region"] }, + }, + }); + + const hosted = await hostedTables(page); + expect(hosted).toContain("Region Totals"); + expect(hosted).not.toContain("Later Totals"); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/restore_graph.spec.ts b/rust/perspective-viewer/test/js/derived/restore_graph.spec.ts new file mode 100644 index 0000000000..93833040f5 --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/restore_graph.spec.ts @@ -0,0 +1,117 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import { + TABLE, + hostedTables, + restoreWorkspace, + restoreWorkspaceError, + saveWorkspace, + ready, +} from "./helpers.ts"; + +test.beforeEach(ready); + +const DEPTH_2 = { + panels: { one: { table: "Big Regions", plugin: "Debug" } }, + derived_tables: { + "Big Regions": { + table: "Region Totals", + filter: [["Sales", ">", 100]], + }, + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, +}; + +test.describe("derived_tables graph build", () => { + test("builds parents first regardless of map order", async ({ page }) => { + await restoreWorkspace(page, DEPTH_2); + const hosted = await hostedTables(page); + expect(hosted).toContain("Region Totals"); + expect(hosted).toContain("Big Regions"); + }); + + test("an unchanged definition is reused rather than rebuilt", async ({ + page, + }) => { + await restoreWorkspace(page, DEPTH_2); + const before = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + const view = await viewer.getView(); + const rows = await view.num_rows(); + view.delete(); + return rows; + }); + + await restoreWorkspace(page, DEPTH_2); + const after = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + const view = await viewer.getView(); + const rows = await view.num_rows(); + view.delete(); + return rows; + }); + + expect(after).toBe(before); + expect(await hostedTables(page)).toContain("Big Regions"); + }); + + test("a rebuilt parent forces its descendants to rebuild", async ({ + page, + }) => { + await restoreWorkspace(page, DEPTH_2); + await restoreWorkspace(page, { + panels: { one: { table: "Big Regions", plugin: "Debug" } }, + derived_tables: { + "Big Regions": { + table: "Region Totals", + filter: [["Sales", ">", 100]], + }, + "Region Totals": { table: TABLE, group_by: ["State"] }, + }, + }); + + const saved = await saveWorkspace(page); + expect(saved.derived_tables["Region Totals"].group_by).toEqual([ + "State", + ]); + + const columns = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + const table = await viewer.getTable(); + return Object.keys(await table.schema()); + }); + + expect(columns.join(" ")).toContain("State"); + }); + + test("a cycle is rejected and named", async ({ page }) => { + const error = await restoreWorkspaceError(page, { + panels: { one: { table: TABLE, plugin: "Debug" } }, + derived_tables: { + A: { table: "B", group_by: ["Region"] }, + B: { table: "A", group_by: ["State"] }, + }, + }); + + expect(error).toMatch(/cycle/); + expect(error).toMatch(/A/); + expect(error).toMatch(/B/); + const hosted = await hostedTables(page); + expect(hosted).not.toContain("A"); + expect(hosted).not.toContain("B"); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/save_restore.spec.ts b/rust/perspective-viewer/test/js/derived/save_restore.spec.ts new file mode 100644 index 0000000000..6123153b7b --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/save_restore.spec.ts @@ -0,0 +1,97 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import { + TABLE, + hostedTables, + restoreWorkspace, + saveWorkspace, + ready, +} from "./helpers.ts"; + +test.beforeEach(ready); + +test.describe("derived_tables save/restore", () => { + test("saveWorkspace records derived_tables and no per-panel key", async ({ + page, + }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + const saved = await saveWorkspace(page); + expect(Object.keys(saved.derived_tables)).toEqual(["Region Totals"]); + expect(saved.derived_tables["Region Totals"].table).toBe(TABLE); + expect(saved.derived_tables["Region Totals"].group_by).toEqual([ + "Region", + ]); + expect(saved.panels.one).not.toHaveProperty("derived"); + expect(saved.panels.one.table).toBe("Region Totals"); + }); + + test("a depth-2 graph round-trips", async ({ page }) => { + const config = { + panels: { one: { table: "Big Regions", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + "Big Regions": { + table: "Region Totals", + filter: [["Sales", ">", 100]], + }, + }, + }; + + await restoreWorkspace(page, config); + const first = await saveWorkspace(page); + await restoreWorkspace(page, first); + const second = await saveWorkspace(page); + expect(second.derived_tables).toEqual(first.derived_tables); + expect(await hostedTables(page)).toContain("Big Regions"); + }); + + test("an empty map deletes every derived table", async ({ page }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + await restoreWorkspace(page, { + panels: { one: { table: TABLE, plugin: "Debug" } }, + derived_tables: {}, + }); + + const saved = await saveWorkspace(page); + expect(saved).not.toHaveProperty("derived_tables"); + expect(await hostedTables(page)).not.toContain("Region Totals"); + }); + + test("an absent key keeps the existing derived tables", async ({ + page, + }) => { + await restoreWorkspace(page, { + panels: { one: { table: "Region Totals", plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + await restoreWorkspace(page, { global_filters: [] }); + const saved = await saveWorkspace(page); + expect(Object.keys(saved.derived_tables)).toEqual(["Region Totals"]); + }); +}); diff --git a/rust/perspective-viewer/test/js/derived/teardown.spec.ts b/rust/perspective-viewer/test/js/derived/teardown.spec.ts new file mode 100644 index 0000000000..e5c1e59caf --- /dev/null +++ b/rust/perspective-viewer/test/js/derived/teardown.spec.ts @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import { + TABLE, + hostedTables, + restoreWorkspace, + saveWorkspace, + ready, +} from "./helpers.ts"; + +test.beforeEach(ready); + +const SHARED = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + ], + }, + panels: { + one: { table: "Region Totals", plugin: "Debug" }, + two: { table: "Region Totals", plugin: "Debug" }, + }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, +}; + +test.describe("derived tables outlive their panels", () => { + test("two panels share one derived table", async ({ page }) => { + await restoreWorkspace(page, SHARED); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).toBe("Region Totals"); + expect(saved.panels.two.table).toBe("Region Totals"); + expect(Object.keys(saved.derived_tables)).toEqual(["Region Totals"]); + }); + + test("closing one panel leaves the table and the other panel", async ({ + page, + }) => { + await restoreWorkspace(page, SHARED); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.removePanel("two"); + // @ts-ignore + await viewer.flush(); + }); + + expect(await hostedTables(page)).toContain("Region Totals"); + const saved = await saveWorkspace(page); + expect(saved.panels.one.table).toBe("Region Totals"); + }); + + test("rebinding a panel away leaves the table listed", async ({ page }) => { + await restoreWorkspace(page, SHARED); + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ table }); + // @ts-ignore + await viewer.flush(); + }, TABLE); + + expect(await hostedTables(page)).toContain("Region Totals"); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/context_menu_derive.spec.ts b/rust/perspective-viewer/test/js/multi_panel/context_menu_derive.spec.ts new file mode 100644 index 0000000000..7db60d6868 --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/context_menu_derive.spec.ts @@ -0,0 +1,114 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// The panel context menu's "Derive Table" command: pivot gating and the +// promote-and-rebind flow. + +import { test, expect } from "../helpers.ts"; +import { TABLE, hostedTables, restoreWorkspace } from "../derived/helpers.ts"; +import { armInvariants } from "./harness.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +armInvariants(test); + +async function deriveItem(page) { + await page + .locator("perspective-viewer perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + return menu.locator(".context-menu-item", { hasText: "Derive Table" }); +} + +test.describe("Context menu New flyout tracks deletes", () => { + test("a derived row deleted from the flyout disappears in place", async ({ + page, + }) => { + await restoreWorkspace(page, { + panels: { one: { table: TABLE, plugin: "Debug" } }, + derived_tables: { + "Region Totals": { table: TABLE, group_by: ["Region"] }, + }, + }); + + await page + .locator("perspective-viewer perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + const new_item = menu.locator(".context-menu-item.has-submenu", { + hasText: "New", + }); + + await new_item.hover(); + const row = new_item.locator( + ".context-menu-submenu .dropdown-menu-item", + { hasText: "Region Totals" }, + ); + + await row.waitFor(); + await row.locator(".derived_delete").click(); + await expect(row).toHaveCount(0); + await expect( + new_item.locator(".context-menu-submenu .dropdown-menu-item", { + hasText: TABLE, + }), + ).toHaveCount(1); + }); +}); + +test.describe("Context menu Derive Table", () => { + test("disabled without pivots", async ({ page }) => { + const item = await deriveItem(page); + await expect(item).toHaveClass(/disabled/); + }); + + test("derives and rebinds a pivoted panel", async ({ page }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ group_by: ["Region"] }); + await viewer.flush(); + }); + + const before = await hostedTables(page); + const item = await deriveItem(page); + await expect(item).not.toHaveClass(/disabled/); + await item.click(); + await expect + .poll(async () => (await hostedTables(page)).length) + .toBe(before.length + 1); + + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.flush(); + return await viewer.saveWorkspace(); + }); + + const panel: any = Object.values(saved.panels)[0]; + expect(panel.table).not.toBe(TABLE); + expect(Object.keys(saved.derived_tables)).toEqual([panel.table]); + expect(saved.derived_tables[panel.table].table).toBe(TABLE); + expect(saved.derived_tables[panel.table].group_by).toEqual(["Region"]); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/context_menu_viewer.spec.ts b/rust/perspective-viewer/test/js/multi_panel/context_menu_viewer.spec.ts new file mode 100644 index 0000000000..b34c1e34db --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/context_menu_viewer.spec.ts @@ -0,0 +1,171 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import { TABLE, restoreWorkspace, saveWorkspace } from "../derived/helpers.ts"; +import { armInvariants } from "./harness.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +armInvariants(test); + +async function openSettings(page) { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ settings: true }); + await viewer.flush(); + }); +} + +async function twoPanelsSecondActive(page) { + await restoreWorkspace(page, { + panels: { + one: { table: TABLE, plugin: "Debug" }, + two: { table: TABLE, plugin: "Debug", group_by: ["Region"] }, + }, + }); + + await openSettings(page); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.setActivePanel("two"); + }); +} + +test.describe("Viewer-wide context menu", () => { + test("a status bar right-click targets the active panel", async ({ + page, + }) => { + await twoPanelsSecondActive(page); + await page + .locator("perspective-viewer #status_bar #spacer") + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu + .locator(".context-menu-item", { hasText: "Duplicate" }) + .click(); + + await expect + .poll( + async () => + Object.keys((await saveWorkspace(page)).panels).length, + ) + .toBe(3); + + const saved = await saveWorkspace(page); + const copy: any = Object.entries(saved.panels).find( + ([k]) => k !== "one" && k !== "two", + )![1]; + + expect(copy.group_by).toEqual(["Region"]); + }); + + test("a settings sidebar right-click targets the active panel", async ({ + page, + }) => { + await twoPanelsSecondActive(page); + await page + .locator("perspective-viewer #settings_panel") + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu.locator(".context-menu-item", { hasText: "Close" }).click(); + await expect + .poll(async () => Object.keys((await saveWorkspace(page)).panels)) + .toEqual(["one"]); + }); + + test("a right-click on an editable input opens no menu", async ({ + page, + }) => { + await openSettings(page); + await page.locator("perspective-viewer #debug_tabbar_tab").click(); + const textarea = page.locator( + "perspective-viewer #debug-panel-editor textarea", + ); + + await textarea.waitFor(); + await textarea.click({ button: "right" }); + await expect(page.locator("perspective-context-menu")).toHaveCount(0); + await page + .locator("perspective-viewer #settings_panel") + .click({ button: "right" }); + + await expect(page.locator("perspective-context-menu")).toHaveCount(1); + }); + + test("a right-click inside the open menu does not stack menus", async ({ + page, + }) => { + await page + .locator("perspective-viewer perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu + .locator(".context-menu-item", { hasText: "Duplicate" }) + .click({ button: "right" }); + + await expect(page.locator("perspective-context-menu")).toHaveCount(1); + await expect( + menu.locator(".context-menu-item", { hasText: "Duplicate" }), + ).toBeVisible(); + }); + + test("at zero panels any right-click offers the New stage menu", async ({ + page, + }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + for (const id of viewer.getPanelNames()) { + await viewer.removePanel(id); + } + }); + + await page + .locator("perspective-viewer #status_bar #spacer") + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await expect(menu.locator(".context-menu-item")).toHaveCount(1); + const new_item = menu.locator(".context-menu-item.has-submenu", { + hasText: "New", + }); + + await new_item.hover(); + await new_item + .locator(".context-menu-submenu .dropdown-menu-item", { + hasText: TABLE, + }) + .click(); + + await page.waitForFunction( + () => + ( + document.querySelector("perspective-viewer") as any + ).getPanelNames().length === 1, + ); + }); +}); diff --git a/rust/perspective-viewer/test/js/viewer_api/effective_aggregates.spec.ts b/rust/perspective-viewer/test/js/viewer_api/effective_aggregates.spec.ts new file mode 100644 index 0000000000..4d697b8bcd --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_api/effective_aggregates.spec.ts @@ -0,0 +1,67 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; + +test.describe("Effective aggregates", () => { + test.beforeEach(async function init({ page }) { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + test("restoring expression pivots emits no aggregate warning", async ({ + page, + }) => { + const warnings: string[] = []; + page.on("console", (msg) => { + if ( + msg.type() === "warning" && + /No (default )?aggregate/.test(msg.text()) + ) { + warnings.push(msg.text()); + } + }); + + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Debug", + settings: true, + group_by: [`bucket("Order Date", 'M')`], + split_by: [`bucket("Sales", 100)`], + columns: ["v"], + expressions: { + [`bucket("Order Date", 'M')`]: `bucket("Order Date", 'M')`, + [`bucket("Sales", 100)`]: `bucket("Sales", 100)`, + v: `if("Region"=='West'){-1}else{1}`, + }, + }); + + return await viewer.save(); + }); + + expect(warnings).toEqual([]); + expect(saved.aggregates).toEqual({}); + await expect( + page + .locator( + ".column-selector-column .aggregate-selector-wrapper select", + ) + .first(), + ).toHaveValue("sum"); + }); +}); diff --git a/rust/perspective-viewer/test/js/windows.spec.ts b/rust/perspective-viewer/test/js/windows.spec.ts index 2ced1a00c1..568fd0ec31 100644 --- a/rust/perspective-viewer/test/js/windows.spec.ts +++ b/rust/perspective-viewer/test/js/windows.spec.ts @@ -630,6 +630,57 @@ test.describe("Window columns", () => { ]); }); + test("row_number is offered for any source and saves without a frame", async ({ + page, + }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Debug", + settings: true, + columns: ["Row ID", "Region"], + }); + }); + + await page.click("#add-expression"); + await page.click("#Window"); + await page.dragAndDrop( + '.column-selector-draggable:has-text("Region")', + "#window-source", + ); + + const select = page.locator( + "#window-source .aggregate-selector-wrapper select", + ); + await select + .locator("option", { hasText: "row_number" }) + .waitFor({ state: "attached" }); + await select.selectOption("row_number"); + await expect(select).toHaveValue("row_number"); + await expect(page.locator("#window-frame-type")).toHaveCount(0); + + await page.fill("#window-order-by .column-empty-input", "Row ID"); + await page.press("#window-order-by .column-empty-input", "Enter"); + await page.fill("input.sidebar_header_title", "rn"); + await page.click("#psp-expression-editor-button-save"); + await page.waitForFunction(async () => { + const viewer = document.querySelector("perspective-viewer"); + const saved = await viewer.save(); + return Object.keys(saved.windows ?? {}).length === 1; + }); + + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + return await viewer.save(); + }); + + expect(saved.windows["rn"]).toEqual({ + column: "Region", + aggregate: "row_number", + order_by: ["Row ID", "asc"], + }); + }); + test("drawer width is trap-doored across editor tab switches", async ({ page, }) => { diff --git a/rust/perspective/build.mjs b/rust/perspective/build.mjs index 03c44872a2..e080d02e57 100644 --- a/rust/perspective/build.mjs +++ b/rust/perspective/build.mjs @@ -15,6 +15,8 @@ import { execSync } from "child_process"; let flags = "--release"; if (!!process.env.PSP_DEBUG) { flags = ""; +} else if (!!process.env.PSP_FAST) { + flags = "--profile fast"; } const env = { ...process.env }; diff --git a/test.json b/test.json new file mode 100644 index 0000000000..2653ab0f98 --- /dev/null +++ b/test.json @@ -0,0 +1,55 @@ +[ + { + "plugin": "Datagrid", + "table": { + "table": "superstore", + "group_by": ["State"], + "group_rollup_mode": "flat", + "columns": ["Sales", "Profit"], + "aggregates": { + "Profit": ["weighted mean", ["Row ID"]], + "Sales": ["weighted mean", ["Postal Code"]] + } + }, + "title": "Superstore", + "group_by": ["State"], + "expressions": { + "\"Sales\" - \"Profit\"": "\"Sales\" - \"Profit\"" + }, + "columns": ["Sales", "Profit", "\"Sales\" - \"Profit\""] + }, + { + "settings": true, + "version": "5.5.1", + "columns_config": {}, + "plugin": "Datagrid", + "plugin_config": {}, + "table": { + "table": "superstore", + "group_by": ["State"], + "split_by": [], + "sort": [], + "filter": [], + "group_rollup_mode": "flat", + "split_rollup_mode": "flat", + "expressions": {}, + "columns": ["Sales"], + "aggregates": { + "Sales": "sum" + } + }, + "theme": "Pro Light", + "title": "Superstore", + "group_by": ["State"], + "split_by": ["New Column 1"], + "sort": [], + "filter": [], + "group_rollup_mode": "rollup", + "split_rollup_mode": "flat", + "expressions": { + "New Column 1": "\"Sales\" > 20000" + }, + "columns": ["Sales"], + "aggregates": {} + } +] diff --git a/tools/scripts/setup.mjs b/tools/scripts/setup.mjs index 9ed00bc8b8..6d688cef93 100644 --- a/tools/scripts/setup.mjs +++ b/tools/scripts/setup.mjs @@ -100,11 +100,20 @@ const CONFIG = new Proxy( }, ); -const PROMPT_DEBUG = { - type: "confirm", - name: "PSP_DEBUG", - message: "Run debug build?", - default: CONFIG["PSP_DEBUG"] || false, +const PROMPT_BUILD_MODE = { + type: "list", + name: "PSP_BUILD_MODE", + message: "Build mode?", + default: CONFIG["PSP_DEBUG"] + ? "debug" + : CONFIG["PSP_FAST"] + ? "fast" + : "prod", + choices: [ + { name: "prod", value: "prod" }, + { name: "fast", value: "fast" }, + { name: "debug", value: "debug" }, + ], }; const PROMPT_DOCKER = { @@ -245,7 +254,17 @@ async function focus_package() { } async function javascript_options() { - const new_config = await inquirer.prompt([PROMPT_DEBUG, PROMPT_DOCKER]); + const { PSP_BUILD_MODE, ...new_config } = await inquirer.prompt([ + PROMPT_BUILD_MODE, + PROMPT_DOCKER, + ]); + + if (PSP_BUILD_MODE === "debug") { + new_config.PSP_DEBUG = 1; + } else if (PSP_BUILD_MODE === "fast") { + new_config.PSP_FAST = 1; + } + CONFIG.add(new_config); CONFIG.write(); } diff --git a/tools/scripts/test_js.mjs b/tools/scripts/test_js.mjs index 2b7fb59a7d..6aee4aadce 100644 --- a/tools/scripts/test_js.mjs +++ b/tools/scripts/test_js.mjs @@ -133,6 +133,8 @@ if (process.env.PACKAGE) { let flags = "--release"; if (!!process.env.PSP_DEBUG) { flags = ""; + } else if (!!process.env.PSP_FAST) { + flags = "--profile fast"; } if (