diff --git a/extensions/sql-assessment/src/assessmentResultGrid.ts b/extensions/sql-assessment/src/assessmentResultGrid.ts index daa5eb9426..7448b15411 100644 --- a/extensions/sql-assessment/src/assessmentResultGrid.ts +++ b/extensions/sql-assessment/src/assessmentResultGrid.ts @@ -28,20 +28,47 @@ export class AssessmentResultGrid implements vscode.Disposable { private descriptionCaption!: azdata.TextComponent; private asmtType!: AssessmentType; + private targetTypeIcon: { [targetType: number]: azdata.IconColumnCellValue }; - private readonly checkIdColOrder = 4; - private readonly targetColOrder = 0; + + private readonly checkIdColOrder = 5; + private readonly targetColOrder = 1; public get component(): azdata.Component { return this.rootContainer; } - public constructor(view: azdata.ModelView) { + public constructor(view: azdata.ModelView, extensionContext: vscode.ExtensionContext) { const headerCssClass = 'no-borders align-with-header'; + this.targetTypeIcon = { + [azdata.sqlAssessment.SqlAssessmentTargetType.Database]: { + icon: { + dark: extensionContext.asAbsolutePath('resources/dark/database.svg'), + light: extensionContext.asAbsolutePath('resources/light/database.svg') + }, + ariaLabel: localize('databaseIconLabel', "Database Icon") + }, + [azdata.sqlAssessment.SqlAssessmentTargetType.Server]: { + icon: { + dark: extensionContext.asAbsolutePath('resources/dark/server.svg'), + light: extensionContext.asAbsolutePath('resources/light/server.svg') + }, + ariaLabel: localize('serverIconLabel', "Server Icon") + } + }; + this.table = view.modelBuilder.table() .withProperties({ data: [], columns: [ + { + value: 'targetType', + name: '', + type: azdata.ColumnType.icon, + width: 10, + headerCssClass: headerCssClass, + toolTip: localize('asmt.column.targetType', "Target Type"), + }, { value: LocalizedStrings.TARGET_COLUMN_NAME, headerCssClass: headerCssClass, width: 125 }, { value: LocalizedStrings.SEVERITY_COLUMN_NAME, headerCssClass: headerCssClass, width: 100 }, { value: LocalizedStrings.MESSAGE_COLUMN_NAME, headerCssClass: headerCssClass, width: 900 }, @@ -250,6 +277,7 @@ export class AssessmentResultGrid implements vscode.Disposable { private convertToDataView(asmtResult: azdata.SqlAssessmentResultItem): any[] { return [ + this.targetTypeIcon[asmtResult.targetType], asmtResult.targetName, asmtResult.level, this.asmtType === AssessmentType.InvokeAssessment ? asmtResult.message : asmtResult.displayName, diff --git a/extensions/sql-assessment/src/engine.ts b/extensions/sql-assessment/src/engine.ts index 92f2a0bcac..4b9ac4aab9 100644 --- a/extensions/sql-assessment/src/engine.ts +++ b/extensions/sql-assessment/src/engine.ts @@ -131,7 +131,7 @@ export class AssessmentEngine { let databaseListRequest = azdata.connection.listDatabases(this.connectionProfile.connectionId); let assessmentResult: azdata.SqlAssessmentResult; - if (AssessmentType.InvokeAssessment) { + if (asmtType === AssessmentType.InvokeAssessment) { TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.InvokeServerAssessment); assessmentResult = await this.sqlAssessment.assessmentInvoke(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Server); diff --git a/extensions/sql-assessment/src/tabs/assessmentMainTab.ts b/extensions/sql-assessment/src/tabs/assessmentMainTab.ts index 8a2cd70d88..9f1eab31c0 100644 --- a/extensions/sql-assessment/src/tabs/assessmentMainTab.ts +++ b/extensions/sql-assessment/src/tabs/assessmentMainTab.ts @@ -74,7 +74,7 @@ export class SqlAssessmentMainTab extends SqlAssessmentTab { } }); - this.resultGrid = new AssessmentResultGrid(view); + this.resultGrid = new AssessmentResultGrid(view, this.extensionContext); rootContainer.addItem(this.resultGrid.component, { flex: '1 1 auto', CSSStyles: { diff --git a/extensions/sql-assessment/src/tabs/historyTab.ts b/extensions/sql-assessment/src/tabs/historyTab.ts index daecbfc35e..356de7e12e 100644 --- a/extensions/sql-assessment/src/tabs/historyTab.ts +++ b/extensions/sql-assessment/src/tabs/historyTab.ts @@ -80,7 +80,7 @@ export class SqlAssessmentHistoryTab extends SqlAssessmentTab { root.clearItems(); - this.resultGrid = new AssessmentResultGrid(view); + this.resultGrid = new AssessmentResultGrid(view, this.extensionContext); this.toDispose.push(this.resultGrid); await view.initializeModel(title); diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 1f11aa65dd..5d76bb0999 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -819,10 +819,25 @@ declare module 'azdata' { } export interface TableComponent { - /** * Append data to an exsiting table data. */ appendData(data: any[][]); } + + export interface IconColumnCellValue { + icon: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri }; + ariaLabel: string; + } + + export enum ColumnType { + icon = 3 + } + + export interface TableColumn { + /** + * The text to display on the column heading. 'value' property will be used, if not specified + **/ + name?: string; + } } diff --git a/src/sql/base/browser/ui/table/formatters.ts b/src/sql/base/browser/ui/table/formatters.ts index 9236601a8e..c1f3b29d6d 100644 --- a/src/sql/base/browser/ui/table/formatters.ts +++ b/src/sql/base/browser/ui/table/formatters.ts @@ -36,6 +36,12 @@ export interface HyperlinkCellValue { linkOrCommand: string | ExecuteCommandInfo; } +export interface CssIconCellValue { + iconCssClass: string, + ariaLabel: string +} + + export namespace DBCellValue { export function isDBCellValue(object: any): boolean { return (object !== undefined && object.displayValue !== undefined && object.isNull !== undefined); @@ -50,6 +56,10 @@ export function isHyperlinkCellValue(obj: any | undefined): obj is HyperlinkCell return !!(obj)?.linkOrCommand; } +export function isCssIconCellValue(obj: any | undefined): obj is CssIconCellValue { + return !!(obj)?.iconCssClass; +} + /** * Format xml field into a hyperlink and performs HTML entity encoding */ @@ -102,6 +112,14 @@ export function textFormatter(row: number | undefined, cell: any | undefined, va return `${valueToDisplay}`; } + +export function iconCssFormatter(row: number | undefined, cell: any | undefined, value: any, columnDef: any | undefined, dataContext: any | undefined): string { + if (isCssIconCellValue(value)) { + return `
`; + } + return textFormatter(row, cell, value, columnDef, dataContext); +} + export function imageFormatter(row: number | undefined, cell: any | undefined, value: any, columnDef: any | undefined, dataContext: any | undefined): string { return ``; } @@ -129,7 +147,7 @@ export function slickGridDataItemColumnValueExtractor(value: any, columnDef: any * In this case, for no display value ariaLabel will be set to specific string "no data available" for accessibily support for screen readers * Set 'no data' label only if cell is present and has no value (so that checkbox and other custom plugins do not get 'no data' label) */ -export function slickGridDataItemColumnValueWithNoData(value: any, columnDef: any): { text: string; ariaLabel: string; } { +export function slickGridDataItemColumnValueWithNoData(value: any, columnDef: any): { text: string; ariaLabel: string; } | CssIconCellValue { let displayValue = value[columnDef.field]; if (typeof displayValue === 'number') { displayValue = displayValue.toString(); @@ -137,6 +155,11 @@ export function slickGridDataItemColumnValueWithNoData(value: any, columnDef: an if (displayValue instanceof Array) { displayValue = displayValue.toString(); } + + if (isCssIconCellValue(displayValue)) { + return displayValue; + } + return { text: displayValue, ariaLabel: displayValue ? escape(displayValue) : ((displayValue !== undefined) ? localize("tableCell.NoDataAvailable", "no data available") : displayValue) diff --git a/src/sql/base/browser/ui/table/plugins/textWithIconColumn.ts b/src/sql/base/browser/ui/table/plugins/textWithIconColumn.ts index 80c79d1e9c..9cf8b52221 100644 --- a/src/sql/base/browser/ui/table/plugins/textWithIconColumn.ts +++ b/src/sql/base/browser/ui/table/plugins/textWithIconColumn.ts @@ -12,29 +12,32 @@ export interface TextWithIconColumnDefinition extends iconCssClassField?: string; } -export interface TextWithIconColumnOptions { +export interface TextWithIconColumnOptions { iconCssClassField?: string; field?: string; width?: number; id?: string; resizable?: boolean; name?: string; + headerCssClass?: string; + formatter?: Slick.Formatter } export class TextWithIconColumn { private _definition: TextWithIconColumnDefinition; - constructor(options: TextWithIconColumnOptions) { + constructor(options: TextWithIconColumnOptions) { this._definition = { id: options.id, field: options.field, resizable: options.resizable, - formatter: this.formatter, + formatter: options.formatter ?? this.formatter, width: options.width, name: options.name, iconCssClassField: options.iconCssClassField, - cssClass: 'slick-icon-cell' + cssClass: 'slick-icon-cell', + headerCssClass: options.headerCssClass }; } private formatter(row: number, cell: number, value: any, columnDef: Slick.Column, dataContext: T): string { diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 244e1554ec..79521b692e 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -811,7 +811,8 @@ export enum SchemaObjectType { export enum ColumnType { text = 0, checkBox = 1, - button = 2 + button = 2, + icon = 3 } export enum ActionOnCellCheckboxCheck { diff --git a/src/sql/workbench/browser/modelComponents/iconUtils.ts b/src/sql/workbench/browser/modelComponents/iconUtils.ts index 6e6c7d0d36..fe985bbe78 100644 --- a/src/sql/workbench/browser/modelComponents/iconUtils.ts +++ b/src/sql/workbench/browser/modelComponents/iconUtils.ts @@ -52,3 +52,7 @@ function getIconUri(iconPath: string | URI): URI { return URI.revive(iconPath); } } + +export function getIconKey(iconPath: IUserFriendlyIcon): string { + return getLightIconUri(iconPath).toString(true) + getDarkIconUri(iconPath)?.toString(true); +} diff --git a/src/sql/workbench/browser/modelComponents/table.component.ts b/src/sql/workbench/browser/modelComponents/table.component.ts index f112e32244..6c5ec83d2a 100644 --- a/src/sql/workbench/browser/modelComponents/table.component.ts +++ b/src/sql/workbench/browser/modelComponents/table.component.ts @@ -23,12 +23,12 @@ import { CheckboxSelectColumn, ICheckboxCellActionEventArgs } from 'sql/base/bro import { Emitter, Event as vsEvent } from 'vs/base/common/event'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; -import { slickGridDataItemColumnValueWithNoData, textFormatter } from 'sql/base/browser/ui/table/formatters'; +import { slickGridDataItemColumnValueWithNoData, textFormatter, iconCssFormatter, CssIconCellValue } from 'sql/base/browser/ui/table/formatters'; import { isUndefinedOrNull } from 'vs/base/common/types'; import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType, ModelViewAction } from 'sql/platform/dashboard/browser/interfaces'; import { convertSizeToNumber } from 'sql/base/browser/dom'; import { ButtonColumn, ButtonClickEventArgs } from 'sql/base/browser/ui/table/plugins/buttonColumn.plugin'; -import { createIconCssClass } from 'sql/workbench/browser/modelComponents/iconUtils'; +import { IUserFriendlyIcon, createIconCssClass, getIconKey } from 'sql/workbench/browser/modelComponents/iconUtils'; import { HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin'; export enum ColumnSizingMode { @@ -37,6 +37,13 @@ export enum ColumnSizingMode { DataFit = 2 // columns use sizing based on cell data, horiz scroll bar present if more cells than visible in view area } +enum ColumnType { + text = 0, + checkBox = 1, + button = 2, + icon = 3 +} + @Component({ selector: 'modelview-table', template: ` @@ -57,6 +64,7 @@ export default class TableComponent extends ComponentBase>(); public readonly onCheckBoxChanged: vsEvent = this._onCheckBoxChanged.event; public readonly onButtonClicked: vsEvent> = this._onButtonClicked.event; + private _iconCssMap: { [iconKey: string]: string } = {}; @ViewChild('table', { read: ElementRef }) private _inputContainer: ElementRef; constructor( @@ -76,24 +84,17 @@ export default class TableComponent extends ComponentBase[] = []; let index: number = 0; + (columns).map(col => { - if (col.type && col.type === 1) { + if (col.type === ColumnType.checkBox) { this.createCheckBoxPlugin(col, index); - } - else if (col.type && col.type === 2) { + } else if (col.type === ColumnType.button) { this.createButtonPlugin(col); + } else if (col.type === ColumnType.icon) { + mycolumns.push(TableComponent.createIconColumn(col)); } else if (col.value) { - mycolumns.push(>{ - name: col.value, - id: col.value, - field: col.value, - width: col.width, - cssClass: col.cssClass, - headerCssClass: col.headerCssClass, - toolTip: col.toolTip, - formatter: textFormatter, - }); + mycolumns.push(TableComponent.createTextColumn(col as azdata.TableColumn)); } else { mycolumns.push(>{ name: col, @@ -116,16 +117,57 @@ export default class TableComponent extends ComponentBase(col: azdata.TableColumn): Slick.Column { + return >{ + name: col.name ?? col.value, + id: col.value, + field: col.value, + width: col.width, + cssClass: col.cssClass, + headerCssClass: col.headerCssClass, + toolTip: col.toolTip, + formatter: iconCssFormatter, + filterable: false + }; + } + + private static createTextColumn(col: azdata.TableColumn): Slick.Column { + return { + name: col.name ?? col.value, + id: col.value, + field: col.value, + width: col.width, + cssClass: col.cssClass, + headerCssClass: col.headerCssClass, + toolTip: col.toolTip, + formatter: textFormatter + }; + } + + public transformData(rows: (string | azdata.IconColumnCellValue)[][], columns: any[]): { [key: string]: string | CssIconCellValue }[] { if (rows && columns) { + return rows.map(row => { - let object: { [key: string]: string } = {}; - if (row.forEach) { - row.forEach((val, index) => { - let columnName: string = (columns[index].value) ? columns[index].value : columns[index]; - object[columnName] = val; - }); + let object: { [key: string]: string | CssIconCellValue } = {}; + if (!Array.isArray(row)) { + return object; } + + row.forEach((val, index) => { + let columnName: string = (columns[index].value) ? columns[index].value : columns[index]; + if (isIconColumnCellValue(val)) { + const icon: IUserFriendlyIcon = val.icon; + const iconKey: string = getIconKey(icon); + const iconCssClass = this._iconCssMap[iconKey] ?? createIconCssClass(icon); + if (!this._iconCssMap[iconKey]) { + this._iconCssMap[iconKey] = iconCssClass; + } + + object[columnName] = { iconCssClass: iconCssClass, ariaLabel: val.ariaLabel }; + } else { + object[columnName] = val; + } + }); return object; }); } else { @@ -263,7 +305,7 @@ export default class TableComponent extends ComponentBase Object.values(dataObject)); this.layoutTable(); } } + +function isIconColumnCellValue(obj: any | undefined): obj is azdata.IconColumnCellValue { + return !!(obj)?.icon; +} diff --git a/src/sql/workbench/test/electron-browser/modalComponents/table.component.test.ts b/src/sql/workbench/test/electron-browser/modalComponents/table.component.test.ts index abaff34e51..23d34ae987 100644 --- a/src/sql/workbench/test/electron-browser/modalComponents/table.component.test.ts +++ b/src/sql/workbench/test/electron-browser/modalComponents/table.component.test.ts @@ -5,10 +5,12 @@ import * as assert from 'assert'; import TableComponent from 'sql/workbench/browser/modelComponents/table.component'; +import { CssIconCellValue } from 'sql/base/browser/ui/table/formatters'; suite('TableComponent Tests', () => { setup(() => { + }); test('Table transformData should convert data and columns successfully given valid inputs', () => { @@ -17,7 +19,9 @@ suite('TableComponent Tests', () => { ['4', '5', '6'] ]; let columns = ['c1', 'c2', 'c3']; - let actual: { [key: string]: string }[] = TableComponent.transformData(data, columns); + const tableComponent = new TableComponent(undefined, undefined, undefined); + + let actual: { [key: string]: string | CssIconCellValue }[] = tableComponent.transformData(data, columns); let expected: { [key: string]: string }[] = [ { 'c1': '1', @@ -35,8 +39,9 @@ suite('TableComponent Tests', () => { test('Table transformData should return empty array given undefined rows', () => { let data = undefined; + const tableComponent = new TableComponent(undefined, undefined, undefined); let columns = ['c1', 'c2', 'c3']; - let actual: { [key: string]: string }[] = TableComponent.transformData(data, columns); + let actual: { [key: string]: string | CssIconCellValue }[] = tableComponent.transformData(data, columns); let expected: { [key: string]: string }[] = []; assert.deepEqual(actual, expected); }); @@ -47,7 +52,8 @@ suite('TableComponent Tests', () => { ['4', '5', '6'] ]; let columns; - let actual: { [key: string]: string }[] = TableComponent.transformData(data, columns); + const tableComponent = new TableComponent(undefined, undefined, undefined); + let actual: { [key: string]: string | CssIconCellValue }[] = tableComponent.transformData(data, columns); let expected: { [key: string]: string }[] = []; assert.deepEqual(actual, expected); }); @@ -57,8 +63,9 @@ suite('TableComponent Tests', () => { ['1', '2'], ['4', '5'] ]; + const tableComponent = new TableComponent(undefined, undefined, undefined); let columns = ['c1', 'c2', 'c3']; - let actual: { [key: string]: string }[] = TableComponent.transformData(data, columns); + let actual: { [key: string]: string | CssIconCellValue }[] = tableComponent.transformData(data, columns); let expected: { [key: string]: string }[] = [ { 'c1': '1',