diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 1a275bdee7..eea22efa50 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -359,7 +359,7 @@ "Message": "Message", "Open in New Tab": "Open in New Tab", "Showplan XML": "Showplan XML", - "Show Filter": "Show Filter", + "Show Menu": "Show Menu", "Sort Ascending": "Sort Ascending", "Sort Descending": "Sort Descending", "Clear Sort": "Clear Sort", @@ -403,6 +403,12 @@ "No results for the active editor": "No results for the active editor", "Run a query in the current editor, or switch to an editor that has results.": "Run a query in the current editor, or switch to an editor that has results.", "Failed to start query.": "Failed to start query.", + "Filter Options": "Filter Options", + "Remove Sort": "Remove Sort", + "{0} selected/{0} is the number of selected rows": { + "message": "{0} selected", + "comment": ["{0} is the number of selected rows"] + }, "Add new column": "Add new column", "Table": "Table", "Save": "Save", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 90a71534e2..1ab9665cb8 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1460,6 +1460,9 @@ Filter Azure subscriptions + + Filter Options + Filter Settings @@ -2533,6 +2536,9 @@ Remove + + Remove Sort + Remove recent connection @@ -2888,12 +2894,12 @@ Show Confirm Password - - Show Filter - Show MSSQL output + + Show Menu + Show New Password @@ -3514,6 +3520,10 @@ {0} rows selected, click to load summary {0} is the number of rows to fetch summary statistics for + + {0} selected + {0} is the number of selected rows + {0} started successfully. {0} started successfully. diff --git a/package.json b/package.json index 07745cd2c8..2ca8717067 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@monaco-editor/react": "^4.6.0", "@playwright/test": "^1.45.0", "@stylistic/eslint-plugin": "^2.8.0", + "@tanstack/react-virtual": "^3.13.12", "@types/azdata": "^1.46.6", "@types/chai": "^5.2.2", "@types/jquery": "^3.3.31", diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 67331be679..72f37a7e1d 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -447,7 +447,7 @@ export class LocConstants { message: l10n.t("Message"), openResultInNewTab: l10n.t("Open in New Tab"), showplanXML: l10n.t("Showplan XML"), - showFilter: l10n.t("Show Filter"), + showMenu: l10n.t("Show Menu"), sortAscending: l10n.t("Sort Ascending"), sortDescending: l10n.t("Sort Descending"), clearSort: l10n.t("Clear Sort"), @@ -509,6 +509,14 @@ export class LocConstants { "Run a query in the current editor, or switch to an editor that has results.", ), failedToStartQuery: l10n.t("Failed to start query."), + filterOptions: l10n.t("Filter Options"), + removeSort: l10n.t("Remove Sort"), + selectedCount: (count: number) => + l10n.t({ + message: "{0} selected", + args: [count], + comment: ["{0} is the number of selected rows"], + }), }; } diff --git a/src/reactviews/media/menu.svg b/src/reactviews/media/menu.svg new file mode 100644 index 0000000000..e7e27b16a7 --- /dev/null +++ b/src/reactviews/media/menu.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/reactviews/media/menu_inverse.svg b/src/reactviews/media/menu_inverse.svg new file mode 100644 index 0000000000..bd83a9649b --- /dev/null +++ b/src/reactviews/media/menu_inverse.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/reactviews/media/table.css b/src/reactviews/media/table.css index 5af0cdedce..c5d9f3f7fa 100644 --- a/src/reactviews/media/table.css +++ b/src/reactviews/media/table.css @@ -18,43 +18,14 @@ height: 100%; } -.monaco-table .slick-sort-indicator { - background-size: 8px; - background-repeat: no-repeat; -} - -.monaco-table .slick-sort-indicator { - display: inline-block; - width: 8px; - height: 8px; - margin-left: 4px; - margin-right: 4px; - margin-top: 6px; - flex: 0 0 auto; -} - -.slick-header-sort-button, -.slick-header-sortdesc-button, -.slick-header-sortasc-button { - background-position: center center; - background-repeat: no-repeat; - cursor: pointer; - display: inline-block; - width: 16px; - flex: 0 0 auto; - margin-right: 2px; - background-color: transparent; - border: 0; - padding: 0; -} - .slick-header-menubutton { background-position: center center; background-repeat: no-repeat; cursor: pointer; display: inline-block; - width: 16px; - background-image: url("filter.svg"); + width: 12px; + background-image: url("menu.svg"); + background-size: 16px; flex: 0 0 auto; margin-right: 2px; background-color: transparent; @@ -62,105 +33,20 @@ padding: 0px; } -.slick-header-menu { - background: none repeat scroll 0 0 white; - border: 1px solid #bfbdbd; - min-width: 175px; - padding: 4px; - z-index: 100000; - cursor: default; - display: flex; - flex-direction: column; - margin: 0; - position: absolute; -} - -.hc-light .slick-header-menu { - background: none repeat scroll 0 0 #f3f2f1; -} - -.vs-dark .slick-header-menu { - background: none repeat scroll 0 0 #333333; +.slick-header-menubutton:hover { + color: var(--vscode-list-hoverForeground); + background-color: var(--vscode-list-hoverBackground); } -.hc-black .slick-header-menu { - background: none repeat scroll 0 0 #000000; +.slick-header-menubutton:focus { + color: var(--vscode-list-hoverForeground); + background-color: var(--vscode-list-hoverBackground); } .slick-header-menu-image-button-container { flex: 0 0 auto; } -.slick-header-menu a.monaco-button.monaco-text-button { - width: 60px; - margin: 5px; - padding: 2px; -} - -.slick-header-menu .searchbox-row { - display: flex; - align-items: center; - padding-left: 5px; - margin-top: 5px; - flex: 0 0 auto; -} - -.slick-header-menu .searchbox-row .select-all-checkbox { - flex: 0; -} - -.slick-header-menu .searchbox-row .search-input { - flex: 1 1 auto; -} - -.slick-header-menu .searchbox-row .search-input input { - padding-right: 70px; -} - -.slick-header-menu .searchbox-row .selected-count { - position: absolute; - right: 8px; - align-self: center; - align-items: center; - display: flex; -} - -.slick-header-menu .searchbox-row .visible-count { - position: absolute; - /* visible count badge is not visible but will be read by the screen reader */ - left: -10000px; -} - -.slick-header-menu .filter { - border: 1px solid #bfbdbd; - font-size: 8pt; - margin-top: 6px; - overflow: hidden; - padding: 1px; - white-space: nowrap; - align-content: flex-start; - display: flex; - flex-direction: column; - flex: 1 1 auto; -} - -.slick-header-menu .filter .filter-option { - display: flex; - align-items: center; - padding-left: 3px; -} - -.slick-header-menu .filter-menu-button-container { - display: flex; - flex-direction: row; - width: 100%; - flex: 0 0 auto; -} - -.slick-header-menu .filter-menu-button { - flex: 0 0 auto; -} - .monaco-table label { display: block; margin-bottom: 5px; @@ -174,23 +60,6 @@ margin: 0; } -.slick-header-menu a.monaco-button.monaco-text-button.slick-header-menuicon { - display: inline-block; - background-position: 2px center; - background-repeat: no-repeat; - padding-left: 18px; - text-align: left; - width: 100%; - padding: 0 0 0 18px; - margin: 0px; - background-size: 10px; -} - -.slick-header-menucontent { - display: inline-block; - vertical-align: middle; -} - .slick-header-menuitem:hover { border-color: #bfbdbd; } @@ -220,46 +89,6 @@ margin-right: -5px; } -#popup-menu { - position: absolute; - padding: 0px; - display: inline-block; - min-width: 100px; - z-index: 99999; - border: 1px solid; - outline: 0; - color: var(--vscode-dropdown-foreground); - background-color: var(--vscode-dropdown-background); - border-color: var(--vscode-menu-border); - font-family: var(--vscode-font-family); -} - -/* Button styling to match the design */ -.sort-btn { - width: 100%; - padding: 8px 10px; - text-align: left; - background: none; - border: none; - cursor: pointer; - font-size: 13px; - color: var(--vscode-dropdown-foreground); - background-color: var(--vscode-dropdown-background); - border-color: var(--vscode-dropdown-border); - font-family: var(--vscode-font-family); -} - -.sort-btn:hover { - color: var(--vscode-list-hoverForeground); - background-color: var(--vscode-list-hoverBackground); -} - -/* Additional styling for active/focused states */ -.sort-btn:focus { - color: var(--vscode-list-hoverForeground); - background-color: var(--vscode-list-hoverBackground); -} - .checkbox-list { min-height: 200px; max-height: 250px; @@ -273,91 +102,19 @@ cursor: pointer; } -.filter-btn-primary { - width: 33%; - padding: 8px 10px; - text-align: center; - background: none; - border: none; - cursor: pointer; - font-size: 13px; - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-background); - border-color: var(--vscode-button-border); - font-family: var(--vscode-font-family); -} - -.filter-btn { - width: 33%; - padding: 8px 10px; - text-align: center; - background: none; - border: none; - cursor: pointer; - font-size: 13px; - color: var(--vscode-button-secondaryForeground); - background-color: var(--vscode-button-secondaryBackground); - border-color: var(--vscode-button-secondaryBorder); - font-family: var(--vscode-font-family); -} - -.searchbox { - width: 100%; - padding: 8px; - text-align: left; - background: none; - border: 1px solid; - font-size: 13px; - color: var(--vscode-dropdown-foreground); - background-color: var(--vscode-dropdown-background); - border-color: var(--vscode-dropdown-border); - font-family: var(--vscode-font-family); -} - -.selection-counter { - margin-left: 8px; - padding: 4px; - border-radius: 4px; - color: var(--vscode-dropdown-foreground); - background-color: var(--vscode-descriptionForeground); - border-color: var(--vscode-dropdown-border); -} - -.filter-btn-primary:hover { - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-hoverBackground); -} - -/* Additional styling for active/focused states */ -.filter-btn-primary:focus { - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-hoverBackground); -} - -.filter-btn:hover { - color: var(--vscode-button-secondaryForeground); - background-color: var(--vscode-button-secondaryHoverBackground); -} - -/* Additional styling for active/focused states */ -.filter-btn:focus { - color: var(--vscode-button-secondaryForeground); - background-color: var(--vscode-button-secondaryHoverBackground); -} - /* Icons */ -/* Icon for filter button */ +/* Icon for menu button */ .vscode-dark .slick-header-menubutton, .vscode-high-contrast .slick-header-menubutton { - background-image: url("filter_inverse.svg"); + background-image: url("menu_inverse.svg"); } .vscode-light .slick-header-menubutton, .vscode-high-contrast.vscode-high-contrast-light .slick-header-menubutton { - background-image: url("filter.svg"); + background-image: url("menu.svg"); } -/* Icon for filter button when filter is applied */ +/* Icon for menu button when filter is applied */ .vscode-dark .slick-header-menubutton.filtered, .vscode-high-contrast .slick-header-menubutton.filtered { background-image: url("filterFilled_inverse.svg"); @@ -368,35 +125,40 @@ background-image: url("filterFilled.svg"); } -/* Icons for sorting buttons */ -.vscode-dark .slick-header-sort-button, -.vscode-high-contrast .slick-header-sort-button { - background-image: url("sort_inverse.svg"); +/* Sort indicator icon styling */ +.slick-sort-indicator-icon { + display: inline-block; + width: 12px; + margin-left: 4px; + background-position: center; + background-repeat: no-repeat; + background-size: 14px; + flex: 0 0 auto; + opacity: 0; + transition: opacity 0.2s ease; } -.vscode-light .slick-header-sort-button, -.vscode-high-contrast.vscode-high-contrast-light .slick-header-sort-button { - background-image: url("sort.svg"); +.slick-sort-indicator-icon.sorted-asc, +.slick-sort-indicator-icon.sorted-desc { + opacity: 1; } -/* Icons for sort desc */ -.vscode-dark .slick-header-sortdesc-button, -.vscode-high-contrast .slick-header-sortdesc-button { - background-image: url("sort_desc_inverse.svg"); +.vscode-dark .slick-sort-indicator-icon.sorted-asc, +.vscode-high-contrast .slick-sort-indicator-icon.sorted-asc { + background-image: url("sort_asc_inverse.svg"); } -.vscode-light .slick-header-sortdesc-button, -.vscode-high-contrast.vscode-high-contrast-light .slick-header-sortdesc-button { - background-image: url("sort_desc.svg"); +.vscode-light .slick-sort-indicator-icon.sorted-asc, +.vscode-high-contrast.vscode-high-contrast-light .slick-sort-indicator-icon.sorted-asc { + background-image: url("sort_asc.svg"); } -/* Icons for sort asc */ -.vscode-dark .slick-header-sortasc-button, -.vscode-high-contrast .slick-header-sortasc-button { - background-image: url("sort_asc_inverse.svg"); +.vscode-dark .slick-sort-indicator-icon.sorted-desc, +.vscode-high-contrast .slick-sort-indicator-icon.sorted-desc { + background-image: url("sort_desc_inverse.svg"); } -.vscode-light .slick-header-sortasc-button, -.vscode-high-contrast.vscode-high-contrast-light .slick-header-sortasc-button { - background-image: url("sort_asc.svg"); +.vscode-light .slick-sort-indicator-icon.sorted-desc, +.vscode-high-contrast.vscode-high-contrast-light .slick-sort-indicator-icon.sorted-desc { + background-image: url("sort_desc.svg"); } diff --git a/src/reactviews/pages/PublishProject/index.tsx b/src/reactviews/pages/PublishProject/index.tsx index fc4201b1e2..60751605fc 100644 --- a/src/reactviews/pages/PublishProject/index.tsx +++ b/src/reactviews/pages/PublishProject/index.tsx @@ -1,18 +1,18 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import ReactDOM from "react-dom/client"; -import "../../index.css"; -import { VscodeWebviewProvider2 } from "../../common/vscodeWebviewProvider2"; -import PublishProjectPage from "./publishProject"; -import { PublishProjectStateProvider } from "./publishProjectStateProvider"; - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - , -); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import ReactDOM from "react-dom/client"; +import "../../index.css"; +import { VscodeWebviewProvider2 } from "../../common/vscodeWebviewProvider2"; +import PublishProjectPage from "./publishProject"; +import { PublishProjectStateProvider } from "./publishProjectStateProvider"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/src/reactviews/pages/PublishProject/publishDialogSelector.ts b/src/reactviews/pages/PublishProject/publishDialogSelector.ts index 61e9d5c671..e93a20ddc4 100644 --- a/src/reactviews/pages/PublishProject/publishDialogSelector.ts +++ b/src/reactviews/pages/PublishProject/publishDialogSelector.ts @@ -1,14 +1,14 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { PublishDialogReducers, PublishDialogState } from "../../../sharedInterfaces/publishDialog"; -import { useVscodeSelector } from "../../common/useVscodeSelector"; - -export function usePublishDialogSelector( - selector: (state: PublishDialogState) => T, - equals: (a: T, b: T) => boolean = Object.is, -) { - return useVscodeSelector(selector, equals); -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PublishDialogReducers, PublishDialogState } from "../../../sharedInterfaces/publishDialog"; +import { useVscodeSelector } from "../../common/useVscodeSelector"; + +export function usePublishDialogSelector( + selector: (state: PublishDialogState) => T, + equals: (a: T, b: T) => boolean = Object.is, +) { + return useVscodeSelector(selector, equals); +} diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index bd1ae1c538..a16e6d8f6b 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -1,95 +1,95 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { useContext } from "react"; -import { Button, makeStyles } from "@fluentui/react-components"; -import { useFormStyles } from "../../common/forms/form.component"; -import { PublishProjectContext } from "./publishProjectStateProvider"; -import { usePublishDialogSelector } from "./publishDialogSelector"; -import { LocConstants } from "../../common/locConstants"; -import { PublishProfileField } from "./components/PublishProfileSection"; -import { PublishTargetSection } from "./components/PublishTargetSection"; -import { ConnectionSection } from "./components/ConnectionSection"; - -const useStyles = makeStyles({ - root: { padding: "12px" }, - footer: { - marginTop: "8px", - display: "flex", - justifyContent: "flex-end", - gap: "12px", - alignItems: "center", - maxWidth: "640px", - width: "100%", - paddingTop: "12px", - borderTop: "1px solid transparent", - }, -}); - -function PublishProjectDialog() { - const classes = useStyles(); - const formStyles = useFormStyles(); - const loc = LocConstants.getInstance().publishProject; - const context = useContext(PublishProjectContext); - - // Select pieces of state needed for this component - const formComponents = usePublishDialogSelector((s) => s.formComponents, Object.is); - const formState = usePublishDialogSelector((s) => s.formState, Object.is); - const inProgress = usePublishDialogSelector((s) => s.inProgress, Object.is); - - // Check if component is properly initialized and ready for user interaction - const isComponentReady = !!context && !!formComponents && !!formState; - - // Let the form framework handle validation - check if any visible components have validation errors - const hasValidationErrors = - isComponentReady && formComponents - ? Object.values(formComponents).some( - (component) => - !component.hidden && component.validation && !component.validation.isValid, - ) - : false; - - // Buttons should be disabled when: - // - Component is not ready (missing context, form components, or form state) - // - Operation is in progress - // - Form has validation errors - const readyToPublish = !isComponentReady || inProgress || hasValidationErrors; - - if (!isComponentReady) { - return
Loading...
; - } - - // Static ordering now expressed via explicit section components. - return ( -
e.preventDefault()}> -
-
- - - - -
- - -
-
-
-
- ); -} - -export default function PublishProjectPage() { - return ; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext } from "react"; +import { Button, makeStyles } from "@fluentui/react-components"; +import { useFormStyles } from "../../common/forms/form.component"; +import { PublishProjectContext } from "./publishProjectStateProvider"; +import { usePublishDialogSelector } from "./publishDialogSelector"; +import { LocConstants } from "../../common/locConstants"; +import { PublishProfileField } from "./components/PublishProfileSection"; +import { PublishTargetSection } from "./components/PublishTargetSection"; +import { ConnectionSection } from "./components/ConnectionSection"; + +const useStyles = makeStyles({ + root: { padding: "12px" }, + footer: { + marginTop: "8px", + display: "flex", + justifyContent: "flex-end", + gap: "12px", + alignItems: "center", + maxWidth: "640px", + width: "100%", + paddingTop: "12px", + borderTop: "1px solid transparent", + }, +}); + +function PublishProjectDialog() { + const classes = useStyles(); + const formStyles = useFormStyles(); + const loc = LocConstants.getInstance().publishProject; + const context = useContext(PublishProjectContext); + + // Select pieces of state needed for this component + const formComponents = usePublishDialogSelector((s) => s.formComponents, Object.is); + const formState = usePublishDialogSelector((s) => s.formState, Object.is); + const inProgress = usePublishDialogSelector((s) => s.inProgress, Object.is); + + // Check if component is properly initialized and ready for user interaction + const isComponentReady = !!context && !!formComponents && !!formState; + + // Let the form framework handle validation - check if any visible components have validation errors + const hasValidationErrors = + isComponentReady && formComponents + ? Object.values(formComponents).some( + (component) => + !component.hidden && component.validation && !component.validation.isValid, + ) + : false; + + // Buttons should be disabled when: + // - Component is not ready (missing context, form components, or form state) + // - Operation is in progress + // - Form has validation errors + const readyToPublish = !isComponentReady || inProgress || hasValidationErrors; + + if (!isComponentReady) { + return
Loading...
; + } + + // Static ordering now expressed via explicit section components. + return ( +
e.preventDefault()}> +
+
+ + + + +
+ + +
+
+
+
+ ); +} + +export default function PublishProjectPage() { + return ; +} diff --git a/src/reactviews/pages/QueryResult/queryResultStateProvider.tsx b/src/reactviews/pages/QueryResult/queryResultStateProvider.tsx index c81cc74263..925a143edb 100644 --- a/src/reactviews/pages/QueryResult/queryResultStateProvider.tsx +++ b/src/reactviews/pages/QueryResult/queryResultStateProvider.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ReactNode, createContext, useEffect, useMemo, useState } from "react"; +import { ReactNode, createContext, useCallback, useEffect, useMemo, useState } from "react"; import { getCoreRPCs2 } from "../../common/utils"; import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; import { ExecutionPlanProvider } from "../../../sharedInterfaces/executionPlan"; @@ -14,9 +14,29 @@ import { QueryResultReducers, QueryResultViewMode, QueryResultWebviewState, + SortProperties, } from "../../../sharedInterfaces/queryResult"; import { WebviewRpc } from "../../common/rpc"; import GridContextMenu from "./table/plugins/GridContextMenu"; +import ColumnMenuPopup, { + ColumnMenuPopupAnchorRect, + FilterListItem, + FilterValue, +} from "./table/plugins/ColumnMenuPopup"; + +export interface ColumnFilterPopupOptions { + columnId: string; + anchorRect: ColumnMenuPopupAnchorRect; + items: FilterListItem[]; + initialSelected: FilterValue[]; + onApply: (selected: FilterValue[]) => Promise; + onClearSort: () => Promise; + onClear: () => Promise; + onDismiss: () => void; + onSortAscending: () => Promise; + onSortDescending: () => Promise; + currentSort: SortProperties; +} export interface QueryResultReactProvider extends Omit, @@ -31,6 +51,8 @@ export interface QueryResultReactProvider onAction: (action: GridContextMenuAction) => void | Promise, ) => void; hideGridContextMenu: () => void; + showColumnFilterPopup: (options: ColumnFilterPopupOptions) => void; + hideColumnMenuPopup: () => 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 @@ -63,6 +85,23 @@ const QueryResultStateProvider: React.FC = ({ children onAction?: (action: GridContextMenuAction) => void | Promise; }>({ open: false, x: 0, y: 0 }); + const [filterPopupState, setFilterPopupState] = useState( + undefined, + ); + + const hideFilterPopup = useCallback(() => { + setFilterPopupState((state) => { + if (state?.onDismiss) { + state.onDismiss(); + } + return undefined; + }); + }, []); + + const hideContextMenu = useCallback(() => { + setMenuState((s) => (s.open ? { ...s, open: false } : s)); + }, []); + const commands = useMemo( () => ({ extensionRpc, @@ -76,11 +115,20 @@ const QueryResultStateProvider: React.FC = ({ children // Grid context menu API showGridContextMenu: (x: number, y: number, onAction) => { + hideFilterPopup(); setMenuState({ open: true, x, y, onAction }); }, hideGridContextMenu: () => { setMenuState((s) => ({ ...s, open: false })); }, + showColumnFilterPopup: (options: ColumnFilterPopupOptions) => { + setMenuState((s) => (s.open ? { ...s, open: false } : s)); + setFilterPopupState((state) => { + state?.onDismiss?.(); + return { ...options }; + }); + }, + hideColumnMenuPopup: hideFilterPopup, openFileThroughLink: (content: string, type: string) => { extensionRpc.action("openFileThroughLink", { content, type }); @@ -124,24 +172,27 @@ const QueryResultStateProvider: React.FC = ({ children extensionRpc.action("updateTotalCost", { addedCost }); }, }), - [extensionRpc], + [extensionRpc, hideFilterPopup], ); // Close context menu when focus leaves the webview or it becomes hidden useEffect(() => { - const closeMenu = () => setMenuState((s) => (s.open ? { ...s, open: false } : s)); + const closeOverlays = () => { + hideContextMenu(); + hideFilterPopup(); + }; const handleVisibilityChange = () => { if (document.visibilityState === "hidden") { - closeMenu(); + closeOverlays(); } }; - window.addEventListener("blur", closeMenu); + window.addEventListener("blur", closeOverlays); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { - window.removeEventListener("blur", closeMenu); + window.removeEventListener("blur", closeOverlays); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, []); + }, [hideFilterPopup]); return ( {children} @@ -157,6 +208,28 @@ const QueryResultStateProvider: React.FC = ({ children onClose={() => setMenuState((s) => ({ ...s, open: false }))} /> )} + {filterPopupState && ( + { + await filterPopupState.onApply(selected); + hideFilterPopup(); + }} + onClear={async () => { + await filterPopupState.onClear(); + hideFilterPopup(); + }} + onDismiss={() => { + hideFilterPopup(); + }} + onClearSort={filterPopupState.onClearSort} + onSortAscending={filterPopupState.onSortAscending} + onSortDescending={filterPopupState.onSortDescending} + currentSort={filterPopupState.currentSort} + /> + )} ); }; diff --git a/src/reactviews/pages/QueryResult/table/hybridDataProvider.ts b/src/reactviews/pages/QueryResult/table/hybridDataProvider.ts index ed812f756c..7d2c5b9a42 100644 --- a/src/reactviews/pages/QueryResult/table/hybridDataProvider.ts +++ b/src/reactviews/pages/QueryResult/table/hybridDataProvider.ts @@ -70,6 +70,12 @@ export class HybridDataProvider implements IDisposabl } public async getColumnValues(column: Slick.Column): Promise { + if (this.thresholdReached) { + await this.queryResultContext.extensionRpc.sendRequest( + ShowFilterDisabledMessageRequest.type, + ); + throw new Error("In-memory data processing is disabled."); + } await this.initializeCacheIfNeeded(); return this.provider.getColumnValues(column); } diff --git a/src/reactviews/pages/QueryResult/table/interfaces.ts b/src/reactviews/pages/QueryResult/table/interfaces.ts index 0d843e9ebb..9efe73fa3b 100644 --- a/src/reactviews/pages/QueryResult/table/interfaces.ts +++ b/src/reactviews/pages/QueryResult/table/interfaces.ts @@ -5,7 +5,6 @@ import { ColumnFilterState, SortProperties } from "../../../../sharedInterfaces/queryResult"; import { IDisposableDataProvider } from "./dataProvider"; -import { SortDirection } from "./plugins/headerFilter.plugin"; export interface ITableMouseEvent { anchor: HTMLElement | { x: number; y: number }; @@ -70,7 +69,7 @@ export interface FilterableColumn extends Slick.Colum export interface ColumnSortState { column: Slick.Column; - sortDirection: SortDirection; + sortDirection: SortProperties; } export interface ITableKeyboardEvent { diff --git a/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx b/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx new file mode 100644 index 0000000000..23dce5d4ba --- /dev/null +++ b/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx @@ -0,0 +1,702 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Button, + Checkbox, + CheckboxOnChangeData, + Input, + InputOnChangeData, + Text, + makeStyles, + mergeClasses, + shorthands, + tokens, +} from "@fluentui/react-components"; +import { + Dismiss16Regular, + DismissCircle16Regular, + Search16Regular, + TextSortAscending16Regular, + TextSortDescending16Regular, +} from "@fluentui/react-icons"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { locConstants } from "../../../../common/locConstants"; +import { SortProperties } from "../../../../../sharedInterfaces/queryResult"; + +export type FilterValue = string | undefined; + +export interface ColumnMenuPopupAnchorRect { + top: number; + left: number; + bottom: number; + right: number; + width: number; + height: number; +} + +export interface FilterListItem { + value: FilterValue; + displayText: string; + index: number; +} + +interface ColumnMenuPopupProps { + anchorRect: ColumnMenuPopupAnchorRect; + items: FilterListItem[]; + initialSelected: FilterValue[]; + onApply: (selected: FilterValue[]) => Promise | void; + onClear: () => Promise | void; + onDismiss: () => void; + onSortAscending: () => Promise | void; + onSortDescending: () => Promise | void; + onClearSort: () => Promise | void; + currentSort: SortProperties; +} + +const POPUP_WIDTH = 200; +const ITEM_HEIGHT = 22; +const LIST_HEIGHT = ITEM_HEIGHT * 4; + +const useStyles = makeStyles({ + root: { + position: "fixed", + zIndex: 100000, + width: POPUP_WIDTH + "px", + display: "flex", + flexDirection: "column", + ...shorthands.padding(tokens.spacingVerticalS, tokens.spacingHorizontalS), + backgroundColor: tokens.colorNeutralBackground1, + boxShadow: `${tokens.shadow28}, 0 0 0 1px ${tokens.colorNeutralStroke2}`, + color: tokens.colorNeutralForeground1, + ...shorthands.border("1px", "solid", tokens.colorTransparentStroke), + gap: tokens.spacingVerticalS, + }, + titleBar: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + minHeight: "16px", + }, + closeButton: { + width: "16px", + height: "16px", + }, + sectionHeading: { + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground3, + textTransform: "uppercase", + letterSpacing: "0.5px", + lineHeight: "16px", + }, + section: { + display: "flex", + flexDirection: "column", + rowGap: tokens.spacingVerticalXS, + }, + divider: { + height: "1px", + backgroundColor: tokens.colorNeutralStroke2, + }, + header: { + display: "flex", + flexDirection: "column", + rowGap: tokens.spacingVerticalXS, + }, + topRow: { + display: "flex", + alignItems: "center", + columnGap: tokens.spacingHorizontalXS, + }, + sortButtons: { + display: "flex", + flexDirection: "column", + columnGap: "2px", + "& button": { + justifyContent: "flex-start", + }, + }, + sortButton: { + minWidth: "20px", + minHeight: "20px", + width: "20px", + height: "20px", + }, + searchInput: { + flex: 1, + minWidth: 0, + }, + listContainer: { + height: LIST_HEIGHT + "px", + overflowY: "auto", + overflowX: "hidden", + position: "relative", + "&:focus": { + outlineStyle: "solid", + outlineWidth: "2px", + outlineColor: tokens.colorBrandBackground, + outlineOffset: "-2px", + }, + }, + selectAllRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingRight: "4px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + borderTopLeftRadius: tokens.borderRadiusSmall, + borderTopRightRadius: tokens.borderRadiusSmall, + height: ITEM_HEIGHT + "px", + }, + scrollableList: { + ...shorthands.padding(0, "4px"), + position: "relative", + width: "100%", + }, + virtualItem: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + }, + optionRow: { + display: "flex", + alignItems: "center", + height: ITEM_HEIGHT + "px", + columnGap: 0, + cursor: "pointer", + "&:hover": { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + optionRowFocused: { + backgroundColor: tokens.colorNeutralBackground1Selected, + outline: `2px solid ${tokens.colorBrandBackground}`, + outlineOffset: "-2px", + }, + optionCheckbox: { + width: "100%", + minWidth: 0, + pointerEvents: "none", + "& .fui-Checkbox__label": { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + }, + actions: { + display: "flex", + columnGap: tokens.spacingHorizontalXS, + flexWrap: "nowrap", + }, + actionButton: { + flex: 1, + minWidth: 0, + }, + emptyState: { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: LIST_HEIGHT + "px", + color: tokens.colorNeutralForegroundDisabled, + padding: tokens.spacingHorizontalM, + textAlign: "center", + }, + counter: { + color: tokens.colorNeutralForeground3, + whiteSpace: "nowrap", + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + flexShrink: 0, + paddingRight: "4px", + }, + compactCheckbox: { + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + minHeight: ITEM_HEIGHT + "px", + height: ITEM_HEIGHT + "px", + display: "flex", + alignItems: "center", + "& .fui-Checkbox__indicator": { + width: "12px", + height: "12px", + fontSize: "10px", + flexShrink: 0, + alignSelf: "center", + }, + }, +}); + +export const ColumnMenuPopup: React.FC = ({ + anchorRect, + items, + initialSelected, + onApply, + onClear, + onDismiss, + onSortAscending, + onSortDescending, + onClearSort, + currentSort = SortProperties.NONE, +}) => { + const styles = useStyles(); + const containerRef = useRef(null); + const rootRef = useRef(null); + const searchInputRef = useRef(null); + const sortAscendingButtonRef = useRef(null); + const closeButtonRef = useRef(null); + const firstFocusableRef = useRef(null); + const lastFocusableRef = useRef(null); + const [search, setSearch] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + () => new Set(initialSelected), + ); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const filteredItems = useMemo(() => { + const trimmed = search.trim().toLowerCase(); + if (!trimmed) { + return items; + } + return items.filter((item) => item.displayText.toLowerCase().includes(trimmed)); + }, [items, search]); + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => containerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 4, + }); + + const updateSelection = useCallback((value: FilterValue, checked: boolean) => { + setSelectedValues((prev) => { + const next = new Set(prev); + if (checked) { + next.add(value); + } else { + next.delete(value); + } + return next; + }); + }, []); + + // Handle outside clicks and keyboard navigation + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (!rootRef.current) { + return; + } + const target = event.target as Node | null; + if (target && rootRef.current.contains(target)) { + return; + } + onDismiss(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onDismiss(); + return; + } + + // Only handle arrow keys and space when focused on the list container + const activeElement = document.activeElement; + if (activeElement !== containerRef.current) { + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusedIndex((prev) => { + const nextIndex = prev + 1; + return nextIndex >= filteredItems.length ? 0 : nextIndex; + }); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusedIndex((prev) => { + const nextIndex = prev - 1; + return nextIndex < 0 ? filteredItems.length - 1 : nextIndex; + }); + } else if ( + event.key === " " && + focusedIndex >= 0 && + focusedIndex < filteredItems.length + ) { + event.preventDefault(); + const item = filteredItems[focusedIndex]; + updateSelection(item.value, !selectedValues.has(item.value)); + } + }; + window.addEventListener("mousedown", handleOutsideClick, true); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("mousedown", handleOutsideClick, true); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onDismiss, filteredItems, focusedIndex, selectedValues, updateSelection]); + + // Sync selected values when initialSelected or items change + useEffect(() => { + setSelectedValues(new Set(initialSelected)); + setSearch(""); + }, [initialSelected, items]); + + // Auto-focus sort button when opened + useEffect(() => { + if (sortAscendingButtonRef.current) { + sortAscendingButtonRef.current.focus(); + } + }, [anchorRect]); + + // Auto-scroll to focused item + useEffect(() => { + if (focusedIndex >= 0 && containerRef.current) { + const itemTop = focusedIndex * ITEM_HEIGHT; + const itemBottom = itemTop + ITEM_HEIGHT; + const scrollTop = containerRef.current.scrollTop; + const scrollBottom = scrollTop + LIST_HEIGHT; + + if (itemTop < scrollTop) { + containerRef.current.scrollTop = itemTop; + } else if (itemBottom > scrollBottom) { + containerRef.current.scrollTop = itemBottom - LIST_HEIGHT; + } + } + }, [focusedIndex]); + + // Reset focus when filtered items change + useEffect(() => { + virtualizer.scrollToIndex(0, { align: "start" }); + setFocusedIndex(-1); + }, [filteredItems.length, virtualizer]); + + const selectAllState = useMemo(() => { + if (filteredItems.length === 0) { + return false as const; + } + let selectedCount = 0; + for (const item of filteredItems) { + if (selectedValues.has(item.value)) { + selectedCount += 1; + } + } + if (selectedCount === 0) { + return false as const; + } + if (selectedCount === filteredItems.length) { + return true as const; + } + return "mixed" as const; + }, [filteredItems, selectedValues]); + + const position = useMemo(() => { + const horizontalMargin = 8; + const verticalMargin = 8; + const estimatedHeight = LIST_HEIGHT + 120; + + // Calculate available space and position + let left = anchorRect.left; + const spaceOnRight = window.innerWidth - anchorRect.left; + + // If popup would overflow on the right, align it to the right edge of the anchor + if (spaceOnRight < POPUP_WIDTH + horizontalMargin) { + left = Math.max(horizontalMargin, anchorRect.right - POPUP_WIDTH); + } + + // Ensure popup doesn't overflow the viewport + left = Math.max( + horizontalMargin, + Math.min(left, window.innerWidth - POPUP_WIDTH - horizontalMargin), + ); + + const maxTop = Math.max( + verticalMargin, + window.innerHeight - estimatedHeight - verticalMargin, + ); + const top = Math.min(anchorRect.bottom + 4, Math.max(maxTop, verticalMargin)); + + return { left, top }; + }, [anchorRect]); + + const onToggleSelectAll = useCallback( + (_e: React.ChangeEvent, data: CheckboxOnChangeData) => { + // Determine if we should select all based on current state + // When selectAllState is false or mixed, and user clicks, data.checked will be true -> select all + // When selectAllState is true, and user clicks, data.checked will be false -> deselect all + const shouldSelectAll = data.checked === true || data.checked === "mixed"; + setSelectedValues((prev) => { + const next = new Set(prev); + for (const item of filteredItems) { + if (shouldSelectAll) { + next.add(item.value); + } else { + next.delete(item.value); + } + } + return next; + }); + }, + [filteredItems], + ); + + const handleSearchChange = useCallback( + (_e: React.ChangeEvent, data: InputOnChangeData) => { + setSearch(data.value); + }, + [], + ); + + const handleApply = useCallback(async () => { + await onApply(Array.from(selectedValues)); + }, [onApply, selectedValues]); + + const handleClear = useCallback(async () => { + setSelectedValues(new Set()); + await onClear(); + }, [onClear]); + + const handleClose = useCallback(() => { + onDismiss(); + }, [onDismiss]); + + const handleSortAscending = useCallback(async () => { + if (onSortAscending) { + await onSortAscending(); + onDismiss(); + } + }, [onSortAscending, onDismiss]); + + const handleSortDescending = useCallback(async () => { + if (onSortDescending) { + await onSortDescending(); + onDismiss(); + } + }, [onSortDescending, onDismiss]); + + const handleClearSort = useCallback(async () => { + if (onClearSort) { + await onClearSort(); + onDismiss(); + } + }, [onClearSort, onDismiss]); + + const handleRootKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Tab") { + const target = e.target as HTMLElement; + + // If Shift+Tab on first element, wrap to close button if it exists, otherwise to last + if (e.shiftKey && target === firstFocusableRef.current) { + e.preventDefault(); + if (closeButtonRef.current) { + closeButtonRef.current.focus(); + } else { + lastFocusableRef.current?.focus(); + } + } + // If Tab on close button, go to first element + else if (!e.shiftKey && target === closeButtonRef.current) { + e.preventDefault(); + firstFocusableRef.current?.focus(); + } + // If Shift+Tab on close button, go to last element + else if (e.shiftKey && target === closeButtonRef.current) { + e.preventDefault(); + lastFocusableRef.current?.focus(); + } + // If Tab on last element, wrap to close button if it exists, otherwise to first + else if (!e.shiftKey && target === lastFocusableRef.current) { + e.preventDefault(); + if (closeButtonRef.current) { + closeButtonRef.current.focus(); + } else { + firstFocusableRef.current?.focus(); + } + } + } + }, []); + + return ( +
e.stopPropagation()} + onKeyDown={handleRootKeyDown}> +
+ Sort +
+
+
+
+
+ + + + + +
+
+ +
+
+ Filter +
+ { + searchInputRef.current = el; + }} + className={styles.searchInput} + appearance="outline" + size="small" + placeholder={locConstants.queryResult.search} + value={search} + onChange={handleSearchChange} + role="searchbox" + contentBefore={} + /> +
+
+
+ {filteredItems.length === 0 ? ( +
+ {locConstants.queryResult.noResultsToDisplay} +
+ ) : ( + <> +
+ + + {locConstants.queryResult.selectedCount(selectedValues.size)} + +
+
{ + if (focusedIndex === -1 && filteredItems.length > 0) { + setFocusedIndex(0); + } + }}> +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = filteredItems[virtualItem.index]; + const isChecked = selectedValues.has(item.value); + const isFocused = virtualItem.index === focusedIndex; + return ( +
+
updateSelection(item.value, !isChecked)} + onMouseEnter={() => setFocusedIndex(virtualItem.index)}> + +
+
+ ); + })} +
+
+ + )} +
+ + +
+
+ ); +}; + +export default ColumnMenuPopup; diff --git a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts index 0b466192fa..6ca767699a 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts @@ -17,6 +17,7 @@ import { mixin } from "../objects"; import { tokens } from "@fluentui/react-components"; import { Keys } from "../../../../common/keys"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; +import { HeaderMenu } from "./headerFilter.plugin"; export interface ICellSelectionModelOptions { cellRangeSelector?: any; @@ -50,6 +51,7 @@ export class CellSelectionModel private context: QueryResultReactProvider, private uri: string, private resultSetSummary: ResultSetSummary, + private headerFilter?: HeaderMenu, ) { this.options = mixin(this.options, defaults, false); if (this.options.cellRangeSelector) { @@ -597,7 +599,7 @@ export class CellSelectionModel const key = e.key; // e.g., 'a', 'ArrowLeft' const metaOrCtrlPressed = this.isMac ? e.metaKey : e.ctrlKey; - // --- 1) Select All (Cmd/Ctrl + A) --- + // Select All (Cmd/Ctrl + A) if (metaOrCtrlPressed && key === Keys?.a) { e.preventDefault(); e.stopPropagation(); @@ -605,7 +607,20 @@ export class CellSelectionModel return; } - // --- 2) Range selection via Shift + Arrow (no Alt, no Meta/Ctrl) --- + // Open Header menu (Alt + F) --- + if (e.altKey && key === Keys?.ArrowDown && !e.shiftKey && !metaOrCtrlPressed) { + e.preventDefault(); + e.stopPropagation(); + if ( + this.headerFilter && + typeof this.headerFilter.openMenuForActiveColumn === "function" + ) { + await this.headerFilter.openMenuForActiveColumn(); + } + return; + } + + // Range selection via Shift + Arrow (no Alt, no Meta/Ctrl) const isArrow = key === (Keys?.ArrowLeft ?? "ArrowLeft") || key === (Keys?.ArrowRight ?? "ArrowRight") || diff --git a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts index d329f3abd0..f5175f4041 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts @@ -7,13 +7,12 @@ // heavily modified import { FilterableColumn } from "../interfaces"; -import { append, $ } from "../dom"; import { IDisposableDataProvider, instanceOfIDisposableDataProvider } from "../dataProvider"; import "../../../../media/table.css"; import { locConstants } from "../../../../common/locConstants"; import { resolveVscodeThemeType } from "../../../../common/utils"; -import { VirtualizedList } from "../../../../common/virtualizedList"; import { EventManager } from "../../../../common/eventManager"; +import type { ColumnMenuPopupAnchorRect, FilterListItem, FilterValue } from "./ColumnMenuPopup"; import { ColumnFilterState, @@ -26,17 +25,15 @@ import { import { ColorThemeKind } from "../../../../../sharedInterfaces/webview"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; -export type SortDirection = "sort-asc" | "sort-desc" | "reset"; - export interface CommandEventArgs { grid: Slick.Grid; column: Slick.Column; - command: SortDirection; + command: SortProperties; } export const FilterButtonWidth = 34; -export class HeaderFilter { +export class HeaderMenu { public onFilterApplied = new Slick.Event<{ grid: Slick.Grid; column: FilterableColumn; @@ -44,470 +41,445 @@ export class HeaderFilter { public onCommand = new Slick.Event>(); public enabled: boolean = true; - private activePopup: JQuery | null = null; + private _activeColumnId: string | null = null; - private grid!: Slick.Grid; - private handler = new Slick.EventHandler(); - private columnDef!: FilterableColumn; - private columnFilterButtonMapping: Map = new Map(); - private columnSortStateMapping: Map = new Map(); - private _listData: TableFilterListElement[] = []; - private _list!: VirtualizedList; + private _grid!: Slick.Grid; + private _handler = new Slick.EventHandler(); + private _columnDef!: FilterableColumn; + private _columnFilterButtonMapping: Map = new Map(); + private _columnSortStateMapping: Map = new Map< + string, + SortProperties + >(); private _eventManager = new EventManager(); - private currentSortColumn: string = ""; - private currentSortButton: JQuery | null = null; + private _currentSortColumn: string = ""; constructor( - private uri: string, + private readonly uri: string, public theme: ColorThemeKind, - private queryResultContext: QueryResultReactProvider, - private gridId: string, + private readonly queryResultContext: QueryResultReactProvider, + private readonly gridId: string, ) {} public init(grid: Slick.Grid): void { - this.grid = grid; - this.handler + this._grid = grid; + this._handler .subscribe( - this.grid.onHeaderCellRendered, + this._grid.onHeaderCellRendered, (e: Event, args: Slick.OnHeaderCellRenderedEventArgs) => - this.handleHeaderCellRendered(e, args), + this.onHeaderCellRendered(e, args), ) .subscribe( - this.grid.onBeforeHeaderCellDestroy, + this._grid.onBeforeHeaderCellDestroy, (e: Event, args: Slick.OnBeforeHeaderCellDestroyEventArgs) => - this.handleBeforeHeaderCellDestroy(e, args), + this.onBeforeHeaderCellDestroy(e, args), ) - .subscribe(this.grid.onBeforeDestroy, () => this.destroy()) - .subscribe(this.grid.onHeaderContextMenu, (e: Event) => - this.headerContextMenuHandler(e), + .subscribe(this._grid.onBeforeDestroy, () => this.destroy()) + .subscribe(this._grid.onHeaderContextMenu, (e, args) => + this.onHeaderContextMenu(e, args), ); } public destroy() { - this.handler.unsubscribeAll(); + this._handler.unsubscribeAll(); this._eventManager.clearEventListeners(); - this._list.dispose(); + this.queryResultContext.hideColumnMenuPopup(); + this._activeColumnId = null; } - private async headerContextMenuHandler(e: Event): Promise { + private async showColumnMenuForColumn(column: FilterableColumn): Promise { + if (!column || column.filterable === false) return; + + const columnId = column.id; + if (!columnId) { + return; + } + + const filterButton = this._columnFilterButtonMapping.get(columnId); + if (filterButton) { + await this.showColumnMenu(filterButton); + } + } + + private async onHeaderContextMenu( + e: Event, + args: Slick.OnHeaderContextMenuEventArgs, + ): Promise { // Prevent the default vscode context menu from showing on right-clicking the header e.preventDefault(); + e.stopPropagation(); + const column = args.column; + if (!column) { + return; + } + try { + await this.showColumnMenuForColumn(column); + } catch (err) { + console.error("Error showing column menu:", err); + } + } + + public async openMenuForActiveColumn(): Promise { + const activeCell = this._grid.getActiveCell(); + if (!activeCell) { + return; + } + const column = this._grid.getColumns()[activeCell.cell] as FilterableColumn; + await this.showColumnMenuForColumn(column); } - private handleHeaderCellRendered(_e: Event, args: Slick.OnHeaderCellRenderedEventArgs) { + private onHeaderCellRendered(_e: Event, args: Slick.OnHeaderCellRenderedEventArgs) { const column = args.column as FilterableColumn; - if ((column as FilterableColumn).filterable === false) { + if (column.filterable === false) { return; } + if (args.node.classList.contains("slick-header-with-filter")) { // the the filter button has already being added to the header return; } - // The default sorting feature is triggered by clicking on the column header, but that is conflicting with query editor grid, - // For query editor grid when column header is clicked, the entire column will be selected. - // If the column is not defined as sortable because of the above reason, we will add the sort indicator here. - if (column.sortable !== true) { - args.node.classList.add("slick-header-sortable"); - append(args.node, $("span.slick-sort-indicator")); - } const theme: string = resolveVscodeThemeType(this.theme); args.node.classList.add("slick-header-with-filter"); args.node.classList.add(theme); - const $filterButton = jQuery( - ``, + + // Add sort indicator + jQuery('').appendTo(args.node); + + const $menuButton = jQuery( + ` + `, - ) - .addClass("slick-header-sort-button") - .data("column", column); + if (column.filterValues?.length) { - this.setFilterButtonImage($filterButton, column.filterValues?.length > 0); - } - if (column.sorted) { - this.setSortButtonImage($sortButton, column); - this.columnSortStateMapping.set(column.id!, column.sorted); + this.updateMenuButtonImage($menuButton, column.filterValues?.length > 0); } - - const filterButton = $filterButton.get(0); - if (filterButton) { - this._eventManager.addEventListener(filterButton, "click", async (e: Event) => { + const buttonEl = $menuButton.get(0); + if (buttonEl) { + this._eventManager.addEventListener(buttonEl, "click", async (e: Event) => { e.stopPropagation(); e.preventDefault(); - await this.showFilter(filterButton); - this.grid.onHeaderClick.notify(); + await this.showColumnMenu(buttonEl); + this._grid.onHeaderClick.notify(); }); } + $menuButton.appendTo(args.node); - const sortButton = $sortButton.get(0); - if (sortButton) { - this._eventManager.addEventListener(sortButton, "click", async (e: Event) => { - e.stopPropagation(); - e.preventDefault(); - if (!this.enabled) { - await this.queryResultContext.extensionRpc.sendRequest( - ShowFilterDisabledMessageRequest.type, - ); - return; - } - this.columnDef = jQuery(sortButton).data("column"); //TODO: fix, shouldn't assign in the event handler - let columnFilterState: ColumnFilterState = { - columnDef: this.columnDef.id!, - filterValues: this.columnDef.filterValues!, - sorted: this.columnDef.sorted ?? SortProperties.NONE, - }; - let sortState = this.columnSortStateMapping.get(column.id!); - - switch (sortState) { - case SortProperties.NONE: - if (this.currentSortColumn && this.currentSortButton) { - const $prevSortButton = this.currentSortButton; - let prevColumnDef = jQuery($prevSortButton).data("column"); - $prevSortButton.removeClass("slick-header-sortasc-button"); - $prevSortButton.removeClass("slick-header-sortdesc-button"); - $prevSortButton.addClass("slick-header-sort-button"); - this.columnSortStateMapping.set( - this.currentSortColumn, - SortProperties.NONE, - ); - columnFilterState.sorted = SortProperties.NONE; - let prevFilterState: ColumnFilterState = { - columnDef: prevColumnDef.id!, - filterValues: prevColumnDef.filterValues!, - sorted: SortProperties.NONE, - }; - await this.updateState(prevFilterState, prevColumnDef.id!); - } - $sortButton.removeClass("slick-header-sort-button"); - $sortButton.addClass("slick-header-sortasc-button"); - $sortButton.attr("aria-label", locConstants.queryResult.sortDescending); // setting ASC, so next is DESC - $sortButton.attr("title", locConstants.queryResult.sortDescending); - await this.handleMenuItemClick("sort-asc", column); - this.columnSortStateMapping.set(column.id!, SortProperties.ASC); - columnFilterState.sorted = SortProperties.ASC; - this.currentSortColumn = column.id!; - this.currentSortButton = $sortButton; - break; - case SortProperties.ASC: - $sortButton.removeClass("slick-header-sortasc-button"); - $sortButton.addClass("slick-header-sortdesc-button"); - $sortButton.attr("aria-label", locConstants.queryResult.clearSort); // setting DESC, so next is cleared - $sortButton.attr("title", locConstants.queryResult.clearSort); - await this.handleMenuItemClick("sort-desc", column); - this.columnSortStateMapping.set(column.id!, SortProperties.DESC); - columnFilterState.sorted = SortProperties.DESC; - break; - case SortProperties.DESC: - $sortButton.removeClass("slick-header-sortdesc-button"); - $sortButton.addClass("slick-header-sort-button"); - $sortButton.attr("aria-label", locConstants.queryResult.sortAscending); // setting cleared, so next is ASC - $sortButton.attr("title", locConstants.queryResult.sortAscending); - this.columnSortStateMapping.set(column.id!, SortProperties.NONE); - await this.handleMenuItemClick("reset", column); - columnFilterState.sorted = SortProperties.NONE; - await this.updateState(columnFilterState, this.columnDef.id!); - this.currentSortColumn = ""; - break; - } - await this.updateState(columnFilterState, this.columnDef.id!); - this.grid.onHeaderClick.notify(); - }); + this._columnFilterButtonMapping.set(column.id!, buttonEl); + if (this._columnSortStateMapping.get(column.id!) === undefined) { + this._columnSortStateMapping.set(column.id!, SortProperties.NONE); } - $sortButton.appendTo(args.node); - $filterButton.appendTo(args.node); - - this.columnFilterButtonMapping.set(column.id!, filterButton); - if (this.columnSortStateMapping.get(column.id!) === undefined) { - this.columnSortStateMapping.set(column.id!, SortProperties.NONE); + // Update sort indicator if column is sorted + if (column.sorted && column.sorted !== SortProperties.NONE) { + this.updateSortIndicator(args.node, column.sorted); } } - private async showFilter(filterButton: HTMLElement) { + private async showColumnMenu(menuButton: HTMLElement) { if (!this.enabled) { await this.queryResultContext.extensionRpc.sendRequest( ShowFilterDisabledMessageRequest.type, ); return; } - let $menuButton: JQuery | undefined; - const target = withNullAsUndefined(filterButton); + const target = withNullAsUndefined(menuButton); if (target) { - $menuButton = jQuery(target); - this.columnDef = $menuButton.data("column"); - } - - // Check if the active popup is for the same button - if (this.activePopup) { - const isSameButton = this.activePopup.data("button") === filterButton; - // close the popup and reset activePopup - this.activePopup.fadeOut(); - this.activePopup = null; - if (isSameButton) { - return; // Exit since we're just closing the popup for the same button - } + const menuButton = jQuery(target); + this._columnDef = menuButton.data("column"); } - // Proceed to open the new popup for the clicked column - const offset = jQuery(filterButton).offset(); - const $popup = jQuery( - '", - ); - - const popupElement = $popup.get(0); - if (popupElement) { - this._eventManager.addEventListener(document, "click", (_e: Event) => { - if ($popup) { - const popupElement = $popup.get(0); - if (!popupElement.contains(_e.target as Node)) { - closePopup($popup); - this.activePopup = null; - } - } - }); + if (!this._columnDef) { + return; + } - this._eventManager.addEventListener(window, "blur", (_e: Event) => { - if ($popup) { - closePopup($popup); - this.activePopup = null; - } - }); + const columnId = this._columnDef.id!; + if (this._activeColumnId === columnId) { + this.queryResultContext.hideColumnMenuPopup(); + this._activeColumnId = null; + return; } - if (offset) { - $popup.css({ - top: offset.top + $menuButton?.outerHeight()!, // Position below the button - left: Math.min(offset.left, document.body.clientWidth - 250), // Position to the left of the button - }); + if (this._activeColumnId) { + this.queryResultContext.hideColumnMenuPopup(); } - await this.createFilterList(); + const filterItems = await this.buildFilterItems(); + const initialSelected = (this._columnDef.filterValues ?? []) as FilterValue[]; + const anchorRect = this.toAnchorRect(menuButton.getBoundingClientRect()); + const filterButtonElement = jQuery(menuButton); - // Append and show the new popup - $popup.appendTo(document.body); - openPopup($popup); + const applySelection = async (selected: FilterValue[]) => { + this._columnDef.filterValues = selected as unknown as string[]; + this.updateMenuButtonImage( + filterButtonElement, + this._columnDef.filterValues.length > 0, + ); + await this.applyFilters(this._columnDef); + }; + + const clearSelection = async () => { + this._columnDef.filterValues = []; + this.updateMenuButtonImage(filterButtonElement, false); + await this.applyFilters(this._columnDef, true); + }; + + // Get current sort state for this column + const currentSort = this._columnSortStateMapping.get(columnId) ?? SortProperties.NONE; + + this.queryResultContext.showColumnFilterPopup({ + columnId, + anchorRect, + items: filterItems, + initialSelected, + onApply: async (selected) => { + await applySelection(selected); + }, + onClear: async () => { + await clearSelection(); + }, + onDismiss: () => { + this._activeColumnId = null; + this.setFocusToColumn(this._columnDef); + }, + onSortAscending: async () => { + await this.applySort(this._columnDef, SortProperties.ASC); + }, + onSortDescending: async () => { + await this.applySort(this._columnDef, SortProperties.DESC); + }, + onClearSort: async () => { + await this.handleClearSort(this._columnDef); + }, + currentSort, + }); - // Store the clicked button reference with the popup, so we can check it later - $popup.data("button", filterButton); + this._activeColumnId = columnId; + this._grid.onHeaderClick.notify(); + } - // Set the new popup as the active popup - this.activePopup = $popup; - const checkboxContainer = $popup.find("#checkbox-list"); - this._list = this.createList(checkboxContainer); + private async applySort(column: FilterableColumn, command: SortProperties) { + const columnId = column.id!; + + // Clear previous sort state + if (this._currentSortColumn && this._currentSortColumn !== columnId) { + const prevColumn = this._grid + .getColumns() + .find((col) => col.id === this._currentSortColumn) as FilterableColumn; + if (prevColumn) { + this._columnSortStateMapping.set(this._currentSortColumn, SortProperties.NONE); + let prevFilterState: ColumnFilterState = { + columnDef: prevColumn.id!, + filterValues: prevColumn.filterValues ?? [], + sorted: SortProperties.NONE, + }; + await this.updateState(prevFilterState, prevColumn.id!); + // Clear sort indicator on previous column + const prevHeaderNode = this.getHeaderNode(prevColumn.id!); + if (prevHeaderNode) { + this.updateSortIndicator(prevHeaderNode, SortProperties.NONE); + } + } + } - $popup.find("#search-input").on("input", (e: Event) => { - const searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); + this._columnSortStateMapping.set(columnId, command); + this._currentSortColumn = columnId; - const visibleItems: TableFilterListElement[] = []; + await this.handleMenuItemClick(command, column); - this._listData.forEach((i) => { - i.isVisible = i.displayText.toLowerCase().includes(searchTerm); - if (i.isVisible) { - visibleItems.push(i); - } - }); - this._list.updateItems(visibleItems); - }); + const columnFilterState: ColumnFilterState = { + columnDef: column.id!, + filterValues: column.filterValues ?? [], + sorted: command, + }; + await this.updateState(columnFilterState, column.id!); - $popup.find("#select-all-checkbox").on("change", (e: Event) => { - const isChecked = (e.target as HTMLInputElement).checked; - this.selectAllFiltered(isChecked); - }); + // Update sort indicator on current column + const headerNode = this.getHeaderNode(columnId); + if (headerNode) { + this.updateSortIndicator(headerNode, command); + } + } - jQuery(document).on("click", (e: JQuery.ClickEvent) => { - const $target = jQuery(e.target); + private getHeaderNode(columnId: string): HTMLElement | null { + const columns = this._grid.getColumns(); + const columnIndex = columns.findIndex((col) => col.id === columnId); + if (columnIndex >= 0) { + const gridContainer = this._grid.getContainerNode(); + return gridContainer?.querySelector( + `.slick-header-columns .slick-header-column:nth-child(${columnIndex + 1})`, + ) as HTMLElement | null; + } + return null; + } - // If the clicked target is not the button or the menu, close the menu - if (!$target.closest("#anchor-btn").length && !$target.closest("#popup-menu").length) { - if (this.activePopup) { - this.activePopup.fadeOut(); - this.activePopup = null; - } + private updateSortIndicator(headerNode: HTMLElement, sortState: SortProperties): void { + const indicator = headerNode.querySelector(".slick-sort-indicator-icon"); + if (indicator) { + indicator.classList.remove("sorted-asc", "sorted-desc"); + if (sortState === SortProperties.ASC) { + indicator.classList.add("sorted-asc"); + } else if (sortState === SortProperties.DESC) { + indicator.classList.add("sorted-desc"); } - }); + } + } - jQuery(document).on("contextmenu", () => { - if (this.activePopup) { - this.activePopup!.fadeOut(); - this.activePopup = null; - } - }); + private async handleClearSort(column: FilterableColumn) { + const columnId = column.id!; - // Close the pop-up when the close-popup button is clicked - jQuery(document).on("click", `#close-popup-${this.columnDef.id}`, () => { - closePopup($popup); - this.activePopup = null; - }); + // Only clear if this column is currently sorted + const currentSortState = this._columnSortStateMapping.get(columnId); + if (currentSortState === SortProperties.NONE || currentSortState === undefined) { + return; + } - jQuery(document).on("click", `#apply-${this.columnDef.id}`, async () => { - closePopup($popup); - this.activePopup = null; - this.applyFilterSelections(); - if (!$menuButton) { - return; - } - if (this.columnDef.filterValues) { - this.setFilterButtonImage($menuButton, this.columnDef.filterValues.length > 0); - } - await this.handleApply(this.columnDef); - }); + // Clear current sort state + this._columnSortStateMapping.set(columnId, SortProperties.NONE); + this._currentSortColumn = ""; - jQuery(document).on("click", `#clear-${this.columnDef.id}`, async () => { - if (this.columnDef.filterValues) { - this.columnDef.filterValues.length = 0; - } + // Clear sort in grid + await this.handleMenuItemClick(SortProperties.NONE, column); - if (!$menuButton) { - return; - } - this.setFilterButtonImage($menuButton, false); - await this.handleApply(this.columnDef, true); - }); + // Update state + const columnFilterState: ColumnFilterState = { + columnDef: column.id!, + filterValues: column.filterValues ?? [], + sorted: SortProperties.NONE, + }; + await this.updateState(columnFilterState, column.id!); - function closePopup($popup: JQuery) { - $popup.hide({ - duration: 0, - }); + // Clear sort indicator + const headerNode = this.getHeaderNode(columnId); + if (headerNode) { + this.updateSortIndicator(headerNode, SortProperties.NONE); + } + } + + private async buildFilterItems(): Promise { + this._columnDef.filterValues = this._columnDef.filterValues || []; + let filterItems: FilterValue[]; + const dataView = this._grid.getData() as IDisposableDataProvider; + + if (instanceOfIDisposableDataProvider(dataView)) { + filterItems = await dataView.getColumnValues(this._columnDef); + } else { + const filterApplied = + this._grid.getColumns().findIndex((col) => { + const filterableColumn = col as FilterableColumn; + return (filterableColumn.filterValues?.length ?? 0) > 0; + }) !== -1; + if (!filterApplied) { + filterItems = this.getFilterValues( + this._grid.getData() as Slick.DataProvider, + this._columnDef, + ); + } else { + filterItems = this.getAllFilterValues( + (this._grid.getData() as Slick.Data.DataView).getFilteredItems(), + this._columnDef, + ); + } } - function openPopup($popup: JQuery) { - $popup.show(); - ($popup[0] as HTMLElement).focus(); + const uniqueValues = new Map(); + for (const value of filterItems) { + const normalized = this.normalizeFilterValue(value); + if (typeof normalized === "string" && normalized.indexOf("Error:") >= 0) { + continue; + } + if (!uniqueValues.has(normalized)) { + uniqueValues.set(normalized, this.getDisplayText(normalized)); + } } - } - public createList(checkboxContainer: JQuery) { - return new VirtualizedList( - checkboxContainer.get(0), - this._listData, - (itemContainer, item) => { - itemContainer.style.boxSizing = "border-box"; - itemContainer.style.display = "flex"; - itemContainer.style.alignItems = "center"; - itemContainer.style.padding = "0 5px"; - itemContainer.id = `listitemcontainer-${item.index}`; - - itemContainer.addEventListener("keydown", (e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - - let nextIndex = e.key === "ArrowDown" ? item.index + 1 : item.index - 1; - const nextItemContainer = checkboxContainer - .get(0) - .querySelectorAll(`[id="listitemcontainer-${nextIndex}"]`); - if (nextItemContainer) { - const nextItem = nextItemContainer[0] as HTMLElement; - nextItem.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - nextItem.tabIndex = 0; // Set tabIndex to 0 for the next item - itemContainer.tabIndex = -1; // Remove focus from the current item - nextItem.focus(); - } - } - }); + const nullEntries: Array<[FilterValue, string]> = []; + const blankEntries: Array<[FilterValue, string]> = []; + const otherEntries: Array<[FilterValue, string]> = []; - const id = `checkbox-${item.index}`; - const checkboxElement = document.createElement("input"); - checkboxElement.type = "checkbox"; - checkboxElement.checked = item.checked; - checkboxElement.name = item.value; - checkboxElement.id = id; - checkboxElement.tabIndex = -1; - - // Attach change listener - this._eventManager.addEventListener(checkboxElement, "change", () => { - this._listData[item.index].checked = checkboxElement.checked; - item.checked = checkboxElement.checked; - }); + uniqueValues.forEach((displayText, value) => { + if (value === undefined) { + nullEntries.push([value, displayText]); + } else if (value === "") { + blankEntries.push([value, displayText]); + } else { + otherEntries.push([value, displayText]); + } + }); - const label = document.createElement("label"); - label.style.flex = "1"; - label.style.overflow = "hidden"; - label.style.textOverflow = "ellipsis"; - label.style.whiteSpace = "nowrap"; - label.innerText = item.displayText; - label.title = item.displayText; - label.setAttribute("for", id); - - itemContainer.appendChild(checkboxElement); - itemContainer.appendChild(label); - }, - (itemDiv, item) => { - const checkboxElement = itemDiv.querySelector( - "input[type='checkbox']", - ) as HTMLInputElement; - checkboxElement.checked = !checkboxElement.checked; - this._listData[item.index].checked = checkboxElement.checked; - item.checked = checkboxElement.checked; - }, - { - itemHeight: 30, - buffer: 5, - }, + otherEntries.sort((a, b) => + this.filterValueToSortString(a[0]).localeCompare(this.filterValueToSortString(b[0])), ); + + const orderedEntries = [...nullEntries, ...blankEntries, ...otherEntries]; + + return orderedEntries.map(([value, displayText], index) => ({ + value, + displayText, + index, + })); } - private selectAllFiltered(select: boolean) { - for (let i = 0; i < this._listData.length; i++) { - if (!this._listData[i].isVisible) { - continue; - } - this._listData[i].checked = select; - } - this._list.updateItems(this._listData.filter((i) => i.isVisible)); + private toAnchorRect(rect: DOMRect): ColumnMenuPopupAnchorRect { + return { + top: rect.top, + left: rect.left, + bottom: rect.bottom, + right: rect.right, + width: rect.width, + height: rect.height, + }; } - private applyFilterSelections() { - const selectedValues: string[] = this._listData - .filter((i) => i.checked) - .map((i) => i.value); + private normalizeFilterValue(value: unknown): FilterValue { + if (value === undefined || value === null) { + return undefined; + } + return String(value); + } - // Update the column filter values - this.columnDef.filterValues = selectedValues; - this.onFilterApplied.notify({ - grid: this.grid, - column: this.columnDef, - }); + private filterValueToSortString(value: FilterValue): string { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } - // Refresh the grid or apply filtering logic based on the selected values - this.grid.invalidate(); - this.grid.render(); + private getDisplayText(value: FilterValue): string { + if (value === undefined || value === null) { + return locConstants.queryResult.null; + } + if (value === "") { + return locConstants.queryResult.blankString; + } + return String(value); } private async resetData(columnDef: Slick.Column) { - const dataView = this.grid.getData() as IDisposableDataProvider; + const dataView = this._grid.getData() as IDisposableDataProvider; if (instanceOfIDisposableDataProvider(dataView)) { - await dataView.filter(this.grid.getColumns()); - this.grid.invalidateAllRows(); - this.grid.updateRowCount(); - this.grid.render(); + await dataView.filter(this._grid.getColumns()); + this._grid.invalidateAllRows(); + this._grid.updateRowCount(); + this._grid.render(); } - this.onFilterApplied.notify({ grid: this.grid, column: columnDef }); + this.onFilterApplied.notify({ grid: this._grid, column: columnDef }); this.setFocusToColumn(columnDef); } - private async handleApply(columnDef: Slick.Column, clear?: boolean) { + private async applyFilters(columnDef: Slick.Column, clear?: boolean) { let columnFilterState: ColumnFilterState; await this.resetData(columnDef); // clear filterValues if clear is true if (clear) { columnFilterState = { - columnDef: this.columnDef.id!, + columnDef: this._columnDef.id!, filterValues: [], sorted: SortProperties.NONE, }; @@ -523,10 +495,7 @@ export class HeaderFilter { gridColumnMapArray = []; } // Drill down into the grid column map array and clear the filter values for the specified column - gridColumnMapArray = await this.clearFilterValues( - gridColumnMapArray, - columnDef.id!, - ); + gridColumnMapArray = this.clearFilterValues(gridColumnMapArray, columnDef.id!); await this.queryResultContext.extensionRpc.sendRequest(SetFiltersRequest.type, { uri: this.uri, filters: gridColumnMapArray, @@ -534,9 +503,9 @@ export class HeaderFilter { } } else { columnFilterState = { - columnDef: this.columnDef.id!, - filterValues: this.columnDef.filterValues!, - sorted: this.columnDef.sorted, + columnDef: this._columnDef.id!, + filterValues: this._columnDef.filterValues!, + sorted: this._columnDef.sorted, }; } @@ -578,15 +547,13 @@ export class HeaderFilter { * @param columnId * @returns */ - private async clearFilterValues(gridFiltersArray: GridColumnMap[], columnId: string) { + 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) => ({ @@ -596,31 +563,9 @@ export class HeaderFilter { } } - this._listData = []; - const dataView = this.grid.getData() as IDisposableDataProvider; - - let filterItems = await dataView.getColumnValues(this.columnDef); - this.columnDef.filterValues = this.columnDef.filterValues || []; - const workingFilters = this.columnDef.filterValues.slice(0); - - this.compileFilters(workingFilters, filterItems); - this._list.updateItems(this._listData.filter((i) => i.isVisible)); return gridFiltersArray; } - private compileFilters(workingFilters: string[], filterItems: string[]) { - for (let i = 0; i < filterItems.length; i++) { - const filtered = workingFilters.some((x) => x === filterItems[i]); - // work item to remove the 'Error:' string check: https://github.com/microsoft/azuredatastudio/issues/15206 - const filterItem = filterItems[i]; - if (!filterItem || filterItem.indexOf("Error:") < 0) { - let element = new TableFilterListElement(filterItem, filtered); - element.index = i; - this._listData.push(element); - } - } - } - /** * 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 @@ -667,30 +612,30 @@ export class HeaderFilter { return [...gridFiltersArray]; } - private async handleMenuItemClick(command: SortDirection, columnDef: Slick.Column) { - const dataView = this.grid.getData(); - if (command === "sort-asc" || command === "sort-desc") { - this.grid.setSortColumn(columnDef.id as string, command === "sort-asc"); + private async handleMenuItemClick(command: SortProperties, columnDef: Slick.Column) { + const dataView = this._grid.getData(); + if (command === SortProperties.ASC || command === SortProperties.DESC) { + this._grid.setSortColumn(columnDef.id as string, command === SortProperties.ASC); } if (instanceOfIDisposableDataProvider(dataView)) { - if (command === "sort-asc" || command === "sort-desc") { + if (command === SortProperties.ASC || command === SortProperties.DESC) { await dataView.sort({ - grid: this.grid, + grid: this._grid, multiColumnSort: false, - sortCol: this.columnDef, - sortAsc: command === "sort-asc", + sortCol: this._columnDef, + sortAsc: command === SortProperties.ASC, }); } else { dataView.resetSort(); - this.grid.setSortColumn("", false); + this._grid.setSortColumn("", false); } - this.grid.invalidateAllRows(); - this.grid.updateRowCount(); - this.grid.render(); + this._grid.invalidateAllRows(); + this._grid.updateRowCount(); + this._grid.render(); } this.onCommand.notify({ - grid: this.grid, + grid: this._grid, column: columnDef, command: command, }); @@ -698,72 +643,35 @@ export class HeaderFilter { this.setFocusToColumn(columnDef); } - private async createFilterList(): Promise { - this.columnDef.filterValues = this.columnDef.filterValues || []; - // WorkingFilters is a copy of the filters to enable apply/cancel behaviour - const workingFilters = this.columnDef.filterValues.slice(0); - let filterItems: Array; - const dataView = this.grid.getData() as IDisposableDataProvider; - if (instanceOfIDisposableDataProvider(dataView)) { - filterItems = await dataView.getColumnValues(this.columnDef); - } else { - const filterApplied = - this.grid.getColumns().findIndex((col) => { - const filterableColumn = col as FilterableColumn; - return filterableColumn.filterValues?.length! > 0; - }) !== -1; - if (!filterApplied) { - // Filter based all available values - filterItems = this.getFilterValues( - this.grid.getData() as Slick.DataProvider, - this.columnDef, - ); - } else { - // Filter based on current dataView subset - filterItems = this.getAllFilterValues( - (this.grid.getData() as Slick.Data.DataView).getFilteredItems(), - this.columnDef, - ); - } - } - // Sort the list to make it easier to find a string - filterItems.sort(); - // Promote undefined (NULL) to be always at the top of the list - const nullValueIndex = filterItems.indexOf(""); - if (nullValueIndex !== -1) { - filterItems.splice(nullValueIndex, 1); - filterItems.unshift(""); - } - this._listData = []; - this.compileFilters(workingFilters, filterItems); - } - - private getFilterValues(dataView: Slick.DataProvider, column: Slick.Column): Array { - const seen: Set = new Set(); + private getFilterValues( + dataView: Slick.DataProvider, + column: Slick.Column, + ): FilterValue[] { + const seen: Set = new Set(); dataView.getItems().forEach((items) => { const value = items[column.field!]; const valueArr = value instanceof Array ? value : [value]; - valueArr.forEach((v) => seen.add(v)); + valueArr.forEach((v) => seen.add(this.normalizeFilterValue(v))); }); return Array.from(seen); } - private getAllFilterValues(data: Array, column: Slick.Column) { - const seen: Set = new Set(); + private getAllFilterValues(data: Array, column: Slick.Column): FilterValue[] { + const seen: Set = new Set(); data.forEach((items) => { const value = items[column.field!]; const valueArr = value instanceof Array ? value : [value]; - valueArr.forEach((v) => seen.add(v)); + valueArr.forEach((v) => seen.add(this.normalizeFilterValue(v))); }); - return Array.from(seen).sort((v) => { - return v; - }); + return Array.from(seen).sort((a, b) => + this.filterValueToSortString(a).localeCompare(this.filterValueToSortString(b)), + ); } - private handleBeforeHeaderCellDestroy( + private onBeforeHeaderCellDestroy( _e: Event, args: Slick.OnBeforeHeaderCellDestroyEventArgs, ) { @@ -771,15 +679,21 @@ export class HeaderFilter { } private setFocusToColumn(columnDef: Slick.Column): void { - if (this.grid.getDataLength() > 0) { - const column = this.grid.getColumns().findIndex((col) => col.id === columnDef.id); + if (this._grid.getDataLength() > 0) { + const column = this._grid.getColumns().findIndex((col) => col.id === columnDef.id); if (column >= 0) { - this.grid.setActiveCell(0, column); + // Select the single cell and set it as active + const cellRange = new Slick.Range(0, column, 0, column); + const selectionModel = this._grid.getSelectionModel(); + if (selectionModel && selectionModel.setSelectedRanges) { + selectionModel.setSelectedRanges([cellRange]); + } + this._grid.setActiveCell(0, column); } } } - private setFilterButtonImage($el: JQuery, filtered: boolean) { + private updateMenuButtonImage($el: JQuery, filtered: boolean) { const element: HTMLElement | undefined = $el.get(0); if (element) { if (filtered) { @@ -792,74 +706,6 @@ export class HeaderFilter { } } } - - private setSortButtonImage($sortButton: JQuery, column: FilterableColumn) { - if ($sortButton && column.sorted && column.sorted !== SortProperties.NONE) { - switch (column.sorted) { - case SortProperties.ASC: - $sortButton.removeClass("slick-header-sort-button"); - $sortButton.addClass("slick-header-sortasc-button"); - break; - case SortProperties.DESC: - $sortButton.removeClass("slick-header-sort-button"); - $sortButton.addClass("slick-header-sortdesc-button"); - break; - } - } - } -} - -export class TableFilterListElement { - private _checked: boolean; - private _isVisible: boolean; - private _index: number = 0; - public displayText: string; - public value: string; - - constructor(val: string, checked: boolean) { - this.value = val; - this._checked = checked; - this._isVisible = true; - // Handle the values that are visually hard to differentiate. - if (val === undefined) { - this.displayText = locConstants.queryResult.null; - } else if (val === "") { - this.displayText = locConstants.queryResult.blankString; - } else { - this.displayText = val; - } - } - - // public onCheckStateChanged = this._onCheckStateChanged.event; - - public get checked(): boolean { - return this._checked; - } - public set checked(val: boolean) { - if (this._checked !== val) { - this._checked = val; - } - } - - public get isVisible(): boolean { - return this._isVisible; - } - - public set isVisible(val: boolean) { - if (this._isVisible !== val) { - this._isVisible = val; - } - } - - public get index(): number { - return this._index; - } - - public set index(val: number) { - if (this._index !== val) { - this._index = val; - } - } } /** diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts index 93e8628d96..9c9a0fa9c3 100644 --- a/src/reactviews/pages/QueryResult/table/table.ts +++ b/src/reactviews/pages/QueryResult/table/table.ts @@ -11,7 +11,7 @@ import * as DOM from "./dom"; import { IDisposableDataProvider } from "./dataProvider"; import { CellSelectionModel } from "./plugins/cellSelectionModel.plugin"; import { mixin } from "./objects"; -import { HeaderFilter } from "./plugins/headerFilter.plugin"; +import { HeaderMenu } from "./plugins/headerFilter.plugin"; import { ContextMenu } from "./plugins/contextMenu.plugin"; import { ColumnFilterState, @@ -57,7 +57,7 @@ export class Table implements IThemable { private _container: HTMLElement; protected _tableContainer: HTMLElement; private selectionModel: CellSelectionModel; - public headerFilter: HeaderFilter; + public headerFilter: HeaderMenu; private _autoColumnSizePlugin: AutoColumnSize; private _lastScrollAt: number = 0; @@ -76,6 +76,7 @@ export class Table implements IThemable { themeKind: ColorThemeKind = ColorThemeKind.Dark, ) { this.linkHandler = linkHandler; + this.headerFilter = new HeaderMenu(this.uri, themeKind, this.context, gridId); this.selectionModel = new CellSelectionModel( { hasRowSelector: true, @@ -83,6 +84,7 @@ export class Table implements IThemable { context, uri, resultSetSummary, + this.headerFilter, ); if ( !configuration || @@ -138,7 +140,6 @@ export class Table implements IThemable { this._container.appendChild(this._tableContainer); this.styleElement = DOM.createStyleSheet(this._container); this._grid = new Slick.Grid(this._tableContainer, this._data, [], newOptions); - this.headerFilter = new HeaderFilter(this.uri, themeKind, this.context, gridId); this.registerPlugin(this.headerFilter); this.registerPlugin( new ContextMenu( diff --git a/src/reactviews/pages/QueryResult/table/tableDataView.ts b/src/reactviews/pages/QueryResult/table/tableDataView.ts index e17eaa0636..e2926c0ad8 100644 --- a/src/reactviews/pages/QueryResult/table/tableDataView.ts +++ b/src/reactviews/pages/QueryResult/table/tableDataView.ts @@ -5,6 +5,7 @@ import { ColumnSortState, FilterableColumn } from "./interfaces"; import { IDisposableDataProvider } from "./dataProvider"; +import { SortProperties } from "../../../../sharedInterfaces/queryResult"; export interface IFindPosition { col: number; @@ -181,7 +182,7 @@ export class TableDataView implements IDisposableData this._data = this._sortFn!( { sortCol: this._currentColumnSort.column, - sortAsc: this._currentColumnSort.sortDirection === "sort-asc", + sortAsc: this._currentColumnSort.sortDirection === SortProperties.ASC, grid: undefined, multiColumnSort: false, }, @@ -200,7 +201,7 @@ export class TableDataView implements IDisposableData this._data = this._sortFn!(args, this._data); this._currentColumnSort = { column: args.sortCol!, - sortDirection: args.sortAsc ? "sort-asc" : "sort-desc", + sortDirection: args.sortAsc ? SortProperties.ASC : SortProperties.DESC, }; console.log(args); // this._onSortComplete.fire(args); diff --git a/yarn.lock b/yarn.lock index 4b3858afa8..76cfee591d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2269,6 +2269,18 @@ dependencies: tslib "^2.8.0" +"@tanstack/react-virtual@^3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz#d372dc2783739cc04ec1a728ca8203937687a819" + integrity sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA== + dependencies: + "@tanstack/virtual-core" "3.13.12" + +"@tanstack/virtual-core@3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578" + integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"