diff --git a/.gitignore b/.gitignore index b5fd3672b0..5823b03643 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ docs/static/browser docs/static/viewer docs/static/react rust/perspective-server/build +target/ diff --git a/packages/react/src/workspace.tsx b/packages/react/src/workspace.tsx index e0b392db8e..a8de72df4e 100644 --- a/packages/react/src/workspace.tsx +++ b/packages/react/src/workspace.tsx @@ -27,7 +27,8 @@ export interface ToggleGloalFilterEventDetail { isGlobalFilter: boolean; } -interface PerspectiveWorkspaceProps extends React.HTMLAttributes { +export interface PerspectiveWorkspaceProps + extends React.HTMLAttributes { client: psp.Client | Promise; layout: PerspectiveWorkspaceConfig; onLayoutUpdate?: (layout: PerspectiveWorkspaceConfig) => void; diff --git a/packages/viewer-d3fc/src/ts/tooltip/selectionEvent.ts b/packages/viewer-d3fc/src/ts/tooltip/selectionEvent.ts index d2c808073f..d9387082af 100644 --- a/packages/viewer-d3fc/src/ts/tooltip/selectionEvent.ts +++ b/packages/viewer-d3fc/src/ts/tooltip/selectionEvent.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { getGroupValues, getSplitValues, getDataValues } from "./selectionData"; +import { PerspectiveSelectDetail } from "@perspective-dev/viewer"; const mapToFilter = (d) => [d.name, "==", d.value]; @@ -19,15 +20,18 @@ export const raiseEvent = (node, data, settings) => { const groupFilters = getGroupValues(data, settings).map(mapToFilter); const splitFilters = getSplitValues(data, settings).map(mapToFilter); const filter = settings.filter.concat(groupFilters).concat(splitFilters); + const detail = new PerspectiveSelectDetail( + true, + data === null ? null : data?.row, + column_names, + [], + [{ filter }], + ); node.dispatchEvent( new CustomEvent("perspective-select", { bubbles: true, composed: true, - detail: { - column_names, - config: { filter }, - row: data === null ? null : data?.row, - }, + detail, }), ); }; diff --git a/packages/viewer-datagrid/src/ts/event_handlers/row_select_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/row_select_click.ts index 193e00210a..7891a9e8ec 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/row_select_click.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/row_select_click.ts @@ -11,11 +11,11 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import getCellConfig from "../get_cell_config.js"; -import type { - RegularTable, - DatagridModel, - PerspectiveViewerElement, - HandledMouseEvent, +import { + type RegularTable, + type DatagridModel, + type PerspectiveViewerElement, + type HandledMouseEvent, PerspectiveSelectDetail, } from "../types.js"; @@ -52,28 +52,31 @@ export async function selectionListener( const is_deselect = !!selected && id.length === selected.length && key_match; - let detail: PerspectiveSelectDetail = { - selected: !is_deselect, - row: {}, - config: { filter: [] }, - }; - const { row, column_names, config } = await getCellConfig( this, meta.y, meta.type === "body" ? meta.x : 0, ); + let detail: PerspectiveSelectDetail; if (is_deselect) { selected_rows_map.delete(regularTable); - detail = { - ...detail, + detail = new PerspectiveSelectDetail( + false, row, - config: { filter: structuredClone(this._config.filter) }, - }; + [], + [], + [{ filter: structuredClone(this._config.filter) }], + ); } else { selected_rows_map.set(regularTable, id); - detail = { ...detail, row, column_names, config }; + detail = new PerspectiveSelectDetail( + true, + row, + column_names, + [], + [config], + ); } await regularTable.draw({ preserve_width: true }); diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts index fb0dede2c6..6f15e0985b 100644 --- a/packages/viewer-datagrid/src/ts/types.ts +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -17,6 +17,7 @@ import type { ColumnType, SortDir, ViewWindow, + ViewConfigUpdate, } from "@perspective-dev/client"; import { RegularTableElement } from "regular-table"; import { CellMetadata, DataResponse } from "regular-table/dist/esm/types"; @@ -244,7 +245,7 @@ export type FormatterCache = Map; export interface CellConfigResult { row: Record; column_names: string[]; - config: Partial; + config: ViewConfigUpdate; } // Custom event detail types @@ -254,12 +255,7 @@ export interface PerspectiveClickDetail { config: Partial; } -export interface PerspectiveSelectDetail { - selected: boolean; - row: Record; - column_names?: string[]; - config: Partial; -} +export { PerspectiveSelectDetail } from "@perspective-dev/viewer"; // Mouse event with handled flag export interface HandledMouseEvent extends MouseEvent { diff --git a/packages/workspace/src/ts/workspace/workspace.ts b/packages/workspace/src/ts/workspace/workspace.ts index a6eb9fc4b1..e7de217f56 100644 --- a/packages/workspace/src/ts/workspace/workspace.ts +++ b/packages/workspace/src/ts/workspace/workspace.ts @@ -683,20 +683,23 @@ export class PerspectiveWorkspace extends SplitPanel { async _filterViewer( viewer: HTMLPerspectiveViewerElement, - filters: [string, string, string][], + removeFilters: psp.Filter[], + insertFilters: psp.Filter[], candidates: Set, ) { const config = await viewer.save(); const table = await viewer.getTable(); const availableColumns = Object.keys(await table.schema()); const currentFilters = config.filter || []; - const columnAvailable = (filter: [string, string, any]) => + const columnAvailable = (filter: psp.Filter) => filter[0] && availableColumns.includes(filter[0]); - const validFilters = filters.filter(columnAvailable); + const clearColumns = new Set(removeFilters.map((f) => f[0])); + const validFilters = insertFilters.filter(columnAvailable); validFilters.push( ...currentFilters.filter( - (x: [string, ..._: string[]]) => !candidates.has(x[0]), + (x: [string, ..._: string[]]) => + !candidates.has(x[0]) && !clearColumns.has(x[0]), ), ); @@ -712,14 +715,21 @@ export class PerspectiveWorkspace extends SplitPanel { const candidates = new Set([ ...(config["group_by"] || []), ...(config["split_by"] || []), - ...(config.filter || []).map((x: [string, string, any]) => x[0]), + ...(config.filter || []).map((x: psp.Filter) => x[0]), ]); - const filters = [...event.detail.config.filter]; + const removeFilters = ( + (event.detail.removeConfigs ?? []) as psp.ViewConfigUpdate[] + ).flatMap((x) => x.filter ?? []); + const insertFilters = ( + (event.detail.insertConfigs ?? []) as psp.ViewConfigUpdate[] + ).flatMap((x) => x.filter ?? []); + toArray(this.dockpanel.widgets()).forEach((widget) => { this._filterViewer( (widget as PerspectiveViewerWidget).viewer, - filters, + removeFilters, + insertFilters, candidates, ); }); diff --git a/packages/workspace/test/js/global_filter.spec.js b/packages/workspace/test/js/global_filter.spec.js index dac88db91d..b577835245 100644 --- a/packages/workspace/test/js/global_filter.spec.js +++ b/packages/workspace/test/js/global_filter.spec.js @@ -23,6 +23,12 @@ test.beforeEach(async ({ page }) => { await new Promise((x) => setTimeout(x, 10)); } }); + await page.evaluate(async () => { + const { PerspectiveSelectDetail } = await import( + "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js" + ); + window.PerspectiveSelectDetail = PerspectiveSelectDetail; + }); }); function tests(context, compare) { @@ -141,6 +147,221 @@ function tests(context, compare) { return compare(page, `${context}-datagrid-filters-work.txt`); }); + test("removeConfigs removes a programmatically applied filter from slave viewers", async ({ + page, + }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + // Apply a filter for "Category" via programmatic dispatch. + // "Category" is not in the master's group_by/split_by/filter, so it + // would not be cleared by the candidates mechanism on deselect. + let cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-select", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + ["Category"], + [], + [{ filter: [["Category", "==", "Furniture"]] }], + ), + }), + ); + }); + + let cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([ + ["Category", "==", "Furniture"], + ]); + + // Use removeConfigs to explicitly clear the Category filter from + // slave viewers. + cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-select", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + [], + [{ filter: [["Category", "==", "Furniture"]] }], + [], + ), + }), + ); + }); + + cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([]); + }); + + test("removeConfigs preserves other slave filters while clearing targeted column", async ({ + page, + }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + // Apply filters for both "Category" and "Segment" via programmatic + // dispatch. + let cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-select", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + ["Category", "Segment"], + [], + [ + { + filter: [ + ["Category", "==", "Furniture"], + ["Segment", "==", "Consumer"], + ], + }, + ], + ), + }), + ); + }); + + let cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([ + ["Category", "==", "Furniture"], + ["Segment", "==", "Consumer"], + ]); + + // Remove only "Category"; "Segment" filter should be preserved. + cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-select", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + [], + [{ filter: [["Category", "==", "Furniture"]] }], + [], + ), + }), + ); + }); + + cfg = await cfgPromise; + // Category is cleared, Segment is preserved. + expect(cfg.viewers.Two.filter).toEqual([["Segment", "==", "Consumer"]]); + }); + test("Child classes of datagrid behave the same way", async ({ page }) => { const config = { viewers: { diff --git a/rust/perspective-viewer/src/ts/extensions.ts b/rust/perspective-viewer/src/ts/extensions.ts index 0af643f0b8..a8a2336fca 100644 --- a/rust/perspective-viewer/src/ts/extensions.ts +++ b/rust/perspective-viewer/src/ts/extensions.ts @@ -14,7 +14,41 @@ import type { HTMLPerspectiveViewerPluginElement } from "./plugin"; import type { PerspectiveViewerElement } from "../../dist/wasm/perspective-viewer.js"; import type React from "react"; import type { ViewerConfigUpdate } from "./ts-rs/ViewerConfigUpdate.js"; -import type { ViewWindow } from "@perspective-dev/client"; +import type { + ViewWindow, + ViewConfigUpdate, + Filter, +} from "@perspective-dev/client"; + +export class PerspectiveSelectDetail { + selected: boolean; + row: Record; + column_names?: string[]; + removeConfigs: ViewConfigUpdate[]; + insertConfigs: ViewConfigUpdate[]; + + constructor( + selected: boolean, + row: Record, + column_names: string[], + removeConfigs: ViewConfigUpdate[], + insertConfigs: ViewConfigUpdate[], + ) { + this.selected = selected; + this.row = row; + this.column_names = column_names; + this.removeConfigs = removeConfigs; + this.insertConfigs = insertConfigs; + } + + get removeFilters(): Filter[] { + return this.removeConfigs.flatMap((x) => x.filter ?? []); + } + + get insertFilters(): Filter[] { + return this.insertConfigs.flatMap((x) => x.filter ?? []); + } +} import type { ExportDropDownMenuElement, CopyDropDownMenuElement, diff --git a/rust/perspective-viewer/src/ts/perspective-viewer.ts b/rust/perspective-viewer/src/ts/perspective-viewer.ts index d506844677..e676572516 100644 --- a/rust/perspective-viewer/src/ts/perspective-viewer.ts +++ b/rust/perspective-viewer/src/ts/perspective-viewer.ts @@ -35,6 +35,7 @@ export { IPerspectiveViewerPlugin } from "./plugin"; export { HTMLPerspectiveViewerPluginElement } from "./plugin"; export type * from "./extensions.ts"; +export { PerspectiveSelectDetail } from "./extensions.ts"; export type * from "./ts-rs/ViewerConfigUpdate.d.ts"; export type * from "./ts-rs/ColumnConfigValues.d.ts"; export type * from "./ts-rs/Filter.d.ts";