diff --git a/examples/react-workspace-example/build.js b/examples/react-workspace-example/build.js new file mode 100644 index 0000000000..44be9a5f78 --- /dev/null +++ b/examples/react-workspace-example/build.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 esbuild from "esbuild"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +await esbuild.build({ + entryPoints: ["src/index.tsx"], + outdir: "dist", + format: "esm", + bundle: true, + sourcemap: "linked", + target: "es2022", + loader: { + ".arrow": "file", + ".wasm": "file", + }, + assetNames: "[name]", +}); + +fs.writeFileSync( + path.join(__dirname, "dist/index.html"), + fs.readFileSync(path.join(__dirname, "src/index.html")).toString() +); diff --git a/examples/react-workspace-example/globals.d.ts b/examples/react-workspace-example/globals.d.ts new file mode 100644 index 0000000000..26ce93c67f --- /dev/null +++ b/examples/react-workspace-example/globals.d.ts @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +declare module "*.wasm" { + const content: string; + export default content; +} + +declare module "*.arrow" { + const content: string; + export default content; +} diff --git a/examples/react-workspace-example/package.json b/examples/react-workspace-example/package.json new file mode 100644 index 0000000000..cc649f4a37 --- /dev/null +++ b/examples/react-workspace-example/package.json @@ -0,0 +1,29 @@ +{ + "name": "react-workspace-example", + "private": true, + "version": "3.7.4", + "description": "An example app using @finos/perspective-react and its workspace component", + "type": "module", + "scripts": { + "build": "node build.js", + "start": "node build.js && http-server dist" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@finos/perspective": "workspace:^", + "@finos/perspective-viewer": "workspace:^", + "@finos/perspective-viewer-d3fc": "workspace:^", + "@finos/perspective-viewer-datagrid": "workspace:^", + "@finos/perspective-react": "workspace:^", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "superstore-arrow": "^3.0.0" + }, + "devDependencies": { + "esbuild": "^0.25.5", + "http-server": "^14.1.1", + "@types/react": "^18", + "@types/react-dom": "^18" + } +} diff --git a/examples/react-workspace-example/src/index.css b/examples/react-workspace-example/src/index.css new file mode 100644 index 0000000000..dd60379436 --- /dev/null +++ b/examples/react-workspace-example/src/index.css @@ -0,0 +1,68 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +body { + margin: 0; + background-color: #f0f0f0; + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; +} + +.container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + grid-template-rows: 45px 1fr; +} + +perspective-viewer { + grid-row: 2; +} + +.toolbar { + grid-row: 1; + grid-column-start: 1; + grid-column-end: 3; + + display: flex; + flex-direction: row; + gap: 10px; + padding: 10px; + justify-content: stretch; + border-bottom: 1px solid #666; +} + +button { + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; +} + +.workspace-container { + display: flex; + flex-direction: column; + + .workspace-toolbar { + display: flex; + flex-direction: row; + } + + perspective-workspace { + height: 100vh; + } +} \ No newline at end of file diff --git a/examples/react-workspace-example/src/index.html b/examples/react-workspace-example/src/index.html new file mode 100644 index 0000000000..d06e153a60 --- /dev/null +++ b/examples/react-workspace-example/src/index.html @@ -0,0 +1,19 @@ + + + + + + Perspective React Example + + + + +
+ + diff --git a/examples/react-workspace-example/src/index.tsx b/examples/react-workspace-example/src/index.tsx new file mode 100644 index 0000000000..f3111e523b --- /dev/null +++ b/examples/react-workspace-example/src/index.tsx @@ -0,0 +1,154 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// # [Perspective bootstrapping](https://perspective.finos.org/guide/how_to/javascript/importing.html) + +// Here we're initializing the WASM interpreter that powers the perspective API +// and viewer, as covered in the [user guide section on bundling](https://perspective.finos.org/guide/how_to/javascript/importing.html). +// This example is written assuming that the bundler is configured +// to treat these files as a "file" and returns a path as the default export. +// Use ./build.js as an example. The type stubs are in ./globals.d.ts + +import perspective from "@finos/perspective"; +import perspective_viewer from "@finos/perspective-viewer"; +import "@finos/perspective-viewer-datagrid"; +import "@finos/perspective-viewer-d3fc"; + +import SERVER_WASM from "@finos/perspective/dist/wasm/perspective-server.wasm"; +import CLIENT_WASM from "@finos/perspective-viewer/dist/wasm/perspective-viewer.wasm"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +// # Data Source + +// Data source creates a static Web Worker instance of Perspective engine, and a +// table creation function which both downloads data and loads it into the +// engine. + +import type * as psp from "@finos/perspective"; +import * as Workspace from "@finos/perspective-workspace"; + +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { PerspectiveWorkspace } from "@finos/perspective-react"; +import { PerspectiveWorkspaceConfig } from "@finos/perspective-workspace"; + +import "@finos/perspective-viewer/dist/css/pro.css"; +import "@finos/perspective-workspace/dist/css/pro.css"; +import "./index.css"; + +const CLIENT = await perspective.worker(); + +interface WorkspaceState { + mounted: boolean; + config: PerspectiveWorkspaceConfig; + tables: Record>; + /// This object is kept for the 'swap tables' button. + /// It is a backup set of tables that correspond in keys to `tables` + /// but with different data. + swapTables: Record>; + /// if false use `tables` and true use `swapTables` in the workspace + swap: boolean; +} + +const WorkspaceApp: React.FC = () => { + const [state, setState] = React.useState({ + mounted: true, + tables: {}, + swapTables: {}, + config: { + sizes: [], + viewers: {}, + detail: undefined, + }, + swap: false, + }); + + const onClickAddViewer = React.useCallback(async () => { + const name = window.crypto.randomUUID().slice(0, 8); + const data = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}`; + const swapData = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}\n${Math.random()},${Math.random()},${Math.random()}`; + // dont assign internal names to the tables they are not used by the workspace + const t = CLIENT.table(data); + const swap = CLIENT.table(swapData); + const config = Workspace.addViewer(state.config, { + table: name, + title: name, + }); + const tables = { ...state.tables, [name]: t }; + const swapTables = { ...state.swapTables, [name]: swap }; + setState({ + ...state, + tables, + config, + swapTables, + }); + }, [state]); + + const onLayoutUpdate: (detail: { + layout: PerspectiveWorkspaceConfig; + tables: Record>; + }) => void = React.useCallback( + ({ layout, tables }) => { + const newTables = Object.fromEntries( + Object.entries(tables).map(([k, v]) => [k, Promise.resolve(v)]) + ); + setState({ + ...state, + config: layout, + tables: state.swap ? state.tables : newTables, + swapTables: state.swap ? newTables : state.swapTables, + }); + }, + [state] + ); + + const onClickToggleMount = () => + setState((old) => ({ ...old, mounted: !state.mounted })); + + // swaps the tables out but uses the same name of them. + // this keeps the layout the same, but the data within each viewer changes + const swapTables = React.useCallback(() => { + setState({ + ...state, + swap: !state.swap, + }); + }, [state]); + + return ( +
+
+ + + +
+ {state.mounted && ( + + )} +
+ ); +}; + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react-workspace-example/tsconfig.json b/examples/react-workspace-example/tsconfig.json new file mode 100644 index 0000000000..2bf55a7ab2 --- /dev/null +++ b/examples/react-workspace-example/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "lib": [ + "ES2020", + "dom" + ], + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitUseStrict": false, + "noUnusedLocals": false, + "strictNullChecks": true, + "skipLibCheck": false, + "removeComments": false, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": false, + "noEmitHelpers": true, + "inlineSourceMap": false, + "sourceMap": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "downlevelIteration": true, + "pretty": true + }, + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/perspective-react/package.json b/packages/perspective-react/package.json index ca732b7f19..ed0038bfd0 100644 --- a/packages/perspective-react/package.json +++ b/packages/perspective-react/package.json @@ -7,7 +7,8 @@ "author": "", "license": "ISC", "dependencies": { - "@finos/perspective": "workspace:^" + "@finos/perspective": "workspace:^", + "async-mutex": "0.5.0" }, "exports": { ".": "./dist/esm/index.js" @@ -24,6 +25,7 @@ "peerDependencies": { "@finos/perspective": "workspace:^", "@finos/perspective-viewer": "workspace:^", + "@finos/perspective-workspace": "workspace:^", "@types/react": "^18", "react": "^18", "react-dom": "^18" diff --git a/packages/perspective-react/src/index.tsx b/packages/perspective-react/src/index.tsx index deada682b5..0a485d721e 100644 --- a/packages/perspective-react/src/index.tsx +++ b/packages/perspective-react/src/index.tsx @@ -20,87 +20,5 @@ * @module */ -import * as React from "react"; -import type * as psp from "@finos/perspective"; -import type * as pspViewer from "@finos/perspective-viewer"; - -function usePspListener( - viewer: HTMLElement | null, - name: string, - f?: (x: A) => void -) { - React.useEffect(() => { - if (!f) return; - const ctx = new AbortController(); - const callback = (e: Event) => f((e as CustomEvent).detail); - viewer?.addEventListener(name, callback, { signal: ctx.signal }); - return () => ctx.abort(); - }, [viewer, f]); -} - -export interface PerspectiveViewerProps { - table?: psp.Table | Promise; - config?: pspViewer.ViewerConfigUpdate; - onConfigUpdate?: (config: pspViewer.ViewerConfigUpdate) => void; - onClick?: (data: pspViewer.PerspectiveClickEventDetail) => void; - onSelect?: (data: pspViewer.PerspectiveSelectEventDetail) => void; - - // Applicable props from `React.HTMLAttributes`, which we cannot extend - // directly because Perspective changes the signature of `onClick`. - className?: string | undefined; - hidden?: boolean | undefined; - id?: string | undefined; - slot?: string | undefined; - style?: React.CSSProperties | undefined; - tabIndex?: number | undefined; - title?: string | undefined; -} - -function PerspectiveViewerImpl(props: PerspectiveViewerProps) { - const [viewer, setViewer] = - React.useState(null); - - React.useEffect(() => { - return () => { - viewer?.delete(); - }; - }, [viewer]); - - React.useEffect(() => { - if (props.table) { - viewer?.load(props.table); - } else { - viewer?.eject(); - } - }, [viewer, props.table]); - - React.useEffect(() => { - if (props.table && props.config) { - viewer?.restore(props.config); - } - }, [viewer, props.table, JSON.stringify(props.config)]); - - usePspListener(viewer, "perspective-click", props.onClick); - usePspListener(viewer, "perspective-select", props.onSelect); - usePspListener(viewer, "perspective-config-update", props.onConfigUpdate); - - return ( - ( + el: HTMLElement | undefined, + event: string, + cb?: (x: A) => void +) { + React.useEffect(() => { + if (!cb || !el) return; + const ctx = new AbortController(); + const callback = (e: Event) => cb((e as CustomEvent).detail); + el?.addEventListener(event, callback, { signal: ctx.signal }); + return () => ctx.abort(); + }, [el, cb]); +} diff --git a/packages/perspective-react/src/viewer.tsx b/packages/perspective-react/src/viewer.tsx new file mode 100644 index 0000000000..0a6175e1b9 --- /dev/null +++ b/packages/perspective-react/src/viewer.tsx @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 * as psp from "@finos/perspective"; +import type * as pspViewer from "@finos/perspective-viewer"; + +import * as utils from "./utils"; + +import * as React from "react"; + +export interface PerspectiveViewerProps { + table?: psp.Table | Promise; + config?: pspViewer.ViewerConfigUpdate; + onConfigUpdate?: (config: pspViewer.ViewerConfigUpdate) => void; + onClick?: (data: pspViewer.PerspectiveClickEventDetail) => void; + onSelect?: (data: pspViewer.PerspectiveSelectEventDetail) => void; + + // Applicable props from `React.HTMLAttributes`, which we cannot extend + // directly because Perspective changes the signature of `onClick`. + className?: string | undefined; + hidden?: boolean | undefined; + id?: string | undefined; + slot?: string | undefined; + style?: React.CSSProperties | undefined; + tabIndex?: number | undefined; + title?: string | undefined; +} + +function PerspectiveViewerImpl(props: PerspectiveViewerProps) { + const [viewer, setViewer] = + React.useState(); + + React.useEffect(() => { + return () => { + viewer?.delete(); + }; + }, [viewer]); + + React.useEffect(() => { + if (props.table) { + viewer?.load(props.table); + } else { + viewer?.eject(); + } + }, [viewer, props.table]); + + React.useEffect(() => { + if (viewer && props.table && props.config) { + viewer.restore(props.config); + } + }, [viewer, props.table, JSON.stringify(props.config)]); + + utils.usePspListener(viewer, "perspective-click", props.onClick); + utils.usePspListener(viewer, "perspective-select", props.onSelect); + utils.usePspListener( + viewer, + "perspective-config-update", + props.onConfigUpdate + ); + + return ( + setViewer(r ?? undefined)} + id={props.id} + className={props.className} + hidden={props.hidden} + slot={props.slot} + style={props.style} + tabIndex={props.tabIndex} + title={props.title} + /> + ); +} + +/** + * A React wrapper component for `` Custom Element. + */ +export const PerspectiveViewer: React.FC = React.memo( + PerspectiveViewerImpl +); diff --git a/packages/perspective-react/src/workspace.tsx b/packages/perspective-react/src/workspace.tsx new file mode 100644 index 0000000000..8f4f94ce86 --- /dev/null +++ b/packages/perspective-react/src/workspace.tsx @@ -0,0 +1,168 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 * as psp from "@finos/perspective"; +import type * as pspWorkspace from "@finos/perspective-workspace"; +import { + PerspectiveWorkspaceConfig, + ViewerConfigUpdateExt, +} from "@finos/perspective-workspace"; + +import * as utils from "./utils"; + +import * as React from "react"; +import { Mutex } from "async-mutex"; +import { HTMLPerspectiveViewerElement } from "@finos/perspective-viewer"; + +export interface PerspectiveWorkspaceProps + extends React.HTMLAttributes { + tables: Record>; + config: PerspectiveWorkspaceConfig; + onLayoutUpdate?: (detail: { + layout: PerspectiveWorkspaceConfig; + tables: Record>; + }) => void; + onNewView?: (detail: { + config: ViewerConfigUpdateExt; + widget: pspWorkspace.PerspectiveViewerWidget; + }) => void; + onToggleGlobalFilter?: (detail: { + widget: pspWorkspace.PerspectiveViewerWidget; + isGlobalFilter: boolean; + }) => void; +} + +export const PerspectiveWorkspace = React.forwardRef< + pspWorkspace.HTMLPerspectiveWorkspaceElement | undefined, + PerspectiveWorkspaceProps +>( + ( + { + tables, + config, + onLayoutUpdate = () => {}, + onNewView = () => {}, + onToggleGlobalFilter = () => {}, + id, + className, + style, + }, + ref + ) => { + const [workspace, setWorkspace] = + React.useState(); + + React.useImperativeHandle(ref, () => workspace, [workspace]); + + // React.useEffect(() => { + // if (!workspace) return; + // }, [workspace, config]); + + // const lock = React.useRef(new Mutex()); + + React.useEffect(() => { + if (!workspace) return; + reconcileNewTables(workspace, tables, config).then(() => { + workspace.restore(config); + }); + // workspace.restore(config); + // lock.current.runExclusive(async () => { + // }); + }, [workspace, tables, config]); + + utils.usePspListener(workspace, "workspace-new-view", onNewView); + + utils.usePspListener( + workspace, + "workspace-layout-update", + onLayoutUpdate + ); + utils.usePspListener( + workspace, + "workspace-toggle-global-filter", + onToggleGlobalFilter + ); + + return ( + setWorkspace(r ?? undefined)} + id={id} + className={className} + style={style} + > + ); + } +); + +// export const PerspectiveWorkspace = React.memo(PerspectiveWorkspaceImpl); + +/// swaps all tables in the workspace with the ones passed in. +/// Any viewers that are using tables not in the new tables will be ejected (`viewer>eject()`). +async function reconcileNewTables( + workspace: pspWorkspace.HTMLPerspectiveWorkspaceElement, + next: Record>, + config: PerspectiveWorkspaceConfig +) { + const prev = workspace.tables; + const allViewers = Array.from( + workspace.children + ) as HTMLPerspectiveViewerElement[]; + const tableViewerMap: Record = + allViewers.reduce((acc, v) => { + const t = config.viewers[v.slot]?.table; + if (t === undefined) { + return acc; + } else { + return { + ...acc, + [t]: [...(acc[t] ?? []), v], + }; + } + }, {} as Record); + + /// reconcile the two sets of tables into the final tables set. + const names = new Set([...Object.keys(next), ...prev.keys()]); + for (const name of names) { + const usedViewers = tableViewerMap[name]; + if (Object.is(prev.get(name), next[name])) { + // We dont need to modify anything in the tables set + // the key is there and uses the same table. + } else if (next[name] === undefined) { + // name is no longer mapped in the new set of tables. + const p = prev.get(name); + if (p === undefined) { + throw new Error("Unreachable."); + } + await Promise.all( + usedViewers.map((v) => { + return v.eject(); + }) + ); + workspace.removeTable(name); + } else if (prev.get(name) === undefined) { + // A table was added that did not exist in the previous set. + await workspace.addTable(name, next[name]); + } else { + // the table for `name` was remapped. + // eject and then reload viewers using `name` + await Promise.all( + usedViewers.map(async (v) => { + await v.eject(); + await v.load(next[name]); + }) + ); + await workspace.replaceTable(name, next[name]); + } + } + + // await Promise.all(tasks); +} diff --git a/packages/perspective-react/test/js/basic.story.tsx b/packages/perspective-react/test/js/basic.story.tsx index db525cf524..d836f91246 100644 --- a/packages/perspective-react/test/js/basic.story.tsx +++ b/packages/perspective-react/test/js/basic.story.tsx @@ -14,6 +14,7 @@ import perspective from "@finos/perspective"; import perspective_viewer from "@finos/perspective-viewer"; import "@finos/perspective-viewer-datagrid"; import "@finos/perspective-viewer-d3fc"; +import "@finos/perspective-workspace"; // @ts-ignore import SERVER_WASM from "@finos/perspective/dist/wasm/perspective-server.wasm?url"; @@ -26,12 +27,18 @@ await Promise.all([ perspective_viewer.init_client(fetch(CLIENT_WASM)), ]); -import type * as psp from "@finos/perspective"; +import * as psp from "@finos/perspective"; import type * as pspViewer from "@finos/perspective-viewer"; // @ts-ignore import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow?url"; +import * as React from "react"; +import { PerspectiveViewer } from "@finos/perspective-react"; + +import "@finos/perspective-viewer/dist/css/themes.css"; +import "./index.css"; + const WORKER = await perspective.worker(); async function createNewSuperstoreTable(): Promise { @@ -45,12 +52,6 @@ const CONFIG: pspViewer.ViewerConfigUpdate = { group_by: ["State"], }; -import * as React from "react"; -import { PerspectiveViewer } from "@finos/perspective-react"; - -import "@finos/perspective-viewer/dist/css/themes.css"; -import "./index.css"; - interface ToolbarState { mounted: boolean; table?: Promise; diff --git a/packages/perspective-react/test/js/index.css b/packages/perspective-react/test/js/index.css index b9ae4513e0..684ff0e2de 100644 --- a/packages/perspective-react/test/js/index.css +++ b/packages/perspective-react/test/js/index.css @@ -52,3 +52,17 @@ button { font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; } + +.workspace-container { + display: flex; + flex-direction: column; + + .workspace-toolbar { + display: flex; + flex-direction: row; + } + + perspective-workspace { + height: 100vh; + } +} diff --git a/packages/perspective-react/test/js/react.spec.tsx b/packages/perspective-react/test/js/react.spec.tsx index df2beb2187..a096a057e3 100644 --- a/packages/perspective-react/test/js/react.spec.tsx +++ b/packages/perspective-react/test/js/react.spec.tsx @@ -11,19 +11,107 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@playwright/experimental-ct-react"; -import { PerspectiveViewer } from "@finos/perspective-react"; -import React from "react"; import { App } from "./basic.story"; +import { WorkspaceApp } from "./workspace.story"; +import { HTMLPerspectiveWorkspaceElement } from "@finos/perspective-workspace"; +import { HTMLPerspectiveViewerElement } from "@finos/perspective-viewer"; test.describe("Perspective React", () => { test("The viewer loads with data in it", async ({ page, mount }) => { const comp = await mount(); - const count = await page.evaluate(async () => { - await new Promise((x) => setTimeout(x, 1000)); - return document.querySelectorAll("perspective-viewer").length; + const viewers = comp.locator("perspective-viewer"); + await viewers.waitFor(); + expect(await viewers.all()).toHaveLength(2); + }); + + test("React workspace functionality", async ({ page, mount }) => { + const comp = await mount(); + const toggleMount = comp.locator("button.toggle-mount"); + const addViewer = comp.locator("button.add-viewer"); + const workspace = comp.locator("perspective-workspace"); + const viewer = comp.locator("perspective-viewer"); + + await toggleMount.waitFor(); + await addViewer.click(); + await addViewer.click(); + await addViewer.click(); + await expect(viewer).toHaveCount(3); + await toggleMount.click(); + await workspace.waitFor({ state: "detached" }); + await toggleMount.click(); + await workspace.waitFor(); + await expect(viewer).toHaveCount(3); + }); + + test("Adding a viewer in single-document mode leaves SDM", async ({ + mount, + }) => { + const comp = await mount(); + const addViewer = comp.locator("button.add-viewer"); + const viewer = comp.locator("perspective-viewer"); + const settingsBtn = comp.locator(`perspective-workspace span#label`); + const settingsPanel = viewer.locator("#settings_panel"); + await settingsBtn.waitFor(); + await addViewer.waitFor(); + await addViewer.click(); + expect(await viewer.count()).toBe(2); + await settingsBtn.first().click(); + await settingsPanel.waitFor(); + await addViewer.click(); + expect(await viewer.count()).toBe(3); + await settingsPanel.waitFor({ state: "detached" }); + }); + + test.only("Swapping tables properly loads the new tables and ejects the viewers of removed tables", async ({ + mount, + page, + }) => { + const comp = await mount(); + const addViewer = comp.locator("button.add-viewer"); + const viewer = comp.locator("perspective-viewer"); + await page.pause(); + const swapBtn = comp.locator("button.swap"); + await swapBtn.waitFor(); + await addViewer.click(); + await viewer.waitFor(); + + let rows = await page.evaluate(async () => { + const workspace = document.querySelector( + "perspective-workspace" + ) as HTMLPerspectiveWorkspaceElement; + const viewer = document.querySelector( + "perspective-viewer" + ) as HTMLPerspectiveViewerElement; + await workspace.flush(); + return await (await viewer.getView()).num_rows(); }); + expect(rows).toBe(1); - expect(count).toBe(2); + await swapBtn.click(); + rows = await page.evaluate(async () => { + const workspace = document.querySelector( + "perspective-workspace" + ) as HTMLPerspectiveWorkspaceElement; + const viewer = document.querySelector( + "perspective-viewer" + ) as HTMLPerspectiveViewerElement; + await workspace.flush(); + return await (await viewer.getView()).num_rows(); + }); + expect(rows).toBe(2); + + await swapBtn.click(); + rows = await page.evaluate(async () => { + const workspace = document.querySelector( + "perspective-workspace" + ) as HTMLPerspectiveWorkspaceElement; + const viewer = document.querySelector( + "perspective-viewer" + ) as HTMLPerspectiveViewerElement; + await workspace.flush(); + return await (await viewer.getView()).num_rows(); + }); + expect(rows).toBe(1); }); }); diff --git a/packages/perspective-react/test/js/workspace.story.tsx b/packages/perspective-react/test/js/workspace.story.tsx new file mode 100644 index 0000000000..1f4a118368 --- /dev/null +++ b/packages/perspective-react/test/js/workspace.story.tsx @@ -0,0 +1,148 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "@finos/perspective-workspace"; + +import { + HTMLPerspectiveWorkspaceElement, + PerspectiveViewerWidget, + PerspectiveWorkspaceConfig, + ViewerConfigUpdateExt, +} from "@finos/perspective-workspace"; + +import * as React from "react"; + +import { PerspectiveWorkspace } from "@finos/perspective-react"; + +import perspective from "@finos/perspective"; +import perspective_viewer from "@finos/perspective-viewer"; +import "@finos/perspective-viewer-datagrid"; +import "@finos/perspective-viewer-d3fc"; +import "@finos/perspective-workspace"; +import * as Workspace from "@finos/perspective-workspace"; + +import * as psp from "@finos/perspective"; + +import "@finos/perspective-workspace/dist/css/pro.css"; +import "./index.css"; + +// @ts-ignore +import SERVER_WASM from "@finos/perspective/dist/wasm/perspective-server.wasm?url"; + +// @ts-ignore +import CLIENT_WASM from "@finos/perspective-viewer/dist/wasm/perspective-viewer.wasm?url"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +const CLIENT = await perspective.worker(); + +interface WorkspaceState { + mounted: boolean; + config: PerspectiveWorkspaceConfig; + tables: Record>; + /// This object is kept for the 'swap tables' button. + /// It is a backup set of tables that correspond in keys to `tables` + /// but with different data. + swapTables: Record>; + /// if false use `tables` and true use `swapTables` in the workspace + swap: boolean; +} + +export const WorkspaceApp: React.FC = () => { + const [state, setState] = React.useState({ + mounted: true, + tables: {}, + swapTables: {}, + config: { + sizes: [], + viewers: {}, + detail: undefined, + }, + swap: false, + }); + + const onClickAddViewer = React.useCallback(async () => { + const name = window.crypto.randomUUID(); + const data = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}`; + const swapData = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}\n${Math.random()},${Math.random()},${Math.random()}`; + // dont assign internal names to the tables they are not used by the workspace + const t = CLIENT.table(data); + const swap = CLIENT.table(swapData); + const config = Workspace.addViewer(state.config, { + table: name, + title: name, + }); + const tables = { ...state.tables, [name]: t }; + const swapTables = { ...state.swapTables, [name]: swap }; + setState({ + ...state, + tables, + config, + swapTables, + }); + }, [state]); + + const onLayoutUpdate: (detail: { + layout: PerspectiveWorkspaceConfig; + tables: Record>; + }) => void = React.useCallback( + ({ layout, tables }) => { + const newTables = Object.fromEntries( + Object.entries(tables).map(([k, v]) => [k, Promise.resolve(v)]) + ); + setState({ + ...state, + config: layout, + tables: state.swap ? state.tables : newTables, + swapTables: state.swap ? newTables : state.swapTables, + }); + }, + [state] + ); + + const onClickToggleMount = () => + setState((old) => ({ ...old, mounted: !state.mounted })); + + // swaps the tables out but uses the same name of them. + // this keeps the layout the same, but the data within each viewer changes + const swapTables = React.useCallback(() => { + setState({ + ...state, + swap: !state.swap, + }); + }, [state]); + + return ( +
+
+ + + +
+ {state.mounted && ( + + )} +
+ ); +}; diff --git a/packages/perspective-viewer-d3fc/test/js/line.spec.ts b/packages/perspective-viewer-d3fc/test/js/line.spec.ts index be3c5a761e..28dc16e4f2 100644 --- a/packages/perspective-viewer-d3fc/test/js/line.spec.ts +++ b/packages/perspective-viewer-d3fc/test/js/line.spec.ts @@ -122,7 +122,5 @@ test.describe("Line regressions", () => { 0 200 400`); - - await page.pause(); }); }); diff --git a/packages/perspective-workspace/build.js b/packages/perspective-workspace/build.js index f39bce6223..3f0d4d079e 100644 --- a/packages/perspective-workspace/build.js +++ b/packages/perspective-workspace/build.js @@ -152,6 +152,7 @@ async function build_all() { "inherit" ); } catch (e) { + console.error(e.stdout); process.exit(1); } } diff --git a/packages/perspective-workspace/package.json b/packages/perspective-workspace/package.json index f1b7e0e79e..4c5d378429 100644 --- a/packages/perspective-workspace/package.json +++ b/packages/perspective-workspace/package.json @@ -42,6 +42,7 @@ "@lumino/widgets": ">=2 <3", "@lumino/coreutils": ">=2 <3", "@lumino/signaling": ">=2 <3", + "async-mutex": "0.5.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/perspective-workspace/src/ts/extensions.ts b/packages/perspective-workspace/src/ts/extensions.ts new file mode 100644 index 0000000000..44e025fa58 --- /dev/null +++ b/packages/perspective-workspace/src/ts/extensions.ts @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { HTMLPerspectiveWorkspaceElement } from "./perspective-workspace"; + +type ReactPerspectiveWorkspaceAttributes = React.HTMLAttributes; + +type JsxPerspectiveWorkspaceElement = { + class?: string; +} & React.DetailedHTMLProps< + ReactPerspectiveWorkspaceAttributes, + HTMLPerspectiveWorkspaceElement +>; + +declare global { + namespace JSX { + interface IntrinsicElements { + "perspective-workspace": JsxPerspectiveWorkspaceElement; + } + } +} + +// Custom Elements extensions + +declare global { + interface Document { + createElement( + tagName: "perspective-workspace", + options?: ElementCreationOptions + ): HTMLPerspectiveWorkspaceElement; + querySelector(selectors: string): E | null; + querySelector( + selectors: "perspective-workspace" + ): HTMLPerspectiveWorkspaceElement | null; + } + + interface CustomElementRegistry { + get( + tagName: "perspective-workspace" + ): HTMLPerspectiveWorkspaceElement & typeof HTMLElement; + } +} diff --git a/packages/perspective-workspace/src/ts/perspective-workspace.ts b/packages/perspective-workspace/src/ts/perspective-workspace.ts index 4938387f6a..2580fdce60 100644 --- a/packages/perspective-workspace/src/ts/perspective-workspace.ts +++ b/packages/perspective-workspace/src/ts/perspective-workspace.ts @@ -15,10 +15,14 @@ import { Widget } from "@lumino/widgets"; import { HTMLPerspectiveViewerElement } from "@finos/perspective-viewer"; import type * as psp from "@finos/perspective"; -export { PerspectiveWorkspace } from "./workspace"; +export { + PerspectiveWorkspace, + ViewerConfigUpdateExt, + addViewer, +} from "./workspace"; export { PerspectiveViewerWidget } from "./workspace/widget"; -import "./external"; +export * from "./extensions"; import { PerspectiveWorkspace, PerspectiveWorkspaceConfig, @@ -28,6 +32,8 @@ import { bindTemplate, CustomElementProto } from "./utils/custom_elements"; import style from "../../build/css/workspace.css"; import template from "../html/workspace.html"; +export { PerspectiveWorkspaceConfig }; + /** * A Custom Element for coordinating a set of `` light DOM * children. `` is built on Lumino.js to allow a more @@ -144,8 +150,8 @@ export class HTMLPerspectiveWorkspaceElement extends HTMLElement { async clear() { await this.restore({ sizes: [], - master: { sizes: [] }, - detail: { sizes: [] }, + master: undefined, + detail: undefined, viewers: {}, }); } @@ -192,7 +198,7 @@ export class HTMLPerspectiveWorkspaceElement extends HTMLElement { } /** - * Replace a `Table` by name. As `Table` doe snot guarantee the same + * Replace a `Table` by name. As `Table` does not guarantee the same * structure, this will wipe the viewer's state. * @param name * @param table @@ -262,6 +268,11 @@ export class HTMLPerspectiveWorkspaceElement extends HTMLElement { } } + disconnectedCallback() { + // get perspective-indicator + // delete it. + } + /*************************************************************************** * * Private diff --git a/packages/perspective-workspace/src/ts/utils/observable_map.ts b/packages/perspective-workspace/src/ts/utils/observable_map.ts index 486bf65370..cc7b27ec85 100644 --- a/packages/perspective-workspace/src/ts/utils/observable_map.ts +++ b/packages/perspective-workspace/src/ts/utils/observable_map.ts @@ -25,12 +25,8 @@ export class ObservableMap extends Map { } delete(name: K) { - const result = this._delete_listener?.(name); - if (result) { - return super.delete(name); - } else { - return false; - } + this._delete_listener?.(name); + return super.delete(name); } addSetListener(listener: (name: K, val: V) => void) { diff --git a/packages/perspective-workspace/src/ts/workspace/dockpanel.ts b/packages/perspective-workspace/src/ts/workspace/dockpanel.ts index 25e25faa98..394cf17313 100644 --- a/packages/perspective-workspace/src/ts/workspace/dockpanel.ts +++ b/packages/perspective-workspace/src/ts/workspace/dockpanel.ts @@ -13,7 +13,14 @@ import { DockLayout, DockPanel, TabBar, Widget } from "@lumino/widgets"; import { PerspectiveTabBar } from "./tabbar"; import { PerspectiveTabBarRenderer } from "./tabbarrenderer"; -import { PerspectiveWorkspace } from "./workspace"; +import { + PerspectiveLayoutArea, + PerspectiveLayoutConfig, + PerspectiveSplitArea, + PerspectiveTabArea, + PerspectiveWorkspace, + PerspectiveWorkspaceConfig, +} from "./workspace"; import { PerspectiveViewerWidget } from "./widget"; class PerspectiveDockPanelRenderer extends DockPanel.Renderer { @@ -78,7 +85,7 @@ export class PerspectiveDockPanel extends DockPanel { } static getWidgets( - layout: DockPanel.ILayoutConfig + layout: PerspectiveLayoutConfig ): PerspectiveViewerWidget[] { if (!!layout.main) { return PerspectiveDockPanel.getAreaWidgets(layout.main); @@ -88,14 +95,14 @@ export class PerspectiveDockPanel extends DockPanel { } static getAreaWidgets( - layout: DockLayout.AreaConfig + layout: PerspectiveLayoutArea ): PerspectiveViewerWidget[] { - if (layout?.hasOwnProperty("children")) { + if (layout.type === "split-area") { const split_panel = layout as DockLayout.ISplitAreaConfig; return split_panel.children.flatMap((widget) => PerspectiveDockPanel.getAreaWidgets(widget) ); - } else if (layout?.hasOwnProperty("widgets")) { + } else if (layout.type === "tab-area") { const tab_panel = layout as DockLayout.ITabAreaConfig; return tab_panel.widgets as PerspectiveViewerWidget[]; } @@ -107,35 +114,48 @@ export class PerspectiveDockPanel extends DockPanel { return super.widgets() as IterableIterator; } - static mapWidgets( - widgetFunc: (widget: any) => any, - layout: any - ): DockPanel.ILayoutConfig { + /// transforms a layout, either a Lumino DockLayout or a PerspectiveWorkspaceConfig + /// each widget in the layout tree is passed to the mapping function + static mapWidgets( + widgetFunc: (widget: T) => U, + layout: PerspectiveLayoutConfig + ): PerspectiveLayoutConfig { if (!!layout.main) { - layout.main = PerspectiveDockPanel.mapAreaWidgets( - widgetFunc, - layout.main - ); + return { + ...layout, + main: PerspectiveDockPanel.mapAreaWidgets( + widgetFunc, + layout.main + ), + }; } - return layout; + // this is safe because without a main there are no `T`s or `U`s + // within the object, so it can be cast from one to the other. + return layout as unknown as PerspectiveLayoutConfig; } - static mapAreaWidgets( - widgetFunc: (widget: any) => any, - layout: DockLayout.AreaConfig - ): DockLayout.AreaConfig { - if (layout.hasOwnProperty("children")) { - const split_panel = layout as DockLayout.ISplitAreaConfig; - split_panel.children = split_panel.children.map((widget) => - PerspectiveDockPanel.mapAreaWidgets(widgetFunc, widget) - ); - } else if (layout.hasOwnProperty("widgets")) { - const tab_panel = layout as DockLayout.ITabAreaConfig; - tab_panel.widgets = tab_panel.widgets.map(widgetFunc); + static mapAreaWidgets( + widgetFunc: (widget: T) => U, + layout: PerspectiveLayoutArea + ): PerspectiveLayoutArea { + if (layout.type === "split-area") { + const split = layout as PerspectiveSplitArea; + return { + ...split, + children: split.children.map((w: PerspectiveLayoutArea) => + PerspectiveDockPanel.mapAreaWidgets(widgetFunc, w) + ), + }; + } else if (layout.type === "tab-area") { + const tab = layout as PerspectiveTabArea; + return { + ...tab, + widgets: tab.widgets.map(widgetFunc), + }; + } else { + throw new Error("Unknown layout type"); } - - return layout; } onAfterAttach() { diff --git a/packages/perspective-workspace/src/ts/workspace/widget.ts b/packages/perspective-workspace/src/ts/workspace/widget.ts index 0195fa2cf8..84ee75b048 100644 --- a/packages/perspective-workspace/src/ts/workspace/widget.ts +++ b/packages/perspective-workspace/src/ts/workspace/widget.ts @@ -15,6 +15,7 @@ import { Message } from "@lumino/messaging"; import type * as psp_viewer from "@finos/perspective-viewer"; import type * as psp from "@finos/perspective"; +import type { ViewerConfigUpdateExt } from "./workspace"; interface IPerspectiveViewerWidgetOptions { node: HTMLElement; @@ -75,7 +76,7 @@ export class PerspectiveViewerWidget extends Widget { } } - async save() { + async save(): Promise { let config = { ...(await this.viewer.save()), table: this.viewer.getAttribute("table"), diff --git a/packages/perspective-workspace/src/ts/workspace/workspace.ts b/packages/perspective-workspace/src/ts/workspace/workspace.ts index 363010db72..a3e5c5f104 100644 --- a/packages/perspective-workspace/src/ts/workspace/workspace.ts +++ b/packages/perspective-workspace/src/ts/workspace/workspace.ts @@ -12,10 +12,9 @@ import { find, toArray } from "@lumino/algorithm"; import { CommandRegistry } from "@lumino/commands"; -import { SplitPanel, Panel, DockPanel } from "@lumino/widgets"; +import { SplitPanel, Panel, DockPanel, Widget } from "@lumino/widgets"; import uniqBy from "lodash/uniqBy"; -import { DebouncedFunc, isEqual } from "lodash"; -import debounce from "lodash/debounce"; +import { isEqual } from "lodash"; import type { HTMLPerspectiveViewerElement, ViewerConfigUpdate, @@ -32,21 +31,155 @@ const DEFAULT_WORKSPACE_SIZE = [1, 3]; let ID_COUNTER = 0; -export interface PerspectiveLayout { - children?: PerspectiveLayout[]; - widgets?: T[]; +export interface PerspectiveSplitArea { + type: "split-area"; sizes: number[]; + orientation: "horizontal" | "vertical"; + children: PerspectiveLayoutArea[]; } +export interface PerspectiveTabArea { + type: "tab-area"; + currentIndex: number; + widgets: T[]; +} + +export type PerspectiveLayoutArea = + | PerspectiveSplitArea + | PerspectiveTabArea; + export interface ViewerConfigUpdateExt extends ViewerConfigUpdate { table: string; } +/// This is a supertype of Lumino's ILayoutConfig that allows the +/// widgets to be differently typed. Luminos DockLayout can be a +/// PerspectiveLayoutArea, Perspective uses PerspectiveLayoutArea +export interface PerspectiveLayoutConfig { + main: PerspectiveLayoutArea | null; +} + export interface PerspectiveWorkspaceConfig { sizes: number[]; - master: PerspectiveLayout; - detail: PerspectiveLayout; viewers: Record; + detail: PerspectiveLayoutConfig | undefined; + master?: { + sizes: number[]; + widgets: T[]; + }; +} + +function genId(workspace: PerspectiveWorkspaceConfig): string { + let i = `PERSPECTIVE_GENERATED_ID_${ID_COUNTER++}`; + if (Object.keys(workspace.viewers).includes(i)) { + i = genId(workspace); + } + return i; +} + +/// This function takes a workspace config and viewer config and adds the +/// viewer config to the workspace config, returning a new workspace config. +/// This is a slightly different algorithm from the Lumino one, +/// which will be used on internal workspace actions (such as duplication). +/// It currently attaches the viewer using a split-right style, +/// (see Lumino docklayout.ts for documentation on insert modes). +export function addViewer( + workspace: PerspectiveWorkspaceConfig, + config: ViewerConfigUpdateExt +): PerspectiveWorkspaceConfig { + const GOLDEN_RATIO = 0.618; + const id = genId(workspace); + /// ensures that the sum of the input is 1 + /// keeps the relative size of the elements + function normalize(sizes: number[]) { + const sum = sizes.reduce((a, b) => a + b, 0); + return sum === 1 ? sizes : sizes.map((size) => size / sum); + } + + if (!workspace.detail || workspace.detail.main === null) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: [1], + orientation: "horizontal", + children: [ + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else if ( + workspace.detail.main.type === "tab-area" || + (workspace.detail.main.type === "split-area" && + workspace.detail.main.orientation === "vertical") + ) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: [0.5, 0.5], + orientation: "horizontal", + children: [ + workspace.detail.main, + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else if ( + workspace.detail.main.type === "split-area" && + workspace.detail.main.orientation === "horizontal" + ) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: normalize([ + ...normalize(workspace.detail.main.sizes), + GOLDEN_RATIO, + ]), + orientation: "horizontal", + children: [ + ...workspace.detail.main.children, + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else { + throw new Error("Unknown workspace state"); + } } export class PerspectiveWorkspace extends SplitPanel { @@ -60,8 +193,8 @@ export class PerspectiveWorkspace extends SplitPanel { private indicator: HTMLElement; private commands: CommandRegistry; private _menu?: WorkspaceMenu; - private _minimizedLayoutSlots?: DockPanel.ILayoutConfig; - private _minimizedLayout?: DockPanel.ILayoutConfig; + private _minimizedLayoutSlots?: PerspectiveLayoutConfig; + private _minimizedLayout?: PerspectiveLayoutConfig; private _maximizedWidget?: PerspectiveViewerWidget; private _last_updated_state?: PerspectiveWorkspaceConfig; // private _context_menu?: Menu & { init_overlay?: () => void }; @@ -115,7 +248,7 @@ export class PerspectiveWorkspace extends SplitPanel { const indicator = document.createElement("perspective-indicator"); indicator.style.position = "fixed"; indicator.style.pointerEvents = "none"; - document.body.appendChild(indicator); + this.node.appendChild(indicator); return indicator; } @@ -156,7 +289,7 @@ export class PerspectiveWorkspace extends SplitPanel { return this._tables; } - async save() { + async save(): Promise> { const is_settings = this.dockpanel.mode === "single-document"; let detail = is_settings ? this._minimizedLayoutSlots @@ -165,7 +298,7 @@ export class PerspectiveWorkspace extends SplitPanel { // this.getWidgetByName(widget)!.viewer.getAttribute("slot") (widget as PerspectiveViewerWidget).viewer.getAttribute( "slot" - ), + )!, this.dockpanel.saveLayout() ); @@ -190,7 +323,7 @@ export class PerspectiveWorkspace extends SplitPanel { layout.master = master; } - const viewers: Record = {}; + const viewers: Record = {}; for (const widget of this.masterPanel.widgets) { const psp_widget = widget as PerspectiveViewerWidget; viewers[psp_widget.viewer.getAttribute("slot")!] = @@ -203,9 +336,8 @@ export class PerspectiveWorkspace extends SplitPanel { await Promise.all( widgets.map(async (widget) => { - const psp_widget = widget as PerspectiveViewerWidget; - const slot = psp_widget.viewer.getAttribute("slot")!; - viewers[slot] = await psp_widget.save(); + const slot = widget.viewer.getAttribute("slot")!; + viewers[slot] = await widget.save(); viewers[slot]!.settings = false; }) ); @@ -221,7 +353,7 @@ export class PerspectiveWorkspace extends SplitPanel { viewers: viewer_configs = {}, } = structuredClone(value); - if (master && master.widgets!.length > 0) { + if (master && master.widgets && master.widgets.length > 0) { this.setupMasterPanel(sizes || DEFAULT_WORKSPACE_SIZE); } else { if (this.masterPanel.isAttached) { @@ -506,8 +638,10 @@ export class PerspectiveWorkspace extends SplitPanel { widget.viewer.classList.add("widget-maximize"); this._minimizedLayout = this.dockpanel.saveLayout(); this._minimizedLayoutSlots = PerspectiveDockPanel.mapWidgets( - (widget: PerspectiveViewerWidget) => - widget.viewer.getAttribute("slot"), + (widget: Widget) => + (widget as PerspectiveViewerWidget).viewer.getAttribute( + "slot" + )!, this.dockpanel.saveLayout() ); @@ -978,7 +1112,7 @@ export class PerspectiveWorkspace extends SplitPanel { const updated = async (event: CustomEvent) => { this.workspaceUpdated(); // Sometimes plugins or other external code fires this event and - // does not populate this field! + // does not populate this field! const config = typeof event.detail === "undefined" ? await widget.viewer.save() diff --git a/packages/perspective-workspace/test/js/table.spec.js b/packages/perspective-workspace/test/js/table.spec.js index 944d71724c..5a19cd88d5 100644 --- a/packages/perspective-workspace/test/js/table.spec.js +++ b/packages/perspective-workspace/test/js/table.spec.js @@ -111,6 +111,27 @@ function tests(context, compare) { `${context}-replace-table-works-with-errored-table.txt` ); }); + + test("removeTable() smoke test", async ({ page }) => { + const tables = await page.evaluate(async () => { + const table = await window.__WORKER__.table("x\n1\n"); + const workspace = document.getElementById("workspace"); + await workspace.addTable("temptable", table); + return Array.from(workspace.tables.keys()); + }); + + expect(tables).toEqual(["superstore", "temptable"]); + const result = await page.evaluate(async () => { + return await workspace.removeTable("temptable"); + }); + expect(result).toBe(true); + + const tablesAfterRemove = await page.evaluate(async () => { + const workspace = document.getElementById("workspace"); + return Array.from(workspace.tables.keys()); + }); + expect(tablesAfterRemove).toEqual(["superstore"]); + }); } test.describe("Workspace table functions", () => { diff --git a/packages/perspective-workspace/tsconfig.json b/packages/perspective-workspace/tsconfig.json index c272ca5fa1..8235b5aaab 100644 --- a/packages/perspective-workspace/tsconfig.json +++ b/packages/perspective-workspace/tsconfig.json @@ -9,5 +9,5 @@ "rootDir": "./src/ts", "moduleResolution": "bundler" }, - "files": ["src/ts/perspective-workspace.ts"] + "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d067c0815d..880224531d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -371,6 +371,46 @@ importers: specifier: ^14.1.1 version: 14.1.1 + examples/react-workspace-example: + dependencies: + '@finos/perspective': + specifier: workspace:^ + version: link:../../rust/perspective-js + '@finos/perspective-react': + specifier: workspace:^ + version: link:../../packages/perspective-react + '@finos/perspective-viewer': + specifier: workspace:^ + version: link:../../rust/perspective-viewer + '@finos/perspective-viewer-d3fc': + specifier: workspace:^ + version: link:../../packages/perspective-viewer-d3fc + '@finos/perspective-viewer-datagrid': + specifier: workspace:^ + version: link:../../packages/perspective-viewer-datagrid + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) + superstore-arrow: + specifier: ^3.0.0 + version: 3.0.0 + devDependencies: + '@types/react': + specifier: ^18 + version: 18.3.8 + '@types/react-dom': + specifier: ^18 + version: 18.3.5(@types/react@18.3.8) + esbuild: + specifier: ^0.25.5 + version: 0.25.5 + http-server: + specifier: ^14.1.1 + version: 14.1.1 + examples/rust-axum: {} examples/vite-example: @@ -418,10 +458,10 @@ importers: version: 0.28.11 file-loader: specifier: ^5 - version: 5.1.0(webpack@5.97.1) + version: 5.1.0(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) html-webpack-plugin: specifier: ^5.1.0 - version: 5.6.0(webpack@5.97.1) + version: 5.6.0(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) style-loader: specifier: ^0.18.2 version: 0.18.2 @@ -523,9 +563,15 @@ importers: '@finos/perspective-viewer': specifier: workspace:^ version: link:../../rust/perspective-viewer + '@finos/perspective-workspace': + specifier: workspace:^ + version: link:../perspective-workspace '@types/react': specifier: ^18 version: 18.3.8 + async-mutex: + specifier: 0.5.0 + version: 0.5.0 react: specifier: ^18 version: 18.3.1 @@ -692,6 +738,9 @@ importers: '@lumino/widgets': specifier: '>=2 <3' version: 2.5.0 + async-mutex: + specifier: 0.5.0 + version: 0.5.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -3820,6 +3869,9 @@ packages: async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} @@ -13632,7 +13684,7 @@ snapshots: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1(esbuild@0.25.5)) - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) @@ -13642,7 +13694,7 @@ snapshots: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1(esbuild@0.25.5)) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) @@ -13652,7 +13704,7 @@ snapshots: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1(esbuild@0.25.5)) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.97.1) @@ -13845,6 +13897,10 @@ snapshots: async-limiter@1.0.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async@2.6.4: dependencies: lodash: 4.17.21 @@ -14591,7 +14647,7 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.6.3 optionalDependencies: - webpack: 5.97.1(esbuild@0.25.5) + webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(esbuild@0.25.5)(webpack@5.97.1(esbuild@0.25.5)): dependencies: @@ -15656,7 +15712,7 @@ snapshots: flat-cache: 4.0.1 optional: true - file-loader@5.1.0(webpack@5.97.1): + file-loader@5.1.0(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)): dependencies: loader-utils: 1.4.2 schema-utils: 2.7.1 @@ -16220,7 +16276,7 @@ snapshots: html-void-elements@3.0.0: {} - html-webpack-plugin@5.6.0(webpack@5.97.1(esbuild@0.25.5)): + html-webpack-plugin@5.6.0(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -16228,9 +16284,9 @@ snapshots: pretty-error: 4.0.0 tapable: 2.2.1 optionalDependencies: - webpack: 5.97.1(esbuild@0.25.5) + webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) - html-webpack-plugin@5.6.0(webpack@5.97.1): + html-webpack-plugin@5.6.0(webpack@5.97.1(esbuild@0.25.5)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -16238,7 +16294,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.2.1 optionalDependencies: - webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) + webpack: 5.97.1(esbuild@0.25.5) htmlparser2@6.1.0: dependencies: @@ -17568,7 +17624,7 @@ snapshots: dependencies: schema-utils: 4.3.0 tapable: 2.2.1 - webpack: 5.97.1(esbuild@0.25.5) + webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) mini-svg-data-uri@1.4.4: {} @@ -19842,25 +19898,25 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 - terser-webpack-plugin@5.3.11(esbuild@0.25.5)(webpack@5.97.1(esbuild@0.25.5)): + terser-webpack-plugin@5.3.11(esbuild@0.25.5)(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(esbuild@0.25.5) + webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) optionalDependencies: esbuild: 0.25.5 - terser-webpack-plugin@5.3.11(esbuild@0.25.5)(webpack@5.97.1): + terser-webpack-plugin@5.3.11(esbuild@0.25.5)(webpack@5.97.1(esbuild@0.25.5)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4) + webpack: 5.97.1(esbuild@0.25.5) optionalDependencies: esbuild: 0.25.5 @@ -20306,9 +20362,9 @@ snapshots: webpack-cli@5.1.4(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.97.1) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack@5.97.1))(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -20440,7 +20496,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(esbuild@0.25.5)(webpack@5.97.1) + terser-webpack-plugin: 5.3.11(esbuild@0.25.5)(webpack@5.97.1(esbuild@0.25.5)(webpack-cli@5.1.4)) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: diff --git a/rust/perspective-server/cpp/perspective/build.mjs b/rust/perspective-server/cpp/perspective/build.mjs new file mode 100644 index 0000000000..cc781e06e8 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/build.mjs @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { execSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import * as url from "node:url"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +const stdio = "inherit"; +const env = process.env.PSP_DEBUG ? "debug" : "release"; +const cwd = path.join(process.cwd(), "dist", env); + +const { compress } = await import("pro_self_extracting_wasm"); + +delete process.env.NODE; + +let cmake_flags = ""; +let make_flags = ""; + +if (!!process.env.PSP_BUILD_VERBOSE) { + cmake_flags += "-Wdev --debug-output "; + make_flags += "VERBOSE=1 "; +} else { + cmake_flags = "-Wno-dev "; // suppress developer warnings +} + +try { + execSync(`mkdirp ${cwd}`, { stdio }); + process.env.CLICOLOR_FORCE = 1; + execSync( + `emcmake cmake ${__dirname} ${cmake_flags} -DCMAKE_BUILD_TYPE=${env} -DRAPIDJSON_BUILD_EXAMPLES=OFF`, + { + cwd, + stdio, + } + ); + + execSync( + `emmake make -j${process.env.PSP_NUM_CPUS || os.cpus().length + } ${make_flags}`, + { + cwd, + stdio, + } + ); + + execSync(`cpy web/**/* ../web`, { cwd, stdio }); + execSync(`cpy node/**/* ../node`, { cwd, stdio }); + if (!process.env.PSP_HEAP_INSTRUMENTS) { + compress( + `../../cpp/perspective/dist/web/perspective-server.wasm`, + `../../cpp/perspective/dist/web/perspective-server.wasm` + ); + } +} catch (e) { + console.error(e); + process.exit(1); +} diff --git a/rust/perspective-server/cpp/perspective/package.json b/rust/perspective-server/cpp/perspective/package.json new file mode 100644 index 0000000000..330a6ea205 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/package.json @@ -0,0 +1,19 @@ +{ + "name": "@finos/perspective-cpp", + "private": true, + "author": "The Perspective Authors", + "license": "Apache-2.0", + "version": "3.7.4", + "main": "./dist/esm/perspective.cpp.js", + "files": [ + "dist/esm/**/*", + "dist/cjs/**/*" + ], + "scripts": { + "build": "node ../../tools/perspective-scripts/run_emsdk.mjs node ./build.mjs", + "clean": "rimraf dist" + }, + "devDependencies": { + "pro_self_extracting_wasm": "0.0.9" + } +} diff --git a/rust/perspective-server/cpp/protos/perspective.proto b/rust/perspective-server/cpp/protos/perspective.proto new file mode 100644 index 0000000000..459192e2b4 --- /dev/null +++ b/rust/perspective-server/cpp/protos/perspective.proto @@ -0,0 +1,540 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +syntax = "proto3"; + +import "google/protobuf/struct.proto"; + +package perspective.proto; + +option optimize_for = LITE_RUNTIME; + +//////////////////////////////////////////////////////////////////////////////// +// +// Common + +enum StatusCode { + SERVER_ERROR = 0; + VIEW_NOT_FOUND = 1; + TRANSPORT_ERROR = 2; +} + +// Recoverable, user-readable error reporting from the engine. +message ServerError { + string message = 1; + StatusCode status_code = 2; +} + +message Schema { + repeated KeyTypePair schema = 1; + message KeyTypePair { + string name = 1; + ColumnType type = 2; + } +} + +// The data type constructors Perspective supports. +message MakeTableData { + oneof data { + Schema from_schema = 1; + string from_csv = 2; + bytes from_arrow = 3; + string from_rows = 4; + string from_cols = 5; + string from_view = 6; + string from_ndjson = 7; + }; +} + +// Filter type scalars - this is _not_ the same as a Columns scalar, as this +// value is used in the view config and must be JSON safe! +message Scalar { + oneof scalar { + bool bool = 1; + // int64 date = 2; // TODO these are the wrong type + // int64 datetime = 3; + double float = 4; + // int32 int = 5; + string string = 6; + google.protobuf.NullValue null = 7; + } +} + +// View types +enum ColumnType { + STRING = 0; + DATE = 1; + DATETIME = 2; + INTEGER = 3; + FLOAT = 4; + BOOLEAN = 5; +} + +// Options for requresting a slice of data, starting with the rectangular +// viewport. +message ViewPort { + optional uint32 start_row = 1; + optional uint32 start_col = 2; + optional uint32 end_row = 3; + optional uint32 end_col = 4; +// optional bool id = 5; +// optional bool index = 3; +// optional bool formatted = 6; +// optional bool leaves_only = 7; +// optional bool compression = 3; +} + +// TODO This belongs in features +enum SortOp { + SORT_NONE = 0; + SORT_ASC = 1; + SORT_DESC = 2; + SORT_COL_ASC = 3; + SORT_COL_DESC = 4; + SORT_ASC_ABS = 5; + SORT_DESC_ABS = 6; + SORT_COL_ASC_ABS = 7; + SORT_COL_DESC_ABS = 8; +} + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RPC + +message Request { + uint32 msg_id = 1; + string entity_id = 2; + oneof client_req { + // Minimum Virtual API (theoretical). + GetFeaturesReq get_features_req = 3; + GetHostedTablesReq get_hosted_tables_req = 4; + RemoveHostedTablesUpdateReq remove_hosted_tables_update_req = 37; + TableMakePortReq table_make_port_req = 5; + TableMakeViewReq table_make_view_req = 6; + TableSchemaReq table_schema_req = 7; + TableSizeReq table_size_req = 8; + TableValidateExprReq table_validate_expr_req = 9; + ViewColumnPathsReq view_column_paths_req = 10; + ViewDeleteReq view_delete_req = 11; + ViewDimensionsReq view_dimensions_req = 12; + ViewExpressionSchemaReq view_expression_schema_req = 13; + ViewGetConfigReq view_get_config_req = 14; + ViewSchemaReq view_schema_req = 15; + ViewToArrowReq view_to_arrow_req = 16; + + // Optional (we can enable real-time/autocomplete/etc with these, but + // not required). + ServerSystemInfoReq server_system_info_req = 17; + ViewCollapseReq view_collapse_req = 18; + ViewExpandReq view_expand_req = 19; + ViewGetMinMaxReq view_get_min_max_req = 20; + ViewOnUpdateReq view_on_update_req = 21; + ViewRemoveOnUpdateReq view_remove_on_update_req = 22; + ViewSetDepthReq view_set_depth_req = 23; + ViewToColumnsStringReq view_to_columns_string_req = 24; + ViewToCSVReq view_to_csv_req = 25; + ViewToRowsStringReq view_to_rows_string_req = 26; + ViewToNdjsonStringReq view_to_ndjson_string_req = 36; + + // External (we don't need these for viewer, but the developer may). + MakeTableReq make_table_req = 27; + TableDeleteReq table_delete_req = 28; + TableOnDeleteReq table_on_delete_req = 29; + TableRemoveDeleteReq table_remove_delete_req = 30; + TableRemoveReq table_remove_req = 31; + TableReplaceReq table_replace_req = 32; + TableUpdateReq table_update_req = 33; + ViewOnDeleteReq view_on_delete_req = 34; + ViewRemoveDeleteReq view_remove_delete_req = 35; + } +} + +message Response { + uint32 msg_id = 1; + string entity_id = 2; + oneof client_resp { + GetFeaturesResp get_features_resp = 3; + GetHostedTablesResp get_hosted_tables_resp = 4; + RemoveHostedTablesUpdateResp remove_hosted_tables_update_resp = 37; + TableMakePortResp table_make_port_resp = 5; + TableMakeViewResp table_make_view_resp = 6; + TableSchemaResp table_schema_resp = 7; + TableSizeResp table_size_resp = 8; + TableValidateExprResp table_validate_expr_resp = 9; + ViewColumnPathsResp view_column_paths_resp = 10; + ViewDeleteResp view_delete_resp = 11; + ViewDimensionsResp view_dimensions_resp = 12; + ViewExpressionSchemaResp view_expression_schema_resp = 13; + ViewGetConfigResp view_get_config_resp = 14; + ViewSchemaResp view_schema_resp = 15; + ViewToArrowResp view_to_arrow_resp = 16; + ServerSystemInfoResp server_system_info_resp = 17; + ViewCollapseResp view_collapse_resp = 18; + ViewExpandResp view_expand_resp = 19; + ViewGetMinMaxResp view_get_min_max_resp = 20; + ViewOnUpdateResp view_on_update_resp = 21; + ViewRemoveOnUpdateResp view_remove_on_update_resp = 22; + ViewSetDepthResp view_set_depth_resp = 23; + ViewToColumnsStringResp view_to_columns_string_resp = 24; + ViewToCSVResp view_to_csv_resp = 25; + ViewToRowsStringResp view_to_rows_string_resp = 26; + ViewToNdjsonStringResp view_to_ndjson_string_resp = 36; + MakeTableResp make_table_resp = 27; + TableDeleteResp table_delete_resp = 28; + TableOnDeleteResp table_on_delete_resp = 29; + TableRemoveDeleteResp table_remove_delete_resp = 30; + TableRemoveResp table_remove_resp = 31; + TableReplaceResp table_replace_resp = 32; + TableUpdateResp table_update_resp = 33; + ViewOnDeleteResp view_on_delete_resp = 34; + ViewRemoveDeleteResp view_remove_delete_resp = 35; + ServerError server_error = 50; + } +} + +//////////////////////////////////////////////////////////////////////////////// +// +// Virtual API + +// Informs the client of the feature set, e.g. what to expect in the +// `ViewConfig` message. +message GetFeaturesReq {} +message GetFeaturesResp { + bool group_by = 1; + bool split_by = 2; + bool expressions = 3; + map filter_ops = 4; + + message ColumnTypeOptions { + repeated string options = 1; + } +} + +// `Client::get_hosted_tables` +message GetHostedTablesReq { + bool subscribe = 1; +} + +message GetHostedTablesResp { + repeated HostedTable table_infos = 1; +} + +message HostedTable { + string entity_id = 1; + optional string index = 2; + optional uint32 limit = 3; +} + +message RemoveHostedTablesUpdateReq { + uint32 id = 1; +} +message RemoveHostedTablesUpdateResp {} + +// `Table::size` +message TableSizeReq {} +message TableSizeResp { + uint32 size = 2; +} + +// `Table::schema` +message TableSchemaReq {} +message TableSchemaResp { + Schema schema = 1; +} + +// `Table::validate_expressions` +// TODO: This should be just `validate()` +message TableValidateExprReq { + map column_to_expr = 1; +} +message TableValidateExprResp { + map expression_schema = 1; + map errors = 2; + map expression_alias = 3; + message ExprValidationError { + string error_message = 1; + uint32 line = 2; + uint32 column = 3; + } +} + +// `Table::view` +message TableMakeViewReq { + string view_id = 1; + ViewConfig config = 2; +} +message TableMakeViewResp { + string view_id = 1; +} + +// `View::schema` +message ViewSchemaReq {} +message ViewSchemaResp { + map schema = 1; +} + +// `View::dimensions` +message ViewDimensionsReq {} +message ViewDimensionsResp { + uint32 num_table_rows = 1; + uint32 num_table_columns = 2; + uint32 num_view_rows = 3; + uint32 num_view_columns = 4; +} + +// `View::get_config` +message ViewGetConfigReq {} +message ViewGetConfigResp { + ViewConfig config = 1; +} + + +//////////////////////////////////////////////////////////////////////////////// + +// `Client::table`. +message MakeTableReq { + MakeTableData data = 1; + optional MakeTableOptions options = 2; + message MakeTableOptions { + oneof make_table_type { + string make_index_table = 1; + uint32 make_limit_table = 2; + }; + } +} +message MakeTableResp {} + +// `Table::delete` +message TableDeleteReq { + bool is_immediate = 1; +} +message TableDeleteResp {} + +// `Table::on_delete` +message TableOnDeleteReq {} +message TableOnDeleteResp {} + +// `Table::make_port` +message TableMakePortReq {} +message TableMakePortResp { + uint32 port_id = 1; +} + +// `Table::remove_delete` +message TableRemoveDeleteReq { + uint32 id = 1; +} +message TableRemoveDeleteResp {} + +// `Table::update` +message TableUpdateReq { + MakeTableData data = 1; + uint32 port_id = 2; +} +message TableUpdateResp {} + +// `Table::replace` +message TableReplaceReq { + MakeTableData data = 1; +} +message TableReplaceResp {} + +// `Table::remove` +message TableRemoveReq { + MakeTableData data = 1; +} +message TableRemoveResp {} + +message ViewOnUpdateReq { + enum Mode { + ROW = 0; + } + optional Mode mode = 1; +} +message ViewOnUpdateResp { + optional bytes delta = 1; + uint32 port_id = 2; +} + +message ViewOnDeleteReq {} +message ViewOnDeleteResp {} + +message ViewRemoveDeleteReq { + uint32 id = 1; +} +message ViewRemoveDeleteResp {} + +message ViewToColumnsStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; + optional bool leaves_only = 5; +} + +message ViewToColumnsStringResp { + string json_string = 1; +} + +message ViewToRowsStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; + optional bool leaves_only = 5; +} + +message ViewToRowsStringResp { + string json_string = 1; +} + +message ViewToNdjsonStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; + optional bool leaves_only = 5; +} + +message ViewToNdjsonStringResp { + string ndjson_string = 1; +} + +message ViewToArrowReq { + ViewPort viewport = 1; + optional string compression = 2; +} + +message ViewToArrowResp { + bytes arrow = 1; +} + +message ViewColumnPathsReq {} + +// // TODO This is a better paths representations but its not compatible with +// // the legacy API. Let's do this when we can fix the API. + +// message ColumnPath { +// repeated string path = 1; +// } + +message ViewColumnPathsResp { + repeated string paths = 1; + // repeated ColumnPath paths = 1; +} + +message ViewDeleteReq {} +message ViewDeleteResp {} + +message ViewGetMinMaxReq { + string column_name = 1; +} + +message ViewGetMinMaxResp { + string min = 1; + string max = 2; +} + + +message ViewExpressionSchemaReq {} +message ViewExpressionSchemaResp { + map schema = 1; +} + +message ViewToCSVReq { + ViewPort viewport = 1; +} + +message ViewToCSVResp { + string csv = 1; +} + +message ViewRemoveOnUpdateReq { + uint32 id = 1; +} +message ViewRemoveOnUpdateResp {} + +message ViewCollapseReq { + uint32 row_index = 1; +} + +message ViewCollapseResp { + uint32 num_changed = 1; +} + +message ViewExpandReq { + uint32 row_index = 1; +} + +message ViewExpandResp { + uint32 num_changed = 1; +} + +// `View::set_depth` +message ViewSetDepthReq { + uint32 depth = 1; +} +message ViewSetDepthResp {} + +message ServerSystemInfoReq {} +message ServerSystemInfoResp { + uint64 heap_size = 1; + uint64 used_size = 2; + uint32 cpu_time = 3; + uint32 cpu_time_epoch = 4; +} + + +message ViewConfig { + repeated string group_by = 1; + repeated string split_by = 2; + ColumnsUpdate columns = 3; + repeated Filter filter = 4; + repeated Sort sort = 5; + map expressions = 6; + map aggregates = 7; + FilterReducer filter_op = 8; + optional uint32 group_by_depth = 9; + + message AggList { + repeated string aggregations = 1; + } + + message Sort { + string column = 1; + SortOp op = 2; + } + + message Filter { + string column = 1; + string op = 2; + repeated Scalar value = 3; + } + + enum FilterReducer { + AND = 0; + OR = 1; + } +} + +message ColumnsUpdate { + oneof opt_columns { + google.protobuf.NullValue default_columns = 1; + Columns columns = 2; + } + + message Columns { + repeated string columns = 1; + } +} diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index 0b1da34310..95164a538b 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -262,7 +262,7 @@ impl PerspectiveViewerElement { /// Restart this `` to its initial state, before /// `load()`. /// - /// Use `Self::restart` if you plan to call `Self::load` on this viewer + /// Use `Self::eject` if you plan to call `Self::load` on this viewer /// again, or alternatively `Self::delete` if this viewer is no longer /// needed. pub fn eject(&mut self) -> ApiFuture<()> { diff --git a/tools/perspective-test/results.tar.gz b/tools/perspective-test/results.tar.gz index 26eabb4d58..e45eab8311 100644 Binary files a/tools/perspective-test/results.tar.gz and b/tools/perspective-test/results.tar.gz differ