diff --git a/docs/docs/development.md b/docs/docs/development.md index 18120587ca..6c02d6d2a6 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -98,6 +98,18 @@ To install a specific version of Emscripten (e.g. `2.0.6`): --- +## `perspective-python` + +To build the Python library, first configure your project to build Python via +`yarn setup`. Then, install the requirements corresponding to your version of +python, e.g. + +```bash +pip install -r python/perspective/requirements-311.txt +``` + +`perspective-python` supports Python 3.8 and upwards. + ### `perspective-jupyterlab` To install the Jupyterlab/Jupyter Notebook plugins from your local working @@ -108,17 +120,18 @@ Afterwards, you should see it listed as a "local extension" when you run `jupyter labextension list` and as a normal extension when you run `jupyter nbextension list`. -## `perspective-python` - -To build the Python library, first configure your project to build Python via -`yarn setup`, then run: +As an example, your setup process might look like this: ```bash +python -m venv ./venv +pip install -r python/perspective/requirements-311.txt +yarn setup # choose python yarn build +yarn setup # choose javascript > jupyterlab +yarn build +yarn jlab_link # run this whenever you need to update a local perspective package ``` -`perspective-python` supports Python 3.8 and upwards. - --- ## System-Specific Instructions diff --git a/packages/perspective-jupyterlab/src/js/view.js b/packages/perspective-jupyterlab/src/js/view.js index ebdf081e88..5999fcd141 100644 --- a/packages/perspective-jupyterlab/src/js/view.js +++ b/packages/perspective-jupyterlab/src/js/view.js @@ -102,7 +102,8 @@ export class PerspectiveView extends DOMWidgetView { typeof new_value === "string" && name !== "plugin" && name !== "theme" && - name !== "title" + name !== "title" && + name !== "version" ) { new_value = JSON.parse(new_value); } @@ -270,6 +271,7 @@ export class PerspectiveView extends DOMWidgetView { theme: this.model.get("theme"), settings: this.model.get("settings"), title: this.model.get("title"), + version: this.model.get("version"), }); } @@ -506,4 +508,10 @@ export class PerspectiveView extends DOMWidgetView { title: this.model.get("title"), }); } + + version_changed() { + this.luminoWidget.restore({ + version: this.model.get("version"), + }); + } } diff --git a/packages/perspective-jupyterlab/test/jupyter/widget.spec.js b/packages/perspective-jupyterlab/test/jupyter/widget.spec.js index 47ffc651bc..70231fbbeb 100644 --- a/packages/perspective-jupyterlab/test/jupyter/widget.spec.js +++ b/packages/perspective-jupyterlab/test/jupyter/widget.spec.js @@ -215,6 +215,7 @@ describe_jupyter( // Check default config expect(config).toEqual({ + version: utils.API_VERSION, aggregates: {}, columns: [ "ui8", @@ -267,6 +268,7 @@ w.theme = "Pro Dark"` // and check it expect(config).toEqual({ + version: utils.API_VERSION, aggregates: {}, columns: ["ui8"], expressions: [], @@ -300,6 +302,7 @@ w.theme = "Pro Dark"` // Check default config expect(config).toEqual({ + version: utils.API_VERSION, aggregates: {}, columns: [ "ui8", @@ -333,8 +336,9 @@ w.theme = "Pro Dark"` title: null, }); - await viewer.evaluate(async (viewer) => { + await viewer.evaluate(async (viewer, version) => { viewer.restore({ + version, columns: ["ui8"], filter: [["i8", "<", "50"]], group_by: ["date"], @@ -347,7 +351,7 @@ w.theme = "Pro Dark"` }); return ""; - }); + }, utils.API_VERSION); const error_cells_dont_exist = await assert_no_error_in_cell( page, diff --git a/packages/perspective-viewer-d3fc/src/js/charts/xy-scatter.js b/packages/perspective-viewer-d3fc/src/js/charts/xy-scatter.js index 88eb8d74c9..2086c743f4 100644 --- a/packages/perspective-viewer-d3fc/src/js/charts/xy-scatter.js +++ b/packages/perspective-viewer-d3fc/src/js/charts/xy-scatter.js @@ -10,28 +10,18 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import * as fc from "d3fc"; import { select } from "d3"; -import { axisFactory } from "../axis/axisFactory"; -import { chartCanvasFactory } from "../axis/chartFactory"; -import { - pointSeriesCanvas, - symbolTypeFromColumn, -} from "../series/pointSeriesCanvas"; +import { symbolTypeFromColumn } from "../series/pointSeriesCanvas"; import { pointData } from "../data/pointData"; import { seriesColorsFromColumn, seriesColorsFromDistinct, colorScale, } from "../series/seriesColors"; -import { seriesLinearRange, seriesColorRange } from "../series/seriesRange"; -import { symbolLegend, colorLegend, colorGroupLegend } from "../legend/legend"; +import { seriesColorRange } from "../series/seriesRange"; +import { symbolLegend, colorLegend } from "../legend/legend"; import { colorRangeLegend } from "../legend/colorRangeLegend"; import { filterDataByGroup } from "../legend/filter"; -import withGridLines from "../gridlines/gridlines"; -import { hardLimitZeroPadding } from "../d3fc/padding/hardLimitZero"; -import zoomableChart from "../zoom/zoomableChart"; -import nearbyTip from "../tooltip/nearbyTip"; import { symbolsObj } from "../series/seriesSymbols"; import { gridLayoutMultiChart } from "../layout/gridLayoutMultiChart"; import xyScatterSeries from "../series/xy-scatter/xyScatterSeries"; @@ -55,7 +45,8 @@ function overrideSymbols(settings, symbols) { for (let i in domain) { range[i] = range[i % len]; } - settings.columns?.[symbolCol]?.symbols?.forEach(({ key, value }) => { + let maybeSymbols = settings.columns?.[symbolCol]?.symbols; + Object.entries(maybeSymbols ?? {}).forEach(([key, value]) => { // TODO: Define custom symbol types based on the values passed in here. // https://d3js.org/d3-shape/symbol#custom-symbols let symbolType = symbolsObj[value] ?? d3.symbolCircle; diff --git a/packages/perspective-viewer-d3fc/src/less/chart.less b/packages/perspective-viewer-d3fc/src/less/chart.less index 90ad222127..7c30570621 100644 --- a/packages/perspective-viewer-d3fc/src/less/chart.less +++ b/packages/perspective-viewer-d3fc/src/less/chart.less @@ -59,6 +59,13 @@ padding: 0; font-size: 14px; + d3fc-group:first-child { + padding: 16px; + padding-top: 0px; + width: calc(100% - 32px); + height: calc(100% - 16px); + } + & .multi-xlabel { position: absolute; bottom: 0; @@ -87,7 +94,7 @@ & .inner-container { display: inline-grid; overflow-y: auto; - width: 100%; + width: calc(100% - 16px); height: 100%; padding: 0; margin: 0; diff --git a/packages/perspective-viewer-d3fc/test/js/barWidth.spec.ts b/packages/perspective-viewer-d3fc/test/js/barWidth.spec.ts index 5351365e57..85c588408d 100644 --- a/packages/perspective-viewer-d3fc/test/js/barWidth.spec.ts +++ b/packages/perspective-viewer-d3fc/test/js/barWidth.spec.ts @@ -11,7 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/test"; -import { compareSVGContentsToSnapshot } from "@finos/perspective-test"; +import { + API_VERSION, + compareSVGContentsToSnapshot, +} from "@finos/perspective-test"; test.describe("Bar Width", () => { test("correctly render when a bar chart has non equidistant times on a datetime axis", async ({ @@ -38,6 +41,7 @@ test.describe("Bar Width", () => { ); expect(config).toEqual({ + version: API_VERSION, plugin: "Y Bar", columns: ["Profit"], group_by: ["Order Date"], diff --git a/packages/perspective-viewer-datagrid/src/js/event_handlers/edit_click.js b/packages/perspective-viewer-datagrid/src/js/event_handlers/edit_click.js index 0064685be4..abfcfee85e 100644 --- a/packages/perspective-viewer-datagrid/src/js/event_handlers/edit_click.js +++ b/packages/perspective-viewer-datagrid/src/js/event_handlers/edit_click.js @@ -56,7 +56,7 @@ export function write_cell(table, model, active_cell) { return false; } } else if (type === "boolean") { - text = text === "check" ? false : text === "close" ? true : null; + text = text === "true" ? false : text === "false" ? true : null; } const msg = { diff --git a/packages/perspective-workspace/test/js/migrate_workspace.spec.js b/packages/perspective-workspace/test/js/migrate_workspace.spec.js index 4b7071cf1a..1d41b0da9b 100644 --- a/packages/perspective-workspace/test/js/migrate_workspace.spec.js +++ b/packages/perspective-workspace/test/js/migrate_workspace.spec.js @@ -13,6 +13,7 @@ const { convert } = require("@finos/perspective-viewer/dist/cjs/migrate.js"); import { test, expect } from "@playwright/test"; import { + API_VERSION, compareLightDOMContents, compareShadowDOMContents, } from "@finos/perspective-test"; @@ -60,6 +61,7 @@ const TESTS = [ { viewers: { One: { + version: API_VERSION, table: "superstore", title: "One", plugin: "Y Area", diff --git a/packages/perspective/src/js/config/constants.js b/packages/perspective/src/js/config/constants.js index 018621627a..3805d71fcb 100644 --- a/packages/perspective/src/js/config/constants.js +++ b/packages/perspective/src/js/config/constants.js @@ -34,6 +34,7 @@ export const CONFIG_ALIASES = { }; export const CONFIG_VALID_KEYS = [ + "version", "viewport", "group_by", "split_by", diff --git a/python/perspective/perspective/tests/viewer/test_validate.py b/python/perspective/perspective/tests/viewer/test_validate.py index 5edc23ec48..4baa69f82a 100644 --- a/python/perspective/perspective/tests/viewer/test_validate.py +++ b/python/perspective/perspective/tests/viewer/test_validate.py @@ -14,6 +14,7 @@ from perspective.core import PerspectiveError from perspective.core import Plugin import perspective.viewer.validate as validate +from perspective.core._version import __version__ class TestValidate: @@ -53,8 +54,16 @@ def test_validate_filter_is_not_null(self): def test_validate_expressions(self): computed = ["// expression1 \n 'Hello'"] - assert validate.validate_expressions(["// expression1 \n 'Hello'"]) == computed + assert validate.validate_expressions(computed) == computed + computed = [{"name": "expression1", "expr": "'hey'"}] + assert validate.validate_expressions(computed) == computed def test_validate_expressions_invalid(self): with raises(PerspectiveError): assert validate.validate_expressions({}) + + def test_validate_version(self): + assert validate.validate_version("1.2.3") + assert validate.validate_version("0.0.0+1.2.3") + assert not validate.validate_version("abc") + assert validate.validate_version(__version__) diff --git a/python/perspective/perspective/viewer/validate.py b/python/perspective/perspective/viewer/validate.py index cd9ec84bdf..a430127fe5 100644 --- a/python/perspective/perspective/viewer/validate.py +++ b/python/perspective/perspective/viewer/validate.py @@ -145,7 +145,10 @@ def validate_expressions(expressions): if isinstance(expressions, list): for expr in expressions: - if not isinstance(expr, str): + if isinstance(expr, dict): + if not (expr.get("name") and expr.get("expr")): + raise PerspectiveError("Cannot parse dict expression: {}".format(str(expr))) + elif not isinstance(expr, str): raise PerspectiveError("Cannot parse non-string expression: {}".format(str(type(expr)))) return expressions else: @@ -158,3 +161,9 @@ def validate_plugin_config(plugin_config): def validate_title(title): return title + + +def validate_version(version): + # basic semver of form \d+\.\d+\.\d+(\+.+)? + spl = version.split(".", 2) + return len(spl) == 3 and spl[0].isdigit() and spl[1].isdigit() and (spl[2].split("+")[0]).isdigit() diff --git a/python/perspective/perspective/viewer/viewer.py b/python/perspective/perspective/viewer/viewer.py index f05dcc1dde..d244414b4b 100644 --- a/python/perspective/perspective/viewer/viewer.py +++ b/python/perspective/perspective/viewer/viewer.py @@ -59,6 +59,7 @@ class PerspectiveViewer(PerspectiveTraitlets, object): "theme", "settings", "title", + "version", ) def __init__( diff --git a/python/perspective/perspective/viewer/viewer_traitlets.py b/python/perspective/perspective/viewer/viewer_traitlets.py index 7a60a6d42a..6e1cd428e8 100644 --- a/python/perspective/perspective/viewer/viewer_traitlets.py +++ b/python/perspective/perspective/viewer/viewer_traitlets.py @@ -11,6 +11,8 @@ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ from traitlets import HasTraits, Unicode, List, Bool, Dict, validate +from ..core._version import __version__ + from .validate import ( validate_plugin, validate_columns, @@ -22,6 +24,7 @@ validate_expressions, validate_plugin_config, validate_title, + validate_version, ) @@ -54,6 +57,7 @@ class PerspectiveTraitlets(HasTraits): server = Bool(False).tag(sync=True) client = Bool(False).tag(sync=True) title = Unicode(None, allow_none=True).tag(sync=True) + version = Unicode(__version__).tag(sync=True) @validate("plugin") def _validate_plugin(self, proposal): @@ -94,3 +98,7 @@ def _validate_plugin_config(self, proposal): @validate("title") def _validate_title(self, proposal): return validate_title(proposal.value) + + @validate("version") + def _validate_version(self, proposal): + return validate_version(proposal.value) diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs index d8148435c5..c5e2e1e888 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs @@ -79,7 +79,12 @@ impl yew::Component for SymbolAttr { .and_then(|json_val| { serde_json::from_value::(json_val.clone()) .ok() - .map(|s| s.symbols.into_iter().map(|s| s.into()).collect_vec()) + .map(|s| { + s.symbols + .into_iter() + .map(|s| SymbolKVPair::new(Some(s.0), s.1)) + .collect_vec() + }) }) .unwrap_or_default(); @@ -101,7 +106,7 @@ impl yew::Component for SymbolAttr { let serialized = new_pairs .clone() .into_iter() - .filter_map(|pair| pair.try_into().ok()) + .filter_map(|pair| Some((pair.key?, pair.value))) .collect(); p.send_plugin_config( diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/types.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/types.rs index 5db1936ad0..489a53bd9d 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/types.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/types.rs @@ -10,36 +10,15 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use crate::components::containers::kvpair::KVPair; -use crate::utils::ApiError; #[derive(Serialize, Deserialize)] pub struct SymbolConfig { - pub symbols: Vec, + pub symbols: HashMap, } -#[derive(Serialize, Deserialize)] -pub struct SymbolSerde(pub KVPair); pub type SymbolKVPair = KVPair, String>; -impl TryFrom for SymbolSerde { - type Error = ApiError; - - fn try_from(pair: SymbolKVPair) -> Result { - Ok(SymbolSerde(KVPair { - key: pair - .key - .ok_or::("Could not unwrap {pair:?}".into())?, - value: pair.value, - })) - } -} -impl From for SymbolKVPair { - fn from(pair: SymbolSerde) -> Self { - Self { - key: Some(pair.0.key), - value: pair.0.value, - } - } -} diff --git a/rust/perspective-viewer/src/rust/components/containers/kvpair.rs b/rust/perspective-viewer/src/rust/components/containers/kvpair.rs index 918a1ae2e7..bf969c45e9 100644 --- a/rust/perspective-viewer/src/rust/components/containers/kvpair.rs +++ b/rust/perspective-viewer/src/rust/components/containers/kvpair.rs @@ -44,4 +44,8 @@ where value, } } + + pub fn tuple(&self) -> (&K, &V) { + (&self.key, &self.value) + } } diff --git a/rust/perspective-viewer/src/rust/config/viewer_config.rs b/rust/perspective-viewer/src/rust/config/viewer_config.rs index a3be038462..1efe9c3d5c 100644 --- a/rust/perspective-viewer/src/rust/config/viewer_config.rs +++ b/rust/perspective-viewer/src/rust/config/viewer_config.rs @@ -9,9 +9,9 @@ // ┃ 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::io::{Read, Write}; use std::str::FromStr; +use std::sync::LazyLock; use flate2::read::ZlibDecoder; use flate2::write::ZlibEncoder; @@ -49,6 +49,7 @@ impl FromStr for ViewerConfigEncoding { #[derive(Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ViewerConfig { + pub version: String, pub plugin: String, pub plugin_config: Value, pub settings: bool, @@ -62,6 +63,7 @@ pub struct ViewerConfig { // `#[serde(flatten)]` makes messagepack 2x as big as they can no longer be // struct fields, so make a tuple alternative for serialization in binary. type ViewerConfigBinarySerialFormat<'a> = ( + &'a String, &'a String, &'a Value, bool, @@ -71,6 +73,7 @@ type ViewerConfigBinarySerialFormat<'a> = ( ); type ViewerConfigBinaryDeserialFormat = ( + VersionUpdate, PluginUpdate, Option, SettingsUpdate, @@ -79,9 +82,20 @@ type ViewerConfigBinaryDeserialFormat = ( ViewConfigUpdate, ); +pub static API_VERSION: LazyLock<&'static str> = LazyLock::new(|| { + #[derive(Deserialize)] + struct Package { + version: &'static str, + } + let pkg: &'static str = include_str!("../../../package.json"); + let pkg: Package = serde_json::from_str(pkg).unwrap(); + pkg.version +}); + impl ViewerConfig { fn token(&self) -> ViewerConfigBinarySerialFormat<'_> { ( + &self.version, &self.plugin, &self.plugin_config, self.settings, @@ -125,6 +139,9 @@ impl ViewerConfig { #[derive(Clone, Deserialize)] // #[serde(deny_unknown_fields)] pub struct ViewerConfigUpdate { + #[serde(default)] + pub version: VersionUpdate, + #[serde(default)] pub plugin: PluginUpdate, @@ -146,9 +163,10 @@ pub struct ViewerConfigUpdate { impl ViewerConfigUpdate { fn from_token( - (plugin, plugin_config, settings, theme, title, view_config): ViewerConfigBinaryDeserialFormat, + (version, plugin, plugin_config, settings, theme, title, view_config): ViewerConfigBinaryDeserialFormat, ) -> ViewerConfigUpdate { ViewerConfigUpdate { + version, plugin, plugin_config, settings, @@ -182,6 +200,11 @@ impl ViewerConfigUpdate { Ok(update.into_serde_ext()?) } } + + pub fn migrate(&self) -> ApiResult { + // TODO: Call the migrate script from js + Ok(self.clone()) + } } #[derive(Clone, Debug, Serialize)] @@ -196,6 +219,7 @@ pub type PluginUpdate = OptionalUpdate; pub type SettingsUpdate = OptionalUpdate; pub type ThemeUpdate = OptionalUpdate; pub type TitleUpdate = OptionalUpdate; +pub type VersionUpdate = OptionalUpdate; /// Handles `{}` when included as a field with `#[serde(default)]`. impl Default for OptionalUpdate { diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index 2b3526fd01..8b3ab72b19 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -243,6 +243,8 @@ impl PerspectiveViewerElement { global::document().blur_active_element(); clone!(self.session, self.renderer, self.root, self.presentation); ApiFuture::new(async move { + let decoded_update = ViewerConfigUpdate::decode(&update)?; + let ViewerConfigUpdate { plugin, plugin_config, @@ -250,7 +252,8 @@ impl PerspectiveViewerElement { theme: theme_name, title, mut view_config, - } = ViewerConfigUpdate::decode(&update)?; + ..//version + } = decoded_update; if !session.has_table() { if let OptionalUpdate::Update(x) = settings { diff --git a/rust/perspective-viewer/src/rust/model/get_viewer_config.rs b/rust/perspective-viewer/src/rust/model/get_viewer_config.rs index 9d4355fcd1..7faafc446b 100644 --- a/rust/perspective-viewer/src/rust/model/get_viewer_config.rs +++ b/rust/perspective-viewer/src/rust/model/get_viewer_config.rs @@ -44,6 +44,7 @@ pub trait GetViewerConfigModel: HasSession + HasRenderer + HasPresentation { fn get_viewer_config(&self) -> Pin>>> { clone!(self.renderer(), self.session(), self.presentation()); Box::pin(async move { + let version = config::API_VERSION.to_string(); let view_config = session.get_view_config().clone(); let js_plugin = renderer.get_active_plugin()?; let settings = presentation.is_settings_open(); @@ -52,6 +53,7 @@ pub trait GetViewerConfigModel: HasSession + HasRenderer + HasPresentation { let theme = presentation.get_selected_theme_name().await; let title = presentation.get_title(); Ok(ViewerConfig { + version, plugin, title, plugin_config, diff --git a/rust/perspective-viewer/src/rust/session/replace_expression_update.rs b/rust/perspective-viewer/src/rust/session/replace_expression_update.rs index 2359ee09cb..8014affc21 100644 --- a/rust/perspective-viewer/src/rust/session/replace_expression_update.rs +++ b/rust/perspective-viewer/src/rust/session/replace_expression_update.rs @@ -15,7 +15,6 @@ use std::collections::HashMap; use crate::config::*; impl ViewConfig { - // TODO: Split this up into expr and alias fns /// Create an update for this `ViewConfig` that replaces an expression /// column with a new one, e.g. when a user edits an expression. This may /// changed either the expression alias, the expression itself, or both; as @@ -37,7 +36,6 @@ impl ViewConfig { sort, filter, aggregates, - .. } = self.clone(); let expressions = expressions diff --git a/rust/perspective-viewer/src/ts/migrate.ts b/rust/perspective-viewer/src/ts/migrate.ts index 20747a1304..dc221c846d 100644 --- a/rust/perspective-viewer/src/ts/migrate.ts +++ b/rust/perspective-viewer/src/ts/migrate.ts @@ -10,6 +10,9 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import migrate_0_0_0 from "./migrate/0-0-0"; +import migrate_2_6_1 from "./migrate/2-6-1"; + /** * A migration utility for `@finos/perspective-viewer` and * `@finos/perspective-workspace` persisted state objects. If you have an @@ -68,6 +71,58 @@ export function convert( } } +function* null_iter() { + while (true) yield null; +} +export type Semver = { + major: number; + minor: number; + patch: number; + build?: { + major: number; + minor: number; + patch: number; + }; +}; +// This gets what the semver crate calls major, minor, patch, and build values, but does not capture release. +export function parse_semver(ver: string): Semver { + let regex = /(\d+)\.(\d+)\.(\d+)(\+.+)?/; + let [_ver, major, minor, patch, build_str] = ver.match(regex); + let [_build, build_major, build_minor, build_patch] = + build_str?.match(regex) ?? null_iter(); + let build = + build_major && build_minor && build_patch + ? { + major: Number(build_major), + minor: Number(build_minor), + patch: Number(build_patch), + } + : null; + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + build, + }; +} + +/** + * Checks if left > right + * @param left + * @param right_str + * @returns + */ +export function cmp_semver(left: Semver, right_str: string) { + let right = parse_semver(right_str); + return ( + left.major > right.major || + (left.major === right.major && left.minor > right.minor) || + (left.major === right.major && + left.minor === right.minor && + left.patch > right.patch) + ); +} + type PerspectiveConvertOptions = { warn?: boolean; replace_defaults?: boolean; @@ -111,26 +166,24 @@ function migrate_workspace(old, options) { * @returns */ function migrate_viewer(old, omit_attributes, options) { + old.version = old.version + ? parse_semver(old.version) + : parse_semver("0.0.0"); + options.omit_attributes = omit_attributes; return chain( old, - [ - migrate_group_by, - migrate_split_by, - migrate_filters, - migrate_expressions, - options.replace_defaults ? migrate_nulls : false, - migrate_plugins, - migrate_plugin_config, - migrate_title, - migrate_name_title_workspace, - omit_attributes - ? migrate_attributes_workspace - : migrate_attributes_viewer, - ].filter((x) => !!x), + [migrate_0_0_0, migrate_2_6_1, semver_to_string], options ); } +function semver_to_string(old) { + // intentionally ignores build + console.warn(old.version); + old.version = `${old.version.major}.${old.version.minor}.${old.version.patch}`; + return old; +} + /** * Chains functions of `args` and apply to `old` * @param old @@ -138,502 +191,10 @@ function migrate_viewer(old, omit_attributes, options) { * @param options * @returns */ -function chain(old, args, options) { +export function chain(old, args, options) { for (const arg of args) { old = arg(old, options); } return old; } - -/** - * Replace `null` properties with defaults. This is not strictly behavioral, - * as new `` treats `null` as an explicit "reset to default" - * instruction. However, it may be necessary to ensure that `.save()` returns - * identical results to `convert()`, which may be desirable when migrating a - * database of layouts. - * @param old - * @param options - * @returns - */ -function migrate_nulls(old, options) { - for (const key of ["group_by", "split_by", "filter", "sort"]) { - if (old[key] === null) { - old[key] = []; - if (options.warn) { - console.warn( - `Deprecated perspective missing attribute "${key}" set to default"` - ); - } - } - - if ("aggregates" in old && old.aggregates === null) { - old.aggregates = {}; - if (options.warn) { - console.warn( - `Deprecated perspective missing attribute "aggregates" set to default"` - ); - } - } - } - - return old; -} - -/** - * Helper for alias-replacement migrations - * @param original - * @param aliases - * @returns - */ -function _migrate_field_aliases(original, aliases) { - return function (old, options) { - let count = 0; - for (const pivot of aliases) { - if (pivot in old) { - if (count++ > 0) { - throw new Error(`Duplicate "${original}" fields`); - } - - old[original] = old[pivot]; - if (pivot !== original) { - delete old[pivot]; - if (options.warn) { - console.warn( - `Deprecated perspective attribute "${pivot}" renamed "${original}"` - ); - } - } - } - } - - return old; - }; -} - -/** - * Migrate `group_by` field aliases - */ -const migrate_group_by = _migrate_field_aliases("group_by", [ - "group_by", - "row_pivots", - "row-pivot", - "row-pivots", - "row_pivot", -]); - -/** - * Migrate `split_by` field aliases - */ -const migrate_split_by = _migrate_field_aliases("split_by", [ - "split_by", - "column_pivots", - "column-pivot", - "column-pivots", - "column_pivot", - "col_pivots", - "col-pivot", - "col-pivots", - "col_pivot", -]); - -/** - * Migrate `filters` field aliases - */ -const migrate_filters = _migrate_field_aliases("filter", ["filter", "filters"]); - -/** - * Migrate the old `computed-columns` format expressions to ExprTK - * @param regex1 - * @param rep - * @param expression - * @param old - * @param options - * @returns - */ -function _migrate_expression(regex1, rep, expression, old, options) { - if (regex1.test(expression)) { - const replaced = expression.replace(regex1, rep); - if (options.warn) { - console.warn( - `Deprecated perspective "expression" attribute value "${expression}" updated to "${replaced}"` - ); - } - - for (const key of ["group_by", "split_by"]) { - if (key in old) { - for (const idx in old[key]) { - const pivot = old[key][idx]; - if (pivot === expression.replace(/"/g, "")) { - old[key][idx] = replaced; - if (options.warn) { - console.warn( - `Deprecated perspective expression in "${key}" attribute "${expression}" replaced with "${replaced}"` - ); - } - } - } - } - } - - for (const filter of old.filter || []) { - if (filter[0] === expression.replace(/"/g, "")) { - filter[0] = replaced; - if (options.warn) { - console.warn( - `Deprecated perspective expression in "filter" attribute "${expression}" replaced with "${replaced}"` - ); - } - } - } - - for (const sort of old.sort || []) { - if (sort[0] === expression.replace(/"/g, "")) { - sort[0] = replaced; - if (options.warn) { - console.warn( - `Deprecated perspective expression in "sort" attribute "${expression}" replaced with "${replaced}"` - ); - } - } - } - - return replaced; - } else { - return expression; - } -} - -function migrate_title(old) { - if (old["title"] === undefined) { - old.title = null; - } - - return old; -} - -/** - * Migrate `expressions` field from `computed-columns` - * @param old - * @param options - * @returns - */ -function migrate_expressions(old, options) { - if (old["computed-columns"]) { - if ("expressions" in old) { - throw new Error(`Duplicate "expressions" and "computed-columns`); - } - - old.expressions = old["computed-columns"]; - delete old["computed-columns"]; - if (options.warn) { - console.warn( - `Deprecated perspective attribute "computed-columns" renamed "expressions"` - ); - } - - const REPLACEMENTS = [ - [/^year_bucket\("(.+?)"\)/, `bucket("$1", 'y')`], - [/^month_bucket\("(.+?)"\)/, `bucket("$1", 'M')`], - [/^day_bucket\("(.+?)"\)/, `bucket("$1", 'd')`], - [/^hour_bucket\("(.+?)"\)/, `bucket("$1", 'h')`], - [/^minute_bucket\("(.+?)"\)/, `bucket("$1", 'm')`], - [/^second_bucket\("(.+?)"\)/, `bucket("$1", 's')`], - ]; - - for (const idx in old.expressions) { - let expression = old.expressions[idx]; - for (const [a, b] of REPLACEMENTS) { - expression = _migrate_expression( - a, - b, - expression, - old, - options - ); - } - - old.expressions[idx] = expression; - } - } - - if (Array.isArray(old["expressions"])) { - const expressions = {}; - for (let expression of old["expressions"]) { - let alias = expression; - if (expression.trim().startsWith("//")) { - const parts = expression.split("\n"); - alias = parts.shift(); - alias = alias.slice(2).trim(); - expression = alias.join("\n").trim(); - } - - expressions[alias] = expression; - } - - old["expressions"] = expressions; - } - - return old; -} - -/** - * Migrate the `plugin` field - * @param old - * @param options - * @returns - */ -function migrate_plugins(old, options) { - const ALIASES = { - datagrid: "Datagrid", - Datagrid: "Datagrid", - d3_y_area: "Y Area", - "Y Area": "Y Area", - d3_y_line: "Y Line", - "Y Line": "Y Line", - d3_xy_line: "X/Y Line", - "X/Y Line": "X/Y Line", - d3_y_scatter: "Y Scatter", - "Y Scatter": "Y Scatter", - d3_xy_scatter: "X/Y Scatter", - "X/Y Scatter": "X/Y Scatter", - d3_x_bar: "X Bar", - "X Bar": "X Bar", - d3_y_bar: "Y Bar", - "Y Bar": "Y Bar", - d3_heatmap: "Heatmap", - Heatmap: "Heatmap", - d3_treemap: "Treemap", - Treemap: "Treemap", - d3_sunburst: "Sunburst", - Sunburst: "Sunburst", - }; - - if ("plugin" in old && old.plugin !== ALIASES[old.plugin]) { - old.plugin = ALIASES[old.plugin]; - if (options.warn) { - console.warn( - `Deprecated perspective "plugin" attribute value "${ - old.plugin - }" updated to "${ALIASES[old.plugin]}"` - ); - } - } - - return old; -} - -/** - * Migrate the `plugin_config` field - * @param old - * @param options - * @returns - */ -function migrate_plugin_config(old, options) { - if (old.plugin === "Datagrid" && !!old.plugin_config) { - if (!old.plugin_config.columns) { - if (options.warn) { - console.warn( - `Deprecated perspective attribute "plugin_config" moved to "plugin_config.columns"` - ); - } - - const columns = {}; - for (const name of Object.keys(old.plugin_config)) { - const column = old.plugin_config[name]; - delete old.plugin_config[name]; - - if (typeof column.color_mode === "string") { - if (column.color_mode === "foreground") { - column.number_fg_mode = "color"; - } else if (column.color_mode === "bar") { - column.number_fg_mode = "bar"; - } else if (column.color_mode === "background") { - column.number_bg_mode = "color"; - } else if (column.color_mode === "gradient") { - column.number_bg_mode = "gradient"; - } else { - console.warn(`Unknown color_mode ${column.color_mode}`); - } - - // column.number_color_mode = column.color_mode; - delete column["color_mode"]; - - if (options.warn) { - console.warn( - `Deprecated perspective attribute "color_mode" renamed "number_bg_mode"` - ); - } - } - - columns[name] = column; - } - - old.plugin_config.columns = columns; - if (options.replace_defaults) { - old.plugin_config.editable = false; - old.plugin_config.scroll_lock = true; - } - } - - // Post 1.5, number columns have been split between `fg` and `bg` - // style param contexts. - for (const name of Object.keys(old.plugin_config.columns)) { - const column = old.plugin_config.columns[name]; - - if (typeof column.number_color_mode === "string") { - if (column.number_color_mode === "foreground") { - column.number_fg_mode = "color"; - } else if (column.number_color_mode === "bar") { - column.number_fg_mode = "bar"; - } else if (column.number_color_mode === "background") { - column.number_bg_mode = "color"; - } else if (column.number_color_mode === "gradient") { - column.number_bg_mode = "gradient"; - } - - delete column["number_color_mode"]; - - if (options.warn) { - console.warn( - `Deprecated perspective attribute "number_color_mode" renamed "number_bg_mode"` - ); - } - } - - if (column.gradient !== undefined) { - if (column.number_bg_mode === "gradient") { - column.bg_gradient = column.gradient; - } else if (column.number_fg_mode === "bar") { - column.fg_gradient = column.gradient; - } - - delete column["gradient"]; - if (options.warn) { - console.warn( - `Deprecated perspective attribute "gradient" renamed "bg_gradient"` - ); - } - } - - if (column.pos_color !== undefined) { - if (column.number_bg_mode !== undefined) { - column.pos_bg_color = column.pos_color; - } else if (column.number_fg_mode !== undefined) { - column.pos_fg_color = column.pos_color; - } - - delete column["pos_color"]; - if (options.warn) { - console.warn( - `Deprecated perspective attribute "pos_color" renamed "pos_bg_color"` - ); - } - } - - if (column.neg_color !== undefined) { - if (column.number_bg_mode !== undefined) { - column.neg_bg_color = column.neg_color; - } else if (column.number_fg_mode !== undefined) { - column.neg_fg_color = column.neg_color; - } - - delete column["neg_color"]; - if (options.warn) { - console.warn( - `Deprecated perspective attribute "neg_color" renamed "neg_bg_color"` - ); - } - } - } - } - - return old; -} - -/** - * Migrate attributes which were once persisted but are now considered errors - * in `` and should only be set in HTML - * @param old - * @param options - * @returns - */ -function migrate_attributes_viewer(old, options) { - const ATTRIBUTES = [ - "editable", - "selectable", - "name", - "table", - "master", - "linked", - ]; - for (const attr of ATTRIBUTES) { - if (attr in old) { - delete old[attr]; - - if (options.warn) { - console.warn( - `Deprecated perspective attribute "${attr}" removed` - ); - } - } - } - - return old; -} - -/** - * Migrate attributes which were once persisted but are now considered errors - * in `` and should only be set in HTML - * @param old - * @param options - * @returns - */ -function migrate_attributes_workspace(old, options) { - const ATTRIBUTES = [ - "editable", - "selectable", - "name", - "table", - "master", - "linked", - ]; - for (const attr of ATTRIBUTES) { - if (attr in old && old[attr] === null) { - delete old[attr]; - - if (options.warn) { - console.warn( - `Deprecated perspective attribute "${attr}" removed` - ); - } - } - } - - return old; -} - -/** - * Migrate workspace viewer 'name' which was unified with `title`. - * @param old - * @param options - * @returns - */ -function migrate_name_title_workspace(old, options) { - if ("name" in old) { - if ("title" in old && old.title !== undefined) { - old.title = old["name"]; - if (options.warn) { - console.warn(`"name" conflicts with "title"`); - } - } - - delete old["name"]; - - if (options.warn) { - console.warn(`"name" unified with "title"`); - } - } - - return old; -} diff --git a/rust/perspective-viewer/src/ts/migrate/0-0-0.ts b/rust/perspective-viewer/src/ts/migrate/0-0-0.ts new file mode 100644 index 0000000000..9c8d457b68 --- /dev/null +++ b/rust/perspective-viewer/src/ts/migrate/0-0-0.ts @@ -0,0 +1,531 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { chain, parse_semver } from "../migrate"; + +/** + * Migrates all viewer configs older than version 1.0.0. + * @param old + * @param omit_attributes + * @param options + * @returns The migrated viewer. + */ +export default function migrate_0_0_0(old, options) { + if (old.version?.major > 0) { + return old; + } else { + if (options.warn) { + console.warn("Migrating pre-1.0.0 config"); + } + } + return chain( + old, + [ + migrate_group_by, + migrate_split_by, + migrate_filters, + migrate_expressions, + options.replace_defaults ? migrate_nulls : false, + migrate_plugins, + migrate_plugin_config, + migrate_title, + migrate_name_title_workspace, + options.omit_attributes + ? migrate_attributes_workspace + : migrate_attributes_viewer, + (old) => { + old.version = parse_semver("0.0.0"); + return old; + }, + ].filter((x) => !!x), + options + ); +} + +/** + * Replace `null` properties with defaults. This is not strictly behavioral, + * as new `` treats `null` as an explicit "reset to default" + * instruction. However, it may be necessary to ensure that `.save()` returns + * identical results to `convert()`, which may be desirable when migrating a + * database of layouts. + * @param old + * @param options + * @returns + */ +function migrate_nulls(old, options) { + for (const key of ["group_by", "split_by", "filter", "sort"]) { + if (old[key] === null) { + old[key] = []; + if (options.warn) { + console.warn( + `Deprecated perspective missing attribute "${key}" set to default"` + ); + } + } + + if ("aggregates" in old && old.aggregates === null) { + old.aggregates = {}; + if (options.warn) { + console.warn( + `Deprecated perspective missing attribute "aggregates" set to default"` + ); + } + } + } + + return old; +} + +/** + * Helper for alias-replacement migrations + * @param original + * @param aliases + * @returns + */ +function _migrate_field_aliases(original, aliases) { + return function (old, options) { + let count = 0; + for (const pivot of aliases) { + if (pivot in old) { + if (count++ > 0) { + throw new Error(`Duplicate "${original}" fields`); + } + + old[original] = old[pivot]; + if (pivot !== original) { + delete old[pivot]; + if (options.warn) { + console.warn( + `Deprecated perspective attribute "${pivot}" renamed "${original}"` + ); + } + } + } + } + + return old; + }; +} + +/** + * Migrate `group_by` field aliases + */ +const migrate_group_by = _migrate_field_aliases("group_by", [ + "group_by", + "row_pivots", + "row-pivot", + "row-pivots", + "row_pivot", +]); + +/** + * Migrate `split_by` field aliases + */ +const migrate_split_by = _migrate_field_aliases("split_by", [ + "split_by", + "column_pivots", + "column-pivot", + "column-pivots", + "column_pivot", + "col_pivots", + "col-pivot", + "col-pivots", + "col_pivot", +]); + +/** + * Migrate `filters` field aliases + */ +const migrate_filters = _migrate_field_aliases("filter", ["filter", "filters"]); + +/** + * Migrate the old `computed-columns` format expressions to ExprTK + * @param regex1 + * @param rep + * @param expression + * @param old + * @param options + * @returns + */ +function _migrate_expression(regex1, rep, expression, old, options) { + if (regex1.test(expression)) { + const replaced = expression.replace(regex1, rep); + if (options.warn) { + console.warn( + `Deprecated perspective "expression" attribute value "${expression}" updated to "${replaced}"` + ); + } + + for (const key of ["group_by", "split_by"]) { + if (key in old) { + for (const idx in old[key]) { + const pivot = old[key][idx]; + if (pivot === expression.replace(/"/g, "")) { + old[key][idx] = replaced; + if (options.warn) { + console.warn( + `Deprecated perspective expression in "${key}" attribute "${expression}" replaced with "${replaced}"` + ); + } + } + } + } + } + + for (const filter of old.filter || []) { + if (filter[0] === expression.replace(/"/g, "")) { + filter[0] = replaced; + if (options.warn) { + console.warn( + `Deprecated perspective expression in "filter" attribute "${expression}" replaced with "${replaced}"` + ); + } + } + } + + for (const sort of old.sort || []) { + if (sort[0] === expression.replace(/"/g, "")) { + sort[0] = replaced; + if (options.warn) { + console.warn( + `Deprecated perspective expression in "sort" attribute "${expression}" replaced with "${replaced}"` + ); + } + } + } + + return replaced; + } else { + return expression; + } +} + +function migrate_title(old) { + if (old["title"] === undefined) { + old.title = null; + } + + return old; +} + +/** + * Migrate `expressions` field from `computed-columns` + * @param old + * @param options + * @returns + */ +function migrate_expressions(old, options) { + if (old["computed-columns"]) { + if ("expressions" in old) { + throw new Error(`Duplicate "expressions" and "computed-columns`); + } + + old.expressions = old["computed-columns"]; + delete old["computed-columns"]; + if (options.warn) { + console.warn( + `Deprecated perspective attribute "computed-columns" renamed "expressions"` + ); + } + + const REPLACEMENTS = [ + [/^year_bucket\("(.+?)"\)/, `bucket("$1", 'y')`], + [/^month_bucket\("(.+?)"\)/, `bucket("$1", 'M')`], + [/^day_bucket\("(.+?)"\)/, `bucket("$1", 'd')`], + [/^hour_bucket\("(.+?)"\)/, `bucket("$1", 'h')`], + [/^minute_bucket\("(.+?)"\)/, `bucket("$1", 'm')`], + [/^second_bucket\("(.+?)"\)/, `bucket("$1", 's')`], + ]; + + for (const idx in old.expressions) { + let expression = old.expressions[idx]; + for (const [a, b] of REPLACEMENTS) { + expression = _migrate_expression( + a, + b, + expression, + old, + options + ); + } + + old.expressions[idx] = expression; + } + } + + return old; +} + +/** + * Migrate the `plugin` field + * @param old + * @param options + * @returns + */ +function migrate_plugins(old, options) { + const ALIASES = { + datagrid: "Datagrid", + Datagrid: "Datagrid", + d3_y_area: "Y Area", + "Y Area": "Y Area", + d3_y_line: "Y Line", + "Y Line": "Y Line", + d3_xy_line: "X/Y Line", + "X/Y Line": "X/Y Line", + d3_y_scatter: "Y Scatter", + "Y Scatter": "Y Scatter", + d3_xy_scatter: "X/Y Scatter", + "X/Y Scatter": "X/Y Scatter", + d3_x_bar: "X Bar", + "X Bar": "X Bar", + d3_y_bar: "Y Bar", + "Y Bar": "Y Bar", + d3_heatmap: "Heatmap", + Heatmap: "Heatmap", + d3_treemap: "Treemap", + Treemap: "Treemap", + d3_sunburst: "Sunburst", + Sunburst: "Sunburst", + }; + + if ("plugin" in old && old.plugin !== ALIASES[old.plugin]) { + old.plugin = ALIASES[old.plugin]; + if (options.warn) { + console.warn( + `Deprecated perspective "plugin" attribute value "${ + old.plugin + }" updated to "${ALIASES[old.plugin]}"` + ); + } + } + + return old; +} + +/** + * Migrate the `plugin_config` field + * @param old + * @param options + * @returns + */ +function migrate_plugin_config(old, options) { + return !!old.plugin_config && old.plugin === "Datagrid" + ? _migrate_datagrid(old, options) + : old; +} + +function _migrate_datagrid(old, options) { + if (!old.plugin_config.columns) { + if (options.warn) { + console.warn( + `Deprecated perspective attribute "plugin_config" moved to "plugin_config.columns"` + ); + } + + const columns = {}; + for (const name of Object.keys(old.plugin_config)) { + const column = old.plugin_config[name]; + delete old.plugin_config[name]; + + if (typeof column.color_mode === "string") { + if (column.color_mode === "foreground") { + column.number_fg_mode = "color"; + } else if (column.color_mode === "bar") { + column.number_fg_mode = "bar"; + } else if (column.color_mode === "background") { + column.number_bg_mode = "color"; + } else if (column.color_mode === "gradient") { + column.number_bg_mode = "gradient"; + } else { + console.warn(`Unknown color_mode ${column.color_mode}`); + } + + // column.number_color_mode = column.color_mode; + delete column["color_mode"]; + + if (options.warn) { + console.warn( + `Deprecated perspective attribute "color_mode" renamed "number_bg_mode"` + ); + } + } + + columns[name] = column; + } + + old.plugin_config.columns = columns; + if (options.replace_defaults) { + old.plugin_config.editable = false; + old.plugin_config.scroll_lock = true; + } + } + + // Post 1.5, number columns have been split between `fg` and `bg` + // style param contexts. + for (const name of Object.keys(old.plugin_config.columns)) { + const column = old.plugin_config.columns[name]; + + if (typeof column.number_color_mode === "string") { + if (column.number_color_mode === "foreground") { + column.number_fg_mode = "color"; + } else if (column.number_color_mode === "bar") { + column.number_fg_mode = "bar"; + } else if (column.number_color_mode === "background") { + column.number_bg_mode = "color"; + } else if (column.number_color_mode === "gradient") { + column.number_bg_mode = "gradient"; + } + + delete column["number_color_mode"]; + + if (options.warn) { + console.warn( + `Deprecated perspective attribute "number_color_mode" renamed "number_bg_mode"` + ); + } + } + + if (column.gradient !== undefined) { + if (column.number_bg_mode === "gradient") { + column.bg_gradient = column.gradient; + } else if (column.number_fg_mode === "bar") { + column.fg_gradient = column.gradient; + } + + delete column["gradient"]; + if (options.warn) { + console.warn( + `Deprecated perspective attribute "gradient" renamed "bg_gradient"` + ); + } + } + + if (column.pos_color !== undefined) { + if (column.number_bg_mode !== undefined) { + column.pos_bg_color = column.pos_color; + } else if (column.number_fg_mode !== undefined) { + column.pos_fg_color = column.pos_color; + } + + delete column["pos_color"]; + if (options.warn) { + console.warn( + `Deprecated perspective attribute "pos_color" renamed "pos_bg_color"` + ); + } + } + + if (column.neg_color !== undefined) { + if (column.number_bg_mode !== undefined) { + column.neg_bg_color = column.neg_color; + } else if (column.number_fg_mode !== undefined) { + column.neg_fg_color = column.neg_color; + } + + delete column["neg_color"]; + if (options.warn) { + console.warn( + `Deprecated perspective attribute "neg_color" renamed "neg_bg_color"` + ); + } + } + } + + return old; +} + +/** + * Migrate attributes which were once persisted but are now considered errors + * in `` and should only be set in HTML + * @param old + * @param options + * @returns + */ +function migrate_attributes_viewer(old, options) { + const ATTRIBUTES = [ + "editable", + "selectable", + "name", + "table", + "master", + "linked", + ]; + for (const attr of ATTRIBUTES) { + if (attr in old) { + delete old[attr]; + + if (options.warn) { + console.warn( + `Deprecated perspective attribute "${attr}" removed` + ); + } + } + } + + return old; +} + +/** + * Migrate attributes which were once persisted but are now considered errors + * in `` and should only be set in HTML + * @param old + * @param options + * @returns + */ +function migrate_attributes_workspace(old, options) { + const ATTRIBUTES = [ + "editable", + "selectable", + "name", + "table", + "master", + "linked", + ]; + for (const attr of ATTRIBUTES) { + if (attr in old && old[attr] === null) { + delete old[attr]; + + if (options.warn) { + console.warn( + `Deprecated perspective attribute "${attr}" removed` + ); + } + } + } + + return old; +} + +/** + * Migrate workspace viewer 'name' which was unified with `title`. + * @param old + * @param options + * @returns + */ +function migrate_name_title_workspace(old, options) { + if ("name" in old) { + if ("title" in old && old.title !== undefined) { + old.title = old["name"]; + if (options.warn) { + console.warn(`"name" conflicts with "title"`); + } + } + + delete old["name"]; + + if (options.warn) { + console.warn(`"name" unified with "title"`); + } + } + + return old; +} diff --git a/rust/perspective-viewer/src/ts/migrate/2-6-1.ts b/rust/perspective-viewer/src/ts/migrate/2-6-1.ts new file mode 100644 index 0000000000..1e38364511 --- /dev/null +++ b/rust/perspective-viewer/src/ts/migrate/2-6-1.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { cmp_semver, parse_semver } from "../migrate"; +/** + * Migrates to 2.6.1 + * @param old + * @param options + * @returns + */ +export default function migrate_2_6_1(old, options) { + if (cmp_semver(old.version, "2.6.1")) { + return; + } else if (options.warn) { + console.warn("Migrating to 2.6.1"); + } + old.version = parse_semver("2.6.1"); + + // Migrate X/Y Scatter plugin + if (old.plugin === "X/Y Scatter") { + for (const i in old.plugin_config.columns) { + const entries = Object.entries(old.plugin_config.columns[i]); + const mapped_entries = entries.map(([grp_name, grp_val]) => { + if (grp_name === "symbols" && Array.isArray(grp_val)) { + if (options.warn) { + console.warn( + `Replacing depcrecated X/Y Scatter Plot symbol config ${grp_val}` + ); + } + const obj = {}; + for (const i in grp_val) { + const item = grp_val[i]; + obj[item.key] = item.value; + } + grp_val = obj; + } + return [grp_name, grp_val]; + }); + old.plugin_config.columns[i] = Object.fromEntries(mapped_entries); + } + } + + // check for string expressions, replace with objects + let new_exprs = {}; + for (let i in old.expressions) { + if (typeof old.expressions[i] === "string") { + if (options.warn) { + console.warn( + "Replacing deprecated string expression with object" + ); + } + let old_expr = old.expressions[i]; + let [whole_expr, name, expr] = old_expr.match( + /\/\/\s*([^\n]+)\n(.*)/ + ) ?? [old_expr, null, null]; + if (name && expr) { + new_exprs[name] = expr; + } else { + new_exprs[whole_expr] = whole_expr; + } + } + } + old.expressions = new_exprs; + + return old; +} diff --git a/rust/perspective-viewer/test/js/events.spec.ts b/rust/perspective-viewer/test/js/events.spec.ts index bf80fb556e..8aeeabeb4c 100644 --- a/rust/perspective-viewer/test/js/events.spec.ts +++ b/rust/perspective-viewer/test/js/events.spec.ts @@ -11,7 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/test"; -import { compareContentsToSnapshot } from "@finos/perspective-test"; +import { + compareContentsToSnapshot, + API_VERSION, +} from "@finos/perspective-test"; async function get_contents(page) { return await page.evaluate(async () => { @@ -67,6 +70,7 @@ test.describe("Events", () => { }); expect(config).toEqual({ + version: API_VERSION, aggregates: {}, split_by: [], columns: ["Profit", "Sales"], diff --git a/rust/perspective-viewer/test/js/migrate_viewer.spec.js b/rust/perspective-viewer/test/js/migrate_viewer.spec.js index ec66507b47..25fe9a114a 100644 --- a/rust/perspective-viewer/test/js/migrate_viewer.spec.js +++ b/rust/perspective-viewer/test/js/migrate_viewer.spec.js @@ -11,7 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/test"; -import { compareContentsToSnapshot } from "@finos/perspective-test"; +import { + compareContentsToSnapshot, + API_VERSION, +} from "@finos/perspective-test"; const { convert } = require("../../dist/cjs/migrate.js"); @@ -46,6 +49,7 @@ const TESTS = [ plugin_config: {}, }, { + version: API_VERSION, plugin: "Y Area", plugin_config: {}, group_by: ["bucket(\"Order Date\", 'M')"], @@ -76,6 +80,7 @@ const TESTS = [ plugin_config: { Sales: { color_mode: "gradient", gradient: 10 } }, }, { + version: API_VERSION, plugin: "Datagrid", plugin_config: { columns: { @@ -114,6 +119,7 @@ const TESTS = [ aggregates: {}, }, { + version: API_VERSION, plugin: "Datagrid", plugin_config: { columns: { @@ -160,6 +166,7 @@ const TESTS = [ aggregates: {}, }, { + version: API_VERSION, plugin: "Datagrid", plugin_config: { columns: { @@ -186,6 +193,7 @@ const TESTS = [ [ "New API, reflexive (new API is unmodified)", { + version: API_VERSION, plugin: "Datagrid", plugin_config: { columns: { @@ -225,8 +233,10 @@ const TESTS = [ group_by: [], expressions: {}, split_by: [], + title: null, }, { + version: API_VERSION, plugin: "Datagrid", plugin_config: { columns: { @@ -269,6 +279,49 @@ const TESTS = [ title: null, }, ], + [ + "From 0.0.0", + { + plugin: "X/Y Scatter", + plugin_config: { + columns: { + Region: { symbols: [{ key: "Central", value: "circle" }] }, + }, + }, + title: null, + group_by: [], + split_by: [], + columns: ["'hello'", "expr"], + filter: [], + sort: [], + expressions: ["// expr\n1+1", "'hello'"], + aggregates: {}, + }, + { + version: API_VERSION, + plugin: "X/Y Scatter", + plugin_config: { + columns: { + Region: { + symbols: { + Central: "circle", + }, + }, + }, + }, + title: null, + group_by: [], + split_by: [], + columns: ["'hello'", "expr"], + filter: [], + sort: [], + expressions: { + expr: "1+1", + "'hello'": "'hello'", + }, + aggregates: {}, + }, + ], ]; test.beforeEach(async ({ page }) => { @@ -289,9 +342,12 @@ test.describe("Migrate Viewer", () => { test.describe("Viewer config migrations", () => { for (const [name, old, current] of TESTS) { test(`Migrate '${name}'`, async ({ page }) => { - const converted = convert(JSON.parse(JSON.stringify(old)), { - replace_defaults: true, - }); + const converted = convert( + JSON.parse(JSON.stringify(old), { warn: true }), + { + replace_defaults: true, + } + ); expect(converted).toEqual(current); }); } diff --git a/rust/perspective-viewer/test/js/plugins.spec.js b/rust/perspective-viewer/test/js/plugins.spec.js index 9c70618b6b..4c394902c3 100644 --- a/rust/perspective-viewer/test/js/plugins.spec.js +++ b/rust/perspective-viewer/test/js/plugins.spec.js @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/test"; +import { API_VERSION } from "@finos/perspective-test"; test.beforeEach(async ({ page }) => { await page.goto( @@ -34,6 +35,7 @@ test.describe("Plugin Priority Order", () => { }); const expected = { + version: API_VERSION, aggregates: {}, columns: [ "Row ID", diff --git a/rust/perspective-viewer/test/js/regressions.spec.js b/rust/perspective-viewer/test/js/regressions.spec.js index 07db516496..fc23efeaae 100644 --- a/rust/perspective-viewer/test/js/regressions.spec.js +++ b/rust/perspective-viewer/test/js/regressions.spec.js @@ -12,6 +12,7 @@ import { test, expect } from "@playwright/test"; import { + API_VERSION, compareContentsToSnapshot, shadow_type, } from "@finos/perspective-test"; @@ -99,6 +100,7 @@ test.describe("Regression tests", () => { }); expect(config).toEqual({ + version: API_VERSION, aggregates: {}, columns: ["Sales"], expressions: {}, diff --git a/rust/perspective-viewer/test/js/save_restore.spec.js b/rust/perspective-viewer/test/js/save_restore.spec.js index 8ca7f3c760..42f0f04784 100644 --- a/rust/perspective-viewer/test/js/save_restore.spec.js +++ b/rust/perspective-viewer/test/js/save_restore.spec.js @@ -11,7 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/test"; -import { compareContentsToSnapshot } from "@finos/perspective-test"; +import { + compareContentsToSnapshot, + API_VERSION, +} from "@finos/perspective-test"; async function get_contents(page) { return await page.evaluate(async () => { @@ -51,6 +54,7 @@ test.describe("Save/Restore", async () => { }); expect(config).toEqual({ + version: API_VERSION, aggregates: {}, split_by: [], columns: ["Profit", "Sales"], @@ -85,6 +89,7 @@ test.describe("Save/Restore", async () => { }); expect(config).toEqual({ + version: API_VERSION, aggregates: {}, split_by: [], columns: ["Profit", "Sales"], @@ -106,6 +111,7 @@ test.describe("Save/Restore", async () => { }); expect(config2).toEqual({ + version: API_VERSION, aggregates: {}, split_by: [], columns: [ @@ -147,6 +153,7 @@ test.describe("Save/Restore", async () => { }, config); expect(config3).toEqual({ + version: API_VERSION, aggregates: {}, split_by: [], columns: ["Profit", "Sales"], diff --git a/rust/perspective-viewer/test/js/settings.spec.js b/rust/perspective-viewer/test/js/settings.spec.js index e5fcece3cf..46e5b69db1 100644 --- a/rust/perspective-viewer/test/js/settings.spec.js +++ b/rust/perspective-viewer/test/js/settings.spec.js @@ -98,7 +98,10 @@ test.describe("Settings", () => { new Promise((_, reject) => reject("Intentional Load Error")) ); try { - await viewer.restore({ settings: true, plugin: "Debug" }); + await viewer.restore({ + settings: true, + plugin: "Debug", + }); } catch (e) { // We need to catch this error else the `evaluate()` fails. // We need to await the call because we want it to fail @@ -115,10 +118,13 @@ test.describe("Settings", () => { // "RuntimeError::unreachable", ]); - expect(logs).toEqual([ - "Invalid config, resetting to default {group_by: Array(0), split_by: Array(0), columns: Array(0), filter: Array(0), sort: Array(0)} `restore()` called before `load()`", - "Caught error: `restore()` called before `load()`", - ]); + expect(logs.length).toBe(2); + expect(logs[0]).toMatch( + /Invalid config, resetting to default \{[^}]+\} `restore\(\)` called before `load\(\)`/ + ); + expect(logs[1]).toEqual( + "Caught error: `restore()` called before `load()`" + ); }); }); }); diff --git a/tools/perspective-test/src/js/utils.ts b/tools/perspective-test/src/js/utils.ts index 26dd801507..f48ad196da 100644 --- a/tools/perspective-test/src/js/utils.ts +++ b/tools/perspective-test/src/js/utils.ts @@ -11,6 +11,11 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, Locator, Page } from "@playwright/test"; +import * as fs from "fs"; + +export const API_VERSION = JSON.parse( + fs.readFileSync(__dirname + "/../../package.json").toString() +)["version"]; /** * Clean a `` for serialization/comparison.