mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-26 17:23:15 -05:00
Adding search, zoom, custom zoom and additional stylings to showplan (#18255)
* Lot of changes -Updating azdata to 0.0.13 -Updating prop views on node clicks -Context menu on graphs -Updating edge color on theme -Scrolling graph control like ssms -Zooming in, out and custom zoom on graph -Custom zoom widget -Node search widget * Fixing hygine errors * Code cleanup * Fixing action name * Renaming actions * equals dropdown * fixing tooltip * Code cleanup Fixing sorting function Adding functionality for replacement strings * Removing internal facing props from azdata proposed * Fixing hygine issue * Fixing web package hygiene * Updating yarn lock files * Fixing initial click
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox';
|
||||
import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar';
|
||||
import { attachInputBoxStyler } from 'sql/platform/theme/common/styler';
|
||||
import { QueryPlanWidgetBase } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase';
|
||||
import { QueryPlan2 } from 'sql/workbench/contrib/queryplan2/browser/queryPlan';
|
||||
import * as DOM from 'vs/base/browser/dom';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { Codicon } from 'vs/base/common/codicons';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
|
||||
export class CustomZoomWidget extends QueryPlanWidgetBase {
|
||||
private _actionBar: ActionBar;
|
||||
public customZoomInputBox: InputBox;
|
||||
|
||||
constructor(
|
||||
public readonly queryPlanView: QueryPlan2,
|
||||
@IContextViewService public readonly contextViewService: IContextViewService,
|
||||
@IThemeService public readonly themeService: IThemeService,
|
||||
@INotificationService public readonly notificationService: INotificationService
|
||||
) {
|
||||
super(DOM.$('.custom-zoom-view'), 'customZoom');
|
||||
|
||||
// Custom zoom input box
|
||||
const zoomValueLabel = localize("qpZoomValueLabel", 'Zoom (percent)');
|
||||
this.customZoomInputBox = new InputBox(this.container, this.contextViewService, {
|
||||
type: 'number',
|
||||
ariaLabel: zoomValueLabel,
|
||||
flexibleWidth: false
|
||||
});
|
||||
attachInputBoxStyler(this.customZoomInputBox, this.themeService);
|
||||
|
||||
const currentZoom = queryPlanView.azdataGraphDiagram.graph.view.getScale();
|
||||
|
||||
// Setting initial value to graph's current zoom
|
||||
this.customZoomInputBox.value = Math.round(currentZoom).toString();
|
||||
|
||||
// Setting up keyboard shortcuts
|
||||
const self = this;
|
||||
this.customZoomInputBox.element.onkeydown = async (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
await new CustomZoomAction().run(self);
|
||||
} else if (ev.key === 'Escape') {
|
||||
queryPlanView.planActionView.removeWidget(self);
|
||||
}
|
||||
};
|
||||
|
||||
// Adding action bar
|
||||
this._actionBar = new ActionBar(this.container);
|
||||
this._actionBar.context = this;
|
||||
this._actionBar.pushAction(new CustomZoomAction(), { label: false, icon: true });
|
||||
this._actionBar.pushAction(new CancelZoom(), { label: false, icon: true });
|
||||
}
|
||||
|
||||
// Setting initial focus to input box
|
||||
public focus() {
|
||||
this.customZoomInputBox.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomZoomAction extends Action {
|
||||
public static ID = 'qp.customZoomAction';
|
||||
public static LABEL = localize('zoomAction', "Zoom (Enter)");
|
||||
|
||||
constructor() {
|
||||
super(CustomZoomAction.ID, CustomZoomAction.LABEL, Codicon.zoomOut.classNames);
|
||||
}
|
||||
|
||||
public override async run(context: CustomZoomWidget): Promise<void> {
|
||||
const newValue = parseInt(context.customZoomInputBox.value);
|
||||
if (newValue <= 200 && newValue >= 1) { // Getting max and min zoom values from SSMS
|
||||
context.queryPlanView.azdataGraphDiagram.graph.view.setScale(newValue / 100);
|
||||
context.queryPlanView.planActionView.removeWidget(context);
|
||||
} else {
|
||||
context.notificationService.error(
|
||||
localize('invalidCustomZoomError', "Select a zoom value between 1 to 200")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelZoom extends Action {
|
||||
public static ID = 'qp.cancelCustomZoomAction';
|
||||
public static LABEL = localize('cancelCustomZoomAction', "Close (Escape)");
|
||||
|
||||
constructor() {
|
||||
super(CancelZoom.ID, CancelZoom.LABEL, Codicon.chromeClose.classNames);
|
||||
}
|
||||
|
||||
public override async run(context: CustomZoomWidget): Promise<void> {
|
||||
context.queryPlanView.planActionView.removeWidget(context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { QueryPlanWidgetBase } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase';
|
||||
import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar';
|
||||
import * as DOM from 'vs/base/browser/dom';
|
||||
import { localize } from 'vs/nls';
|
||||
import { Codicon } from 'vs/base/common/codicons';
|
||||
import { InternalExecutionPlanNode, QueryPlan2 } from 'sql/workbench/contrib/queryplan2/browser/queryPlan';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { attachInputBoxStyler, attachSelectBoxStyler } from 'sql/platform/theme/common/styler';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { SelectBox } from 'sql/base/browser/ui/selectBox/selectBox';
|
||||
import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox';
|
||||
import { isString } from 'vs/base/common/types';
|
||||
|
||||
const CONTAINS_DISPLAY_STRING = localize("queryPlanSearchTypeContains", 'Contains');
|
||||
const EQUALS_DISPLAY_STRING = localize("queryPlanSearchTypeEquals", 'Equals');
|
||||
|
||||
export class NodeSearchWidget extends QueryPlanWidgetBase {
|
||||
|
||||
private _propertyNameSelectBoxContainer: HTMLElement;
|
||||
private _propertyNameSelectBox: SelectBox;
|
||||
|
||||
private _searchTypeSelectBoxContainer: HTMLElement;
|
||||
private _searchTypeSelectBox: SelectBox;
|
||||
|
||||
private _searchTextInputBox: InputBox;
|
||||
private _searchResults: string[] = [];
|
||||
private _currentSearchResultIndex = 0;
|
||||
private _usePreviousSearchResult: boolean = false;
|
||||
|
||||
private _actionBar: ActionBar;
|
||||
|
||||
constructor(
|
||||
public readonly queryPlanView: QueryPlan2,
|
||||
@IContextViewService public readonly contextViewService: IContextViewService,
|
||||
@IThemeService public readonly themeService: IThemeService
|
||||
|
||||
) {
|
||||
super(DOM.$('.search-node-widget'), 'searchWidget');
|
||||
|
||||
// property name dropdown
|
||||
this._propertyNameSelectBoxContainer = DOM.$('.search-widget-property-name-select-box .dropdown-container');
|
||||
this.container.appendChild(this._propertyNameSelectBoxContainer);
|
||||
const propDropdownOptions = [...queryPlanView.graphElementPropertiesSet].sort();
|
||||
this._propertyNameSelectBox = new SelectBox(propDropdownOptions, propDropdownOptions[0], this.contextViewService, this._propertyNameSelectBoxContainer);
|
||||
attachSelectBoxStyler(this._propertyNameSelectBox, this.themeService);
|
||||
this._propertyNameSelectBoxContainer.style.width = '150px';
|
||||
this._propertyNameSelectBox.render(this._propertyNameSelectBoxContainer);
|
||||
this._propertyNameSelectBox.onDidSelect(e => {
|
||||
this._usePreviousSearchResult = false;
|
||||
});
|
||||
|
||||
// search type dropdown
|
||||
this._searchTypeSelectBoxContainer = DOM.$('.search-widget-search-type-select-box .dropdown-container');
|
||||
this.container.appendChild(this._searchTypeSelectBoxContainer);
|
||||
this._searchTypeSelectBox = new SelectBox([
|
||||
EQUALS_DISPLAY_STRING,
|
||||
CONTAINS_DISPLAY_STRING
|
||||
], EQUALS_DISPLAY_STRING, this.contextViewService, this._searchTypeSelectBoxContainer);
|
||||
this._searchTypeSelectBox.render(this._searchTypeSelectBoxContainer);
|
||||
attachSelectBoxStyler(this._searchTypeSelectBox, this.themeService);
|
||||
this._searchTypeSelectBoxContainer.style.width = '100px';
|
||||
this._searchTypeSelectBox.onDidSelect(e => {
|
||||
this._usePreviousSearchResult = false;
|
||||
});
|
||||
|
||||
// search text input box
|
||||
this._searchTextInputBox = new InputBox(this.container, this.contextViewService, {});
|
||||
attachInputBoxStyler(this._searchTextInputBox, this.themeService);
|
||||
this._searchTextInputBox.element.style.marginLeft = '5px';
|
||||
this._searchTextInputBox.onDidChange(e => {
|
||||
this._usePreviousSearchResult = false;
|
||||
});
|
||||
|
||||
|
||||
// setting up key board shortcuts
|
||||
const self = this;
|
||||
this._searchTextInputBox.element.onkeydown = async e => {
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
await new GoToPreviousMatchAction().run(self);
|
||||
} else if (e.key === 'Enter') {
|
||||
await new GoToNextMatchAction().run(self);
|
||||
} else if (e.key === 'Escape') {
|
||||
await new CancelSearch().run(self);
|
||||
}
|
||||
};
|
||||
|
||||
// Adding action bar
|
||||
this._actionBar = new ActionBar(this.container);
|
||||
this._actionBar.context = this;
|
||||
this._actionBar.pushAction(new GoToPreviousMatchAction(), { label: false, icon: true });
|
||||
this._actionBar.pushAction(new GoToNextMatchAction(), { label: false, icon: true });
|
||||
this._actionBar.pushAction(new CancelSearch(), { label: false, icon: true });
|
||||
}
|
||||
|
||||
// Initial focus is set to the search text input box
|
||||
public focus() {
|
||||
this._searchTextInputBox.focus();
|
||||
}
|
||||
|
||||
|
||||
public searchNode(returnPreviousResult: boolean): void {
|
||||
|
||||
// Searching again as the input params have changed
|
||||
if (!this._usePreviousSearchResult) {
|
||||
|
||||
this._searchResults = [];
|
||||
this._currentSearchResultIndex = 0; //Resetting search Index to 0;
|
||||
this._usePreviousSearchResult = true;
|
||||
|
||||
// Doing depth first search in the graphModel to find nodes with matching prop values.
|
||||
const graphModel = this.queryPlanView.graphModel;
|
||||
const stack: InternalExecutionPlanNode[] = [];
|
||||
stack.push(graphModel.root);
|
||||
|
||||
while (stack.length !== 0) {
|
||||
const currentNode = stack.pop();
|
||||
|
||||
const matchingProp = currentNode.properties.find(e => e.name === this._propertyNameSelectBox.value);
|
||||
|
||||
// Searching only properties with string value.
|
||||
if (isString(matchingProp?.value)) {
|
||||
// If the search type is '=' we look for exact match and for 'contains' we look search string occurance in prop value
|
||||
if (
|
||||
this._searchTypeSelectBox.value === EQUALS_DISPLAY_STRING && matchingProp.value === this._searchTextInputBox.value ||
|
||||
this._searchTypeSelectBox.value === CONTAINS_DISPLAY_STRING && matchingProp.value.includes(this._searchTextInputBox.value)
|
||||
) {
|
||||
this._searchResults.push(currentNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
stack.push(...currentNode.children);
|
||||
}
|
||||
}
|
||||
// Returning if no results found.
|
||||
if (this._searchResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Getting the node at search index
|
||||
const resultCell = this.queryPlanView.azdataGraphDiagram.graph.model.getCell(this._searchResults[this._currentSearchResultIndex]);
|
||||
// Selecting the node on graph diagram
|
||||
this.queryPlanView.azdataGraphDiagram.graph.setSelectionCell(resultCell);
|
||||
this.queryPlanView.propertiesView.graphElement = this.queryPlanView.searchNodes(resultCell.id);
|
||||
|
||||
/**
|
||||
* The selected graph node might be hidden/partially visible if the graph is overflowing the parent container.
|
||||
* Apart from the obvious problems in aesthetics, user do not get a proper feedback of the search result.
|
||||
* To solve this problem, we will have to scroll the node into view. (preferably into the center of the view)
|
||||
* Steps for that:
|
||||
* 1. Get the bounding rect of the node on graph.
|
||||
* 2. Get the midpoint of the node's bounding rect.
|
||||
* 3. Find the dimensions of the parent container.
|
||||
* 4. Since, we are trying to position the node into center, we set the left top corner position of parent to
|
||||
* below x and y.
|
||||
* x = node's x midpoint - half the width of parent container
|
||||
* y = node's y midpoint - half the height of parent container
|
||||
* 5. If the x and y are negative, we set them 0 as that is the minimum possible scroll position.
|
||||
* 6. Smoothly scroll to the left top x and y calculated in step 4, 5.
|
||||
*/
|
||||
|
||||
const cellRect = this.queryPlanView.azdataGraphDiagram.graph.getCellBounds(resultCell);
|
||||
const cellMidPoint: Point = {
|
||||
x: cellRect.x + cellRect.width / 2,
|
||||
y: cellRect.y + cellRect.height / 2,
|
||||
};
|
||||
|
||||
const graphContainer = <HTMLElement>this.queryPlanView.azdataGraphDiagram.container;
|
||||
const containerBoundingRect = graphContainer.getBoundingClientRect();
|
||||
|
||||
const leftTopScrollPoint: Point = {
|
||||
x: cellMidPoint.x - containerBoundingRect.width / 2,
|
||||
y: cellMidPoint.y - containerBoundingRect.height / 2
|
||||
};
|
||||
|
||||
leftTopScrollPoint.x = leftTopScrollPoint.x < 0 ? 0 : leftTopScrollPoint.x;
|
||||
leftTopScrollPoint.y = leftTopScrollPoint.y < 0 ? 0 : leftTopScrollPoint.y;
|
||||
|
||||
graphContainer.scrollTo({
|
||||
left: leftTopScrollPoint.x,
|
||||
top: leftTopScrollPoint.y,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Updating search result index based on prev flag
|
||||
if (returnPreviousResult) {
|
||||
// going to the end of list if the index is 0 on prev
|
||||
this._currentSearchResultIndex = this._currentSearchResultIndex === 0 ?
|
||||
this._currentSearchResultIndex = this._searchResults.length - 1 :
|
||||
this._currentSearchResultIndex = --this._currentSearchResultIndex;
|
||||
} else {
|
||||
// going to the front of list if we are at the last element
|
||||
this._currentSearchResultIndex = this._currentSearchResultIndex === this._searchResults.length - 1 ?
|
||||
this._currentSearchResultIndex = 0 :
|
||||
this._currentSearchResultIndex = ++this._currentSearchResultIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class GoToNextMatchAction extends Action {
|
||||
public static ID = 'qp.NextSearchAction';
|
||||
public static LABEL = localize('nextSearchItemAction', "Next Match (Enter)");
|
||||
|
||||
constructor() {
|
||||
super(GoToNextMatchAction.ID, GoToNextMatchAction.LABEL, Codicon.arrowDown.classNames);
|
||||
}
|
||||
|
||||
public override async run(context: NodeSearchWidget): Promise<void> {
|
||||
context.searchNode(false);
|
||||
}
|
||||
}
|
||||
|
||||
export class GoToPreviousMatchAction extends Action {
|
||||
public static ID = 'qp.PreviousSearchAction';
|
||||
public static LABEL = localize('previousSearchItemAction', "Previous Match (Shift+Enter)");
|
||||
|
||||
constructor() {
|
||||
super(GoToPreviousMatchAction.ID, GoToPreviousMatchAction.LABEL, Codicon.arrowUp.classNames);
|
||||
}
|
||||
|
||||
public override async run(context: NodeSearchWidget): Promise<void> {
|
||||
context.searchNode(true);
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelSearch extends Action {
|
||||
public static ID = 'qp.cancelSearchAction';
|
||||
public static LABEL = localize('cancelSearchAction', "Close (Escape)");
|
||||
|
||||
constructor() {
|
||||
super(CancelSearch.ID, CancelSearch.LABEL, Codicon.chromeClose.classNames);
|
||||
}
|
||||
|
||||
public override async run(context: NodeSearchWidget): Promise<void> {
|
||||
context.queryPlanView.planActionView.removeWidget(context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user