diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 382cca5080..0eeabe2ba3 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -62,6 +62,7 @@ import { ExecutionPlanOptions } from "../models/contracts/queryExecute"; import { ObjectExplorerDragAndDropController } from "../objectExplorer/objectExplorerDragAndDropController"; import { SchemaDesignerService } from "../services/schemaDesignerService"; import { SchemaDesignerWebviewController } from "../schemaDesigner/schemaDesignerWebviewController"; +import store from "../queryResult/singletonStore"; /** * The main controller class that initializes the extension @@ -1646,6 +1647,8 @@ export default class MainController implements vscode.Disposable { if (editor.document.getText(selectionToTrim).trim().length === 0) { return; } + // Delete query result filters for the current uri when we run a new query + store.delete(uri); await self._outputContentProvider.runQuery( self._statusview, @@ -2095,6 +2098,9 @@ export default class MainController implements vscode.Disposable { if (diagnostics.has(doc.uri)) { diagnostics.delete(doc.uri); } + + // Delete query result fiters for the closed uri + store.delete(closedDocumentUri); } /** diff --git a/src/queryResult/queryResultWebViewController.ts b/src/queryResult/queryResultWebViewController.ts index 3b1da4ffb4..f79d0ca1c3 100644 --- a/src/queryResult/queryResultWebViewController.ts +++ b/src/queryResult/queryResultWebViewController.ts @@ -54,7 +54,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< resultPaneTab: qr.QueryResultPaneTabs.Messages, }, executionPlanState: {}, - filterState: {}, fontSettings: {}, }); @@ -72,7 +71,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< tabStates: undefined, isExecutionPlan: false, executionPlanState: {}, - filterState: {}, fontSettings: { fontSize: this.getFontSizeConfig(), @@ -264,7 +262,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< xmlPlans: {}, }, }), - filterState: {}, fontSettings: { fontSize: this.getFontSizeConfig(), fontFamily: this.getFontFamilyConfig(), diff --git a/src/queryResult/queryResultWebviewPanelController.ts b/src/queryResult/queryResultWebviewPanelController.ts index 2b4cd6019a..0b58238e3f 100644 --- a/src/queryResult/queryResultWebviewPanelController.ts +++ b/src/queryResult/queryResultWebviewPanelController.ts @@ -37,7 +37,6 @@ export class QueryResultWebviewPanelController extends ReactWebviewPanelControll resultPaneTab: qr.QueryResultPaneTabs.Messages, }, executionPlanState: {}, - filterState: {}, fontSettings: {}, }, { diff --git a/src/queryResult/singletonStore.ts b/src/queryResult/singletonStore.ts new file mode 100644 index 0000000000..00c8280d08 --- /dev/null +++ b/src/queryResult/singletonStore.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class QueryResultSingletonStore { + private static instance: QueryResultSingletonStore; + private store: Map; + + // Private constructor to prevent instantiation from outside + private constructor() { + this.store = new Map(); + } + + // Method to get the single instance of the store + public static getInstance(): QueryResultSingletonStore { + if (!QueryResultSingletonStore.instance) { + QueryResultSingletonStore.instance = + new QueryResultSingletonStore(); + } + return QueryResultSingletonStore.instance; + } + + // Method to set a value in the store + public set(key: string, value: any): void { + this.store.set(key, value); + } + + // Method to get a value from the store + public get(key: string): T | undefined { + return this.store.get(key); + } + + // Method to check if a key exists + public has(key: string): boolean { + return this.store.has(key); + } + + // Method to delete a key-value pair + public delete(key: string): boolean { + return this.store.delete(key); + } + + // Method to clear the store + public clear(): void { + this.store.clear(); + } +} + +// Export the singleton instance +const store = QueryResultSingletonStore.getInstance(); +export default store; diff --git a/src/queryResult/utils.ts b/src/queryResult/utils.ts index 21a43c545f..2d66420d2b 100644 --- a/src/queryResult/utils.ts +++ b/src/queryResult/utils.ts @@ -22,6 +22,7 @@ import { sendActionEvent } from "../telemetry/telemetry"; import * as qr from "../sharedInterfaces/queryResult"; import { QueryResultWebviewPanelController } from "./queryResultWebviewPanelController"; import { QueryResultWebviewController } from "./queryResultWebViewController"; +import store from "./singletonStore"; export function getNewResultPaneViewColumn( uri: string, @@ -217,22 +218,29 @@ export function registerCommonRequestHandlers( message.selection, ); }); - webviewController.registerReducer( - "setResultTab", - async (state, payload) => { - state.tabStates.resultPaneTab = payload.tabId; - return state; + + // Register request handlers for query result filters + webviewController.registerRequestHandler("getFilters", async (message) => { + return store.get(message.uri); + }); + + webviewController.registerRequestHandler("setFilters", async (message) => { + store.set(message.uri, message.filters); + return true; + }); + + webviewController.registerRequestHandler( + "deleteFilter", + async (message) => { + store.delete(message.uri); + return true; }, ); + webviewController.registerReducer( - "setFilterState", + "setResultTab", async (state, payload) => { - state.filterState[payload.filterState.columnDef] = { - filterValues: payload.filterState.filterValues, - columnDef: payload.filterState.columnDef, - seachText: payload.filterState.seachText, - sorted: payload.filterState.sorted, - }; + state.tabStates.resultPaneTab = payload.tabId; return state; }, ); diff --git a/src/reactviews/pages/QueryResult/queryResultPane.tsx b/src/reactviews/pages/QueryResult/queryResultPane.tsx index 1460b3098c..c15da8ad0f 100644 --- a/src/reactviews/pages/QueryResult/queryResultPane.tsx +++ b/src/reactviews/pages/QueryResult/queryResultPane.tsx @@ -275,6 +275,7 @@ export const QueryResultPane = () => { gridCount: number, ) => { const divId = `grid-parent-${batchId}-${resultId}`; + const gridId = `resultGrid-${batchId}-${resultId}`; return (
{ webViewState={webViewState} state={state} linkHandler={linkHandler} + gridId={gridId} /> = ({ tabId: tabId, }); }, - setFilterState: function ( - filterState: ColumnFilterState, - ): void { - webViewState?.extensionRpc.action("setFilterState", { - filterState: filterState, - }); - }, getExecutionPlan: function (uri: string): void { webViewState?.extensionRpc.action("getExecutionPlan", { uri: uri, diff --git a/src/reactviews/pages/QueryResult/resultGrid.tsx b/src/reactviews/pages/QueryResult/resultGrid.tsx index a5bc880cbb..6e42f8c9db 100644 --- a/src/reactviews/pages/QueryResult/resultGrid.tsx +++ b/src/reactviews/pages/QueryResult/resultGrid.tsx @@ -65,6 +65,7 @@ export interface ResultGridProps { gridParentRef?: React.RefObject; state: QueryResultState; linkHandler: (fileContent: string, fileType: string) => void; + gridId: string; } export interface ResultGridHandle { @@ -288,6 +289,7 @@ const ResultGrid = forwardRef( props.webViewState!, props.state, props.linkHandler!, + props.gridId, { dataProvider: dataProvider, columns: columns }, tableOptions, props.gridParentRef, diff --git a/src/reactviews/pages/QueryResult/table/interfaces.ts b/src/reactviews/pages/QueryResult/table/interfaces.ts index fe4c33f128..7f7da3a34e 100644 --- a/src/reactviews/pages/QueryResult/table/interfaces.ts +++ b/src/reactviews/pages/QueryResult/table/interfaces.ts @@ -54,6 +54,21 @@ export interface ColumnFilterState { columnDef: string; } +/** + * Maps all the column filters for a specific grid ID + */ +export type GridColumnMap = Record; + +/** + * Maps the column filter state for a specific column + */ +export type ColumnFilterMap = Record; + +export interface GridFilters { + gridId: string; + columnFilters: ColumnFilterState; +} + export interface GridSortState { field: string; sortAsc: boolean; diff --git a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts index ce8f2eb583..77d9bd9e0a 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts @@ -9,6 +9,7 @@ import { ColumnFilterState, FilterableColumn, + GridColumnMap, SortProperties, } from "../interfaces"; import { append, $ } from "../dom"; @@ -18,12 +19,19 @@ import { } from "../dataProvider"; import "../../../../media/table.css"; import { locConstants } from "../../../../common/locConstants"; -import { ColorThemeKind } from "../../../../common/vscodeWebviewProvider"; +import { + ColorThemeKind, + VscodeWebviewContext, +} from "../../../../common/vscodeWebviewProvider"; import { resolveVscodeThemeType } from "../../../../common/utils"; import { VirtualizedList } from "../../../../common/virtualizedList"; import { EventManager } from "../../../../common/eventManager"; import { QueryResultState } from "../../queryResultStateProvider"; +import { + QueryResultReducers, + QueryResultWebviewState, +} from "../../../../../sharedInterfaces/queryResult"; export type HeaderFilterCommands = "sort-asc" | "sort-desc"; @@ -38,7 +46,6 @@ const ShowFilterText = locConstants.queryResult.showFilter; export const FilterButtonWidth: number = 34; export class HeaderFilter { - public theme: ColorThemeKind; public onFilterApplied = new Slick.Event<{ grid: Slick.Grid; column: FilterableColumn; @@ -59,12 +66,16 @@ export class HeaderFilter { private _list!: VirtualizedList; private _eventManager = new EventManager(); - private queryResultState: QueryResultState; - constructor(theme: ColorThemeKind, queryResultState: QueryResultState) { - this.queryResultState = queryResultState; - this.theme = theme; - } + constructor( + public theme: ColorThemeKind, + private queryResultState: QueryResultState, + private webviewState: VscodeWebviewContext< + QueryResultWebviewState, + QueryResultReducers + >, + private gridId: string, + ) {} public init(grid: Slick.Grid): void { this.grid = grid; @@ -98,7 +109,7 @@ export class HeaderFilter { args: Slick.OnHeaderCellRenderedEventArgs, ) { const column = args.column as FilterableColumn; - if ((>column).filterable === false) { + if ((column as FilterableColumn).filterable === false) { return; } if (args.node.classList.contains("slick-header-with-filter")) { @@ -257,8 +268,10 @@ export class HeaderFilter { !$target.closest("#anchor-btn").length && !$target.closest("#popup-menu").length ) { - this.activePopup!.fadeOut(); - this.activePopup = null; + if (this.activePopup) { + this.activePopup.fadeOut(); + this.activePopup = null; + } } }); @@ -306,19 +319,18 @@ export class HeaderFilter { "click", `#apply-${this.columnDef.id}`, async () => { - this.columnDef.filterValues = this._listData - .filter((element) => element.checked) - .map((element) => element.value); closePopup($popup); this.activePopup = null; this.applyFilterSelections(); if (!$menuButton) { return; } - this.setButtonImage( - $menuButton, - this.columnDef.filterValues.length > 0, - ); + if (this.columnDef.filterValues) { + this.setButtonImage( + $menuButton, + this.columnDef.filterValues.length > 0, + ); + } await this.handleApply(this.columnDef); }, ); @@ -327,7 +339,9 @@ export class HeaderFilter { "click", `#clear-${this.columnDef.id}`, async () => { - this.columnDef.filterValues!.length = 0; + if (this.columnDef.filterValues) { + this.columnDef.filterValues.length = 0; + } closePopup($popup); this.activePopup = null; @@ -454,6 +468,25 @@ export class HeaderFilter { filterValues: [], sorted: SortProperties.NONE, }; + if (this.queryResultState.state.uri) { + // Get the current filters from the query result singleton store + let gridColumnMapArray = + (await this.webviewState.extensionRpc.call("getFilters", { + uri: this.queryResultState.state.uri, + })) as GridColumnMap[]; + if (!gridColumnMapArray) { + gridColumnMapArray = []; + } + // Drill down into the grid column map array and clear the filter values for the specified column + gridColumnMapArray = this.clearFilterValues( + gridColumnMapArray, + columnDef.id!, + ); + await this.webviewState.extensionRpc.call("setFilters", { + uri: this.queryResultState.state.uri, + filters: gridColumnMapArray, + }); + } } else { columnFilterState = { columnDef: this.columnDef.id!, @@ -461,11 +494,128 @@ export class HeaderFilter { sorted: this.columnDef.sorted, }; } - this.updateState(columnFilterState); + + await this.updateState(columnFilterState, columnDef.id!); + } + + /** + * Update the filter state in the query result singleton store + * @param newState + * @param columnId + * @returns + */ + private async updateState( + newState: ColumnFilterState, + columnId: string, + ): Promise { + let newStateArray: GridColumnMap[]; + // Check if there is any existing filter state + if (!this.queryResultState.state.uri) { + this.queryResultState.log("no uri set for query result state"); + return; + } + let currentFiltersArray = (await this.webviewState.extensionRpc.call( + "getFilters", + { + uri: this.queryResultState.state.uri, + }, + )) as GridColumnMap[]; + if (!currentFiltersArray) { + currentFiltersArray = []; + } + newStateArray = this.combineFilters( + currentFiltersArray, + newState, + columnId, + ); + await this.webviewState.extensionRpc.call("setFilters", { + uri: this.queryResultState.state.uri, + filters: newStateArray, + }); + } + + /** + * Drill down into the grid column map array and clear the filter values for the specified column + * @param gridFiltersArray + * @param columnId + * @returns + */ + private clearFilterValues( + gridFiltersArray: GridColumnMap[], + columnId: string, + ) { + const targetGridFilters = gridFiltersArray.find( + (gridFilters) => gridFilters[this.gridId], + ); + + // Return original array if gridId is not found + if (!targetGridFilters) { + return gridFiltersArray; + } + + // Iterate through each ColumnFilterMap and clear filterValues for the specified columnId + for (const columnFilterMap of targetGridFilters[this.gridId]) { + if (columnFilterMap[columnId]) { + columnFilterMap[columnId] = columnFilterMap[columnId].map( + (filterState) => ({ + ...filterState, + filterValues: [], + }), + ); + } + } + + return gridFiltersArray; } - private updateState(newState: ColumnFilterState) { - this.queryResultState.provider.setFilterState(newState); + /** + * Combines two GridColumnMaps into one, keeping filters separate for different gridIds and removing any duplicate filterValues within the same column id + * @param currentFiltersArray filters array for all grids + * @param newFilters + * @param columnId + * @returns + */ + private combineFilters( + gridFiltersArray: GridColumnMap[], + newFilterState: ColumnFilterState, + columnId: string, + ): GridColumnMap[] { + // Find the appropriate GridColumnFilterMap for the gridId + let targetGridFilters = gridFiltersArray.find( + (gridFilters) => gridFilters[this.gridId], + ); + + if (!targetGridFilters) { + // If no GridColumnFilterMap found for the gridId, create a new one + targetGridFilters = { [this.gridId]: [] }; + gridFiltersArray.push(targetGridFilters); + } + + let columnFilterMap = targetGridFilters[this.gridId].find( + (map) => map[columnId], + ); + + if (!columnFilterMap) { + // If no existing ColumnFilterMap for this column, create a new one + columnFilterMap = { [columnId]: [newFilterState] }; + targetGridFilters[this.gridId].push(columnFilterMap); + } else { + // Update the existing column filter state + const filterStates = columnFilterMap[columnId]; + const existingIndex = filterStates.findIndex( + (filter) => filter.columnDef === newFilterState.columnDef, + ); + + if (existingIndex !== -1) { + // Replace existing filter state for the column + filterStates[existingIndex] = newFilterState; + } else { + // Add new filter state for this column + filterStates.push(newFilterState); + } + } + + return [...gridFiltersArray]; } private async handleMenuItemClick( diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts index d9718d2821..a85a547762 100644 --- a/src/reactviews/pages/QueryResult/table/table.ts +++ b/src/reactviews/pages/QueryResult/table/table.ts @@ -14,6 +14,8 @@ import { ITableConfiguration, ITableStyles, FilterableColumn, + GridColumnMap, + ColumnFilterState, } from "./interfaces"; import * as DOM from "./dom"; @@ -82,6 +84,7 @@ export class Table implements IThemable { >, state: QueryResultState, linkHandler: (value: string, type: string) => void, + private gridId: string, configuration?: ITableConfiguration, options?: Slick.GridOptions, gridParentRef?: React.RefObject, @@ -157,7 +160,12 @@ export class Table implements IThemable { newOptions, ); this.registerPlugin( - new HeaderFilter(webViewState.themeKind, this.queryResultState), + new HeaderFilter( + webViewState.themeKind, + this.queryResultState, + this.webViewState, + gridId, + ), ); this.registerPlugin( new ContextMenu(this.uri, this.resultSetSummary, this.webViewState), @@ -210,18 +218,35 @@ export class Table implements IThemable { * @returns true if filters were successfully loaded and applied, false if no filters were found */ public async setupFilterState(): Promise { - this.columns.forEach((column) => { - if (column.field) { - const filters = - this.queryResultState.state.filterState[column.field]; - if (filters) { - (>column).filterValues = - filters.filterValues; - } else { - return false; + const filterMapArray = (await this.webViewState.extensionRpc.call( + "getFilters", + { + uri: this.queryResultState.state.uri, + }, + )) as GridColumnMap[]; + if (!filterMapArray) { + return false; + } + const filterMap = filterMapArray.find((filter) => filter[this.gridId]); + if (!filterMap || !filterMap[this.gridId]) { + this.queryResultState.log("No filters found in store"); + return false; + } + for (const column of this.columns) { + for (const columnFilterMap of filterMap[this.gridId]) { + if (columnFilterMap[column.id!]) { + const filterStateArray = columnFilterMap[column.id!]; + filterStateArray.forEach( + (filterState: ColumnFilterState) => { + if (filterState.columnDef === column.field) { + (column as FilterableColumn).filterValues = + filterState.filterValues; + } + }, + ); } } - }); + } await this._data.filter(this.columns); return true; } diff --git a/src/sharedInterfaces/queryResult.ts b/src/sharedInterfaces/queryResult.ts index 7e73e1d786..e29789107a 100644 --- a/src/sharedInterfaces/queryResult.ts +++ b/src/sharedInterfaces/queryResult.ts @@ -9,7 +9,6 @@ import { ExecutionPlanState, ExecutionPlanWebviewState, } from "../reactviews/pages/ExecutionPlan/executionPlanInterfaces"; -import { ColumnFilterState } from "../reactviews/pages/QueryResult/table/interfaces"; import { ISlickRange } from "../reactviews/pages/QueryResult/table/utils"; export enum QueryResultLoadState { @@ -26,12 +25,6 @@ export enum QueryResultSaveAsTrigger { export interface QueryResultReactProvider extends Omit { setResultTab: (tabId: QueryResultPaneTabs) => void; - /** - * Sets the filter state for the current result set - * @param filterState - * @returns - */ - setFilterState: (filterState: ColumnFilterState) => void; /** * Gets the execution plan graph from the provider for a result set * @param uri the uri of the query result state this request is associated with @@ -76,7 +69,6 @@ export interface QueryResultWebviewState extends ExecutionPlanWebviewState { actualPlanEnabled?: boolean; selection?: ISlickRange[]; executionPlanState: ExecutionPlanState; - filterState: Record; fontSettings: FontSettings; autoSizeColumns?: boolean; } @@ -86,9 +78,6 @@ export interface QueryResultReducers setResultTab: { tabId: QueryResultPaneTabs; }; - setFilterState: { - filterState: ColumnFilterState; - }; /** * Gets the execution plan graph from the provider for given uri * @param uri the uri for which to get graphs for