diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index c80bcc0101..c9088e5c52 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -174,6 +174,10 @@ "dark": "resources/dark/add_to_server_list_inverse.svg", "light": "resources/light/add_to_server_list.svg" } + }, + { + "command": "azure.dataGrid.openInAzurePortal", + "title": "%azure.openInAzurePortal.title%" } ], "connectionTreeProvider": [ @@ -211,6 +215,10 @@ { "command": "azure.resource.connectsqlserver", "when": "false" + }, + { + "command": "azure.dataGrid.openInAzurePortal", + "when": "false" } ], "view/title": [ @@ -293,6 +301,11 @@ "when": "treeId == connectionDialog/azureResourceExplorer", "group": "navigation" } + ], + "dataGrid/item/context": [ + { + "command": "azure.dataGrid.openInAzurePortal" + } ] }, "hasAzureResourceProviders": true diff --git a/extensions/azurecore/src/azureDataGridProvider.ts b/extensions/azurecore/src/azureDataGridProvider.ts index 3b19634e27..80a1c41066 100644 --- a/extensions/azurecore/src/azureDataGridProvider.ts +++ b/extensions/azurecore/src/azureDataGridProvider.ts @@ -45,12 +45,14 @@ export class AzureDataGridProvider implements azdata.DataGridProvider { .map(item => { return { id: item.id, - // nameLink: { displayText: item.name, linkOrCommand: ''}, - resourceGroup: item.resourceGroup, - subscriptionName: subscriptions.find(subscription => subscription.id === item.subscriptionId)?.name ?? item.subscriptionId, - locationDisplayName: utils.getRegionDisplayName(item.location), - typeDisplayName: utils.getResourceTypeDisplayName(item.type), - iconPath: utils.getResourceTypeIcon(this._appContext, item.type) + fieldValues: { + nameLink: { displayText: item.name, linkOrCommand: 'https://microsoft.com' }, + resourceGroup: item.resourceGroup, + subscriptionName: subscriptions.find(subscription => subscription.id === item.subscriptionId)?.name ?? item.subscriptionId, + locationDisplayName: utils.getRegionDisplayName(item.location), + typeDisplayName: utils.getResourceTypeDisplayName(item.type), + iconPath: utils.getResourceTypeIcon(this._appContext, item.type), + } }; }); items.push(...newItems); diff --git a/extensions/azurecore/src/extension.ts b/extensions/azurecore/src/extension.ts index 114a54c7b5..b0e0828156 100644 --- a/extensions/azurecore/src/extension.ts +++ b/extensions/azurecore/src/extension.ts @@ -91,6 +91,9 @@ export async function activate(context: vscode.ExtensionContext): Promise onDidChangeConfiguration(e), this)); registerAzureResourceCommands(appContext, [azureResourceTree, connectionDialogTree]); azdata.dataprotocol.registerDataGridProvider(new AzureDataGridProvider(appContext)); + vscode.commands.registerCommand('azure.dataGrid.openInAzurePortal', async (item: azdata.DataGridItem) => { + await vscode.env.openExternal(vscode.Uri.parse('https://microsoft.com')); + }); return { getSubscriptions(account?: azdata.Account, ignoreErrors?: boolean, selectedOnly: boolean = false): Thenable { diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index dbfaa8790b..5026fb949a 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -139,7 +139,11 @@ declare module 'azdata' { DataGridProvider = 'DataGridProvider' } + /** + * The type of the DataGrid column + */ export type DataGridColumnType = 'hyperlink' | 'text' | 'image'; + /** * A column in a data grid */ @@ -198,10 +202,14 @@ declare module 'azdata' { * The ID of the command to execute */ id: string; + /** + * The text to display for the action + */ + displayText?: string; /** * The optional args to pass to the command */ - args?: string[]; + args?: any[]; } /** @@ -226,14 +234,11 @@ declare module 'azdata' { * A unique identifier for this item */ id: string; + /** - * The optional icon to display for this item + * The other properties that will be displayed in the grid columns */ - iconPath?: string; - /** - * The other properties that will be displayed in the grid - */ - [key: string]: string | DataGridHyperlinkInfo; + fieldValues: { [key: string]: string | DataGridHyperlinkInfo } } /** diff --git a/src/sql/base/browser/ui/table/formatters.ts b/src/sql/base/browser/ui/table/formatters.ts index dae06c58c7..9236601a8e 100644 --- a/src/sql/base/browser/ui/table/formatters.ts +++ b/src/sql/base/browser/ui/table/formatters.ts @@ -16,6 +16,7 @@ export interface DBCellValue { */ export interface ExecuteCommandInfo { id: string; + displayText?: string; args?: string[] } @@ -49,7 +50,6 @@ export function isHyperlinkCellValue(obj: any | undefined): obj is HyperlinkCell return !!(obj)?.linkOrCommand; } - /** * Format xml field into a hyperlink and performs HTML entity encoding */ @@ -107,8 +107,7 @@ export function imageFormatter(row: number | undefined, cell: any | undefined, v } /** - * Provide slick grid cell with encoded ariaLabel and plain text. - * text will be escaped by the textFormatter and ariaLabel will be consumed by slickgrid directly. + * Extracts the specified field into the expected object to be handled by SlickGrid and/or formatters as needed. */ export function slickGridDataItemColumnValueExtractor(value: any, columnDef: any): TextCellValue | HyperlinkCellValue { let fieldValue = value[columnDef.field]; @@ -123,7 +122,6 @@ export function slickGridDataItemColumnValueExtractor(value: any, columnDef: any ariaLabel: fieldValue ? escape(fieldValue) : fieldValue }; } - } /** diff --git a/src/sql/base/browser/ui/table/plugins/buttonColumn.plugin.ts b/src/sql/base/browser/ui/table/plugins/buttonColumn.plugin.ts index 20b0849ed9..9bd07a1e2f 100644 --- a/src/sql/base/browser/ui/table/plugins/buttonColumn.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/buttonColumn.plugin.ts @@ -12,9 +12,22 @@ export interface ButtonColumnDefinition extends TextW } export interface ButtonColumnOptions { + /** + * The CSS class of the icon (either a common icon or one added by the user of this column) to display in the button + */ iconCssClass?: string; + /** + * The aria-label title of the button + */ title?: string; + /** + * The unique ID used by SlickGrid + */ id?: string; + /** + * Whether the column is sortable or not + */ + sortable?: boolean; } export interface ButtonClickEventArgs { @@ -41,7 +54,8 @@ export class ButtonColumn implements Slick.Plugin }, width: 30, selectable: false, - iconCssClassField: options.iconCssClass + iconCssClassField: options.iconCssClass, + sortable: options.sortable }; } diff --git a/src/sql/base/browser/ui/table/table.ts b/src/sql/base/browser/ui/table/table.ts index 3ed13cc046..e7b4086f7b 100644 --- a/src/sql/base/browser/ui/table/table.ts +++ b/src/sql/base/browser/ui/table/table.ts @@ -241,6 +241,10 @@ export class Table extends Widget implements IDisposa this._grid.registerPlugin(plugin); } + unregisterPlugin(plugin: Slick.Plugin): void { + this._grid.unregisterPlugin(plugin); + } + /** * This function needs to be called if the table is drawn off dom. */ diff --git a/src/sql/workbench/browser/editor/resourceViewer/resourceViewerInput.ts b/src/sql/workbench/browser/editor/resourceViewer/resourceViewerInput.ts index 0f2b2a1208..63759e5d8f 100644 --- a/src/sql/workbench/browser/editor/resourceViewer/resourceViewerInput.ts +++ b/src/sql/workbench/browser/editor/resourceViewer/resourceViewerInput.ts @@ -3,34 +3,44 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as azdata from 'azdata'; import * as nls from 'vs/nls'; import { EditorInput } from 'vs/workbench/common/editor'; import { Event, Emitter } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; import { IDataGridProviderService } from 'sql/workbench/services/dataGridProvider/common/dataGridProviderService'; import { onUnexpectedError } from 'vs/base/common/errors'; +import { ButtonColumn } from 'sql/base/browser/ui/table/plugins/buttonColumn.plugin'; import { getDataGridFormatter } from 'sql/workbench/services/dataGridProvider/browser/dataGridProviderUtils'; -export interface ColumnDefinition extends Slick.Column { +export interface ColumnDefinition extends Slick.Column { name: string; - type: string; + // actions is a special internal type for the More Actions column + type: azdata.DataGridColumnType | 'actions'; filterable?: boolean; } export class ResourceViewerInput extends EditorInput { public static ID: string = 'workbench.editorInput.resourceViewerInput'; - private _data: Slick.SlickData[] = []; + private _data: azdata.DataGridItem[] = []; private _columns: ColumnDefinition[] = []; - - private _onColumnsChanged = new Emitter[]>(); - public onColumnsChanged: Event[]> = this._onColumnsChanged.event; + private _onColumnsChanged = new Emitter[]>(); + public actionsColumn: ButtonColumn; + public onColumnsChanged: Event[]> = this._onColumnsChanged.event; private _onDataChanged = new Emitter(); public onDataChanged: Event = this._onDataChanged.event; - constructor(private _providerId: string, @IDataGridProviderService private _dataGridProvider: IDataGridProviderService) { + constructor(private _providerId: string, + @IDataGridProviderService private _dataGridProviderService: IDataGridProviderService) { super(); + this.actionsColumn = new ButtonColumn({ + id: 'actions', + iconCssClass: 'toggle-more', + title: nls.localize('resourceViewer.showActions', "Show Actions"), + sortable: false + }); this.refresh().catch(err => onUnexpectedError(err)); } @@ -42,7 +52,7 @@ export class ResourceViewerInput extends EditorInput { return nls.localize('resourceViewerInput.resourceViewer', "Resource Viewer"); } - public get data(): Slick.SlickData[] { + public get data(): azdata.DataGridItem[] { return this._data; } @@ -70,9 +80,13 @@ export class ResourceViewerInput extends EditorInput { ]); } + public get plugins(): Slick.Plugin[] { + return [this.actionsColumn]; + } + private async fetchColumns(): Promise { - const columns = await this._dataGridProvider.getDataGridColumns(this._providerId); - this.columns = columns.map(col => { + const columns = await this._dataGridProviderService.getDataGridColumns(this._providerId); + const columnDefinitions: ColumnDefinition[] = columns.map(col => { return { name: col.name, field: col.field, @@ -86,10 +100,15 @@ export class ResourceViewerInput extends EditorInput { type: col.type }; }); + + // Now add in the actions column definition at the end + const actionsColumnDef: ColumnDefinition = Object.assign({}, this.actionsColumn.definition, { type: 'actions', filterable: false }) as ColumnDefinition; + columnDefinitions.push(actionsColumnDef); + this.columns = columnDefinitions; } private async fetchItems(): Promise { - const items = await this._dataGridProvider.getDataGridItems(this._providerId); + const items = await this._dataGridProviderService.getDataGridItems(this._providerId); this._data = items; this._onDataChanged.fire(); } diff --git a/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerEditor.ts b/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerEditor.ts index 471cdf3553..c4a21d233c 100644 --- a/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerEditor.ts +++ b/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerEditor.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/resourceViewerView'; +import * as azdata from 'azdata'; import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { CancellationToken } from 'vs/base/common/cancellation'; @@ -17,6 +18,13 @@ import { ResourceViewerInput } from 'sql/workbench/browser/editor/resourceViewer import { ResourceViewerTable } from 'sql/workbench/contrib/resourceViewer/browser/resourceViewerTable'; import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; import { ResourceViewerEditColumns, ResourceViewerRefresh } from 'sql/workbench/contrib/resourceViewer/browser/resourceViewerActions'; +import { IAction } from 'vs/base/common/actions'; +import { fillInActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; + +export type ContextMenuAnchor = HTMLElement | { x: number; y: number; width?: number; height?: number; }; export class ResourceViewerEditor extends EditorPane { public static readonly ID: string = 'workbench.editor.resource-viewer'; @@ -30,7 +38,10 @@ export class ResourceViewerEditor extends EditorPane { @ITelemetryService telemetryService: ITelemetryService, @IWorkbenchThemeService themeService: IWorkbenchThemeService, @IInstantiationService private _instantiationService: IInstantiationService, - @IStorageService storageService: IStorageService + @IStorageService storageService: IStorageService, + @IContextMenuService private _contextMenuService: IContextMenuService, + @IMenuService private _menuService: IMenuService, + @IContextKeyService private _contextKeyService: IContextKeyService ) { super(ResourceViewerEditor.ID, telemetryService, themeService, storageService); } @@ -43,6 +54,10 @@ export class ResourceViewerEditor extends EditorPane { const header = this.createHeader(); const tableContainer = this.createResourceViewerTable(); + this._register(this._resourceViewerTable.onContextMenu(e => { + this.showContextMenu(e.anchor, e.item); + })); + this._container.appendChild(header); this._container.appendChild(tableContainer); } @@ -82,6 +97,16 @@ export class ResourceViewerEditor extends EditorPane { this._inputDisposables.clear(); this._resourceViewerTable.data = input.data; + + input.plugins.forEach(plugin => { + this._resourceViewerTable.registerPlugin(plugin); + this._inputDisposables.add({ + dispose: () => { + this._resourceViewerTable.unregisterPlugin(plugin); + } + }); + }); + this._resourceViewerTable.columns = input.columns; this._inputDisposables.add(input.onColumnsChanged(columns => { this._resourceViewerTable.columns = columns; @@ -89,6 +114,9 @@ export class ResourceViewerEditor extends EditorPane { this._inputDisposables.add(input.onDataChanged(() => { this._resourceViewerTable.data = input.data; })); + this._inputDisposables.add(input.actionsColumn.onClick(e => { + this.showContextMenu(e.position, e.item); + })); this._actionBar.context = input; @@ -99,4 +127,24 @@ export class ResourceViewerEditor extends EditorPane { this._container.style.width = dimension.width + 'px'; this._container.style.height = dimension.height + 'px'; } + + private showContextMenu(anchor: ContextMenuAnchor, context: azdata.DataGridItem): void { + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getMenuActions(context) + }); + } + + private getMenuActions(context: azdata.DataGridItem): IAction[] { + // Get the contributed menu action items. Note that this currently doesn't + // have any item-level support for action filtering, that can be added to the scoped context as + // needed in the future + const scopedContext = this._contextKeyService.createScoped(); + const menu = this._menuService.createMenu(MenuId.DataGridItemContext, scopedContext); + const options = { arg: context }; + const groups = menu.getActions(options); + const actions: IAction[] = []; + fillInActions(groups, actions, false); + return actions; + } } diff --git a/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerTable.ts b/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerTable.ts index bcb5635594..28be71e811 100644 --- a/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerTable.ts +++ b/src/sql/workbench/contrib/resourceViewer/browser/resourceViewerTable.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/resourceViewerTable'; +import * as azdata from 'azdata'; import { Table } from 'sql/base/browser/ui/table/table'; import { attachTableStyler, attachButtonStyler } from 'sql/platform/theme/common/styler'; import { RowSelectionModel } from 'sql/base/browser/ui/table/plugins/rowSelectionModel.plugin'; import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; -import { isHyperlinkCellValue, slickGridDataItemColumnValueExtractor } from 'sql/base/browser/ui/table/formatters'; +import { HyperlinkCellValue, isHyperlinkCellValue, TextCellValue } from 'sql/base/browser/ui/table/formatters'; import { HeaderFilter, CommandEventArgs, IExtendedColumn } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin'; import { Disposable } from 'vs/base/common/lifecycle'; import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; @@ -19,11 +20,17 @@ import { isString } from 'vs/base/common/types'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { localize } from 'vs/nls'; import { INotificationService } from 'vs/platform/notification/common/notification'; +import { ColumnDefinition } from 'sql/workbench/browser/editor/resourceViewer/resourceViewerInput'; +import { Emitter } from 'vs/base/common/event'; +import { ContextMenuAnchor } from 'sql/workbench/contrib/resourceViewer/browser/resourceViewerEditor'; export class ResourceViewerTable extends Disposable { - private _resourceViewerTable!: Table; - private _dataView: TableDataView; + private _resourceViewerTable!: Table; + private _dataView: TableDataView; + + private _onContextMenu = new Emitter<{ anchor: ContextMenuAnchor, item: azdata.DataGridItem }>(); + public onContextMenu = this._onContextMenu.event; constructor(parent: HTMLElement, @IWorkbenchThemeService private _themeService: IWorkbenchThemeService, @@ -31,17 +38,17 @@ export class ResourceViewerTable extends Disposable { @ICommandService private _commandService: ICommandService, @INotificationService private _notificationService: INotificationService) { super(); - let filterFn = (data: Array): Array => { + let filterFn = (data: Array): Array => { return data.filter(item => this.filter(item)); }; - this._dataView = new TableDataView(undefined, undefined, undefined, filterFn); + this._dataView = new TableDataView(undefined, undefined, undefined, filterFn); this._resourceViewerTable = this._register(new Table(parent, { sorter: (args) => { this._dataView.sort(args); } }, { - dataItemColumnValueExtractor: slickGridDataItemColumnValueExtractor, + dataItemColumnValueExtractor: dataGridColumnValueExtractor, forceFitColumns: true })); this._resourceViewerTable.setSelectionModel(new RowSelectionModel()); @@ -49,7 +56,12 @@ export class ResourceViewerTable extends Disposable { this._register(attachButtonStyler(filterPlugin, this._themeService)); this._register(attachTableStyler(this._resourceViewerTable, this._themeService)); this._register(this._resourceViewerTable.onClick(this.onTableClick, this)); - + this._register(this._resourceViewerTable.onContextMenu((e: ITableMouseEvent) => { + this._onContextMenu.fire({ + anchor: e.anchor, + item: this._dataView.getItem(e.cell.row) + }); + })); filterPlugin.onFilterApplied.subscribe(() => { this._dataView.filter(); this._resourceViewerTable.grid.invalidate(); @@ -57,7 +69,7 @@ export class ResourceViewerTable extends Disposable { this._resourceViewerTable.grid.resetActiveCell(); this._resourceViewerTable.grid.resizeCanvas(); }); - filterPlugin.onCommand.subscribe((e, args: CommandEventArgs) => { + filterPlugin.onCommand.subscribe((e, args: CommandEventArgs) => { // Convert filter command to SlickGrid sort args this._dataView.sort({ grid: args.grid, @@ -71,7 +83,7 @@ export class ResourceViewerTable extends Disposable { this._resourceViewerTable.registerPlugin(filterPlugin); } - public set data(data: Slick.SlickData[]) { + public set data(data: azdata.DataGridItem[]) { this._dataView.clear(); this._dataView.push(data); this._resourceViewerTable.grid.setData(this._dataView, true); @@ -82,6 +94,14 @@ export class ResourceViewerTable extends Disposable { this._resourceViewerTable.columns = columns; } + public registerPlugin(plugin: Slick.Plugin): void { + this._resourceViewerTable.registerPlugin(plugin); + } + + public unregisterPlugin(plugin: Slick.Plugin): void { + this._resourceViewerTable.unregisterPlugin(plugin); + } + public focus(): void { this._resourceViewerTable.focus(); } @@ -107,10 +127,10 @@ export class ResourceViewerTable extends Disposable { } private async onTableClick(event: ITableMouseEvent): Promise { - const column = this._resourceViewerTable.columns[event.cell.cell]; + const column = this._resourceViewerTable.columns[event.cell.cell] as ColumnDefinition; if (column) { const row = this._dataView.getItem(event.cell.row); - const value = row[column.field]; + const value = row.fieldValues[column.field]; if (isHyperlinkCellValue(value)) { if (isString(value.linkOrCommand)) { try { @@ -118,7 +138,6 @@ export class ResourceViewerTable extends Disposable { } catch (err) { this._notificationService.error(localize('resourceViewerTable.openError', "Error opening link : {0}", err.message ?? err)); } - } else { try { await this._commandService.executeCommand(value.linkOrCommand.id, ...(value.linkOrCommand.args ?? [])); @@ -130,3 +149,18 @@ export class ResourceViewerTable extends Disposable { } } } + +/** + * Extracts the specified field into the expected object to be handled by SlickGrid and/or formatters as needed. + */ +function dataGridColumnValueExtractor(value: azdata.DataGridItem, columnDef: ColumnDefinition): TextCellValue | HyperlinkCellValue { + const fieldValue = value.fieldValues[columnDef.field]; + if (columnDef.type === 'hyperlink') { + return fieldValue as HyperlinkCellValue; + } else { + return { + text: fieldValue, + ariaLabel: fieldValue ? escape(fieldValue as string) : fieldValue + }; + } +} diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 4f57bd5ffd..a074b97273 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -135,6 +135,7 @@ export class MenuId { static readonly DashboardToolbar = new MenuId('DashboardToolbar'); // {{SQL CARBON EDIT}} static readonly NotebookTitle = new MenuId('NotebookTitle'); // {{SQL CARBON EDIT}} static readonly ConnectionDialogBrowseTreeContext = new MenuId('ConnectionDialogBrowseTreeContext'); // {{SQL CARBON EDIT}} + static readonly DataGridItemContext = new MenuId('DataGridItemContext'); // {{SQL CARBON EDIT}} static readonly TimelineItemContext = new MenuId('TimelineItemContext'); static readonly TimelineTitle = new MenuId('TimelineTitle'); static readonly TimelineTitleContext = new MenuId('TimelineTitleContext'); diff --git a/src/vs/workbench/api/common/menusExtensionPoint.ts b/src/vs/workbench/api/common/menusExtensionPoint.ts index d775c9c101..3b5c2d9e11 100644 --- a/src/vs/workbench/api/common/menusExtensionPoint.ts +++ b/src/vs/workbench/api/common/menusExtensionPoint.ts @@ -210,7 +210,12 @@ const apiMenus: IAPIMenu[] = [ key: 'connectionDialog/browseTree', id: MenuId.ConnectionDialogBrowseTreeContext, description: localize('connectionDialogBrowseTree.context', "The connection dialog's browse tree context menu") - } + }, + { + key: 'dataGrid/item/context', + id: MenuId.DataGridItemContext, + description: localize('dataGrid.context', "The data grid item context menu") + }, // {{SQL CARBON EDIT}} end menu entries ];