diff --git a/src/sql/workbench/browser/designer/designer.ts b/src/sql/workbench/browser/designer/designer.ts index d723126ac4..fc1199be4f 100644 --- a/src/sql/workbench/browser/designer/designer.ts +++ b/src/sql/workbench/browser/designer/designer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { - DesignerComponentInput, DesignerEditType, DesignerTab, DesignerEdit, DesignerEditPath, DesignerViewModel, DesignerDataPropertyInfo, + DesignerComponentInput, DesignerEditType, DesignerTab, DesignerEdit, DesignerPropertyPath, DesignerViewModel, DesignerDataPropertyInfo, DesignerTableComponentRowData, DesignerTableProperties, InputBoxProperties, DropDownProperties, CheckBoxProperties, DesignerEditProcessedEventArgs, DesignerStateChangedEventArgs, DesignerAction, DesignerUIState, ScriptProperty, DesignerRootObjectPath } @@ -38,6 +38,10 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { DesignerMessagesTabPanelView } from 'sql/workbench/browser/designer/designerMessagesTabPanelView'; import { DesignerScriptEditorTabPanelView } from 'sql/workbench/browser/designer/designerScriptEditorTabPanelView'; +import { DesignerPropertyPathValidator } from 'sql/workbench/browser/designer/designerPropertyPathValidator'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { listActiveSelectionBackground, listActiveSelectionForeground } from 'vs/platform/theme/common/colorRegistry'; +import { alert } from 'vs/base/browser/ui/aria/aria'; export interface IDesignerStyle { tabbedPanelStyles?: ITabbedPanelStyles; @@ -52,7 +56,7 @@ export interface IDesignerStyle { export type DesignerUIComponent = InputBox | Checkbox | Table | SelectBox; -export type CreateComponentsFunc = (container: HTMLElement, components: DesignerDataPropertyInfo[], parentPath: DesignerEditPath) => DesignerUIComponent[]; +export type CreateComponentsFunc = (container: HTMLElement, components: DesignerDataPropertyInfo[], parentPath: DesignerPropertyPath) => DesignerUIComponent[]; export type SetComponentValueFunc = (definition: DesignerDataPropertyInfo, component: DesignerUIComponent, data: DesignerViewModel) => void; const TableRowHeight = 25; @@ -92,14 +96,15 @@ export class Designer extends Disposable implements IThemable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextViewService private readonly _contextViewProvider: IContextViewService, @INotificationService private readonly _notificationService: INotificationService, - @IDialogService private readonly _dialogService: IDialogService) { + @IDialogService private readonly _dialogService: IDialogService, + @IThemeService private readonly _themeService: IThemeService) { super(); this._tableCellEditorFactory = new TableCellEditorFactory( { valueGetter: (item, column): string => { return item[column.field].value; }, - valueSetter: (parentPath: DesignerEditPath, row: number, item: DesignerTableComponentRowData, column: Slick.Column, value: string): void => { + valueSetter: (parentPath: DesignerPropertyPath, row: number, item: DesignerTableComponentRowData, column: Slick.Column, value: string): void => { this.handleEdit({ type: DesignerEditType.Update, path: [...parentPath, row, column.field], @@ -138,7 +143,10 @@ export class Designer extends Disposable implements IThemable { onDidChange: Event.None }, Sizing.Distribute); this._scriptTabbedPannel = new TabbedPanel(this._editorContainer); - this._messagesView = new DesignerMessagesTabPanelView(); + this._messagesView = this._instantiationService.createInstance(DesignerMessagesTabPanelView); + this._register(this._messagesView.onMessageSelected((path) => { + this.selectProperty(path); + })); this._scriptEditorView = new DesignerScriptEditorTabPanelView(this._instantiationService); this._scriptTabbedPannel.pushTab({ title: localize('designer.scriptTabTitle', "Scripts"), @@ -311,6 +319,9 @@ export class Designer extends Disposable implements IThemable { private handleEditProcessedEvent(args: DesignerEditProcessedEventArgs): void { const edit = args.edit; this._supressEditProcessing = true; + if (!args.result.isValid) { + alert(localize('designer.errorCountAlert', "{0} validation errors found.", args.result.errors.length)); + } try { this.updateComponentValues(); if (edit.type === DesignerEditType.Add) { @@ -407,7 +418,7 @@ export class Designer extends Disposable implements IThemable { return rows * TableRowHeight + TableHeaderRowHeight; } - private updatePropertiesPane(objectPath: DesignerEditPath): void { + private updatePropertiesPane(objectPath: DesignerPropertyPath): void { let type: string; let components: DesignerDataPropertyInfo[]; let objectViewModel: DesignerViewModel; @@ -469,6 +480,74 @@ export class Designer extends Disposable implements IThemable { this._messagesView.updateMessages(this._input.validationErrors); } + private selectProperty(path: DesignerPropertyPath): void { + if (!DesignerPropertyPathValidator.validate(path, this._input.viewModel)) { + return; + } + + // Find top level property + let found = false; + if (this._input.view.components) { + for (const component of this._input.view.components) { + if (path[0] === component.propertyName) { + found = true; + break; + } + } + } + if (this._input.view.tabs) { + for (const tab of this._input.view.tabs) { + if (tab) { + for (const component of tab.components) { + if (path[0] === component.propertyName) { + this._contentTabbedPanel.showTab(tab.title); + found = true; + break; + } + } + } + if (found) { + break; + } + } + } + + if (found) { + const propertyInfo = this._componentMap.get(path[0]); + if (propertyInfo.defintion.componentType !== 'table') { + propertyInfo.component.focus(); + return; + } else { + const tableComponent = >propertyInfo.component; + const targetRow = path[1]; + const targetCell = 0; + tableComponent.setActiveCell(targetRow, targetCell); + tableComponent.grid.scrollCellIntoView(targetRow, targetCell, false); + if (path.length > 2) { + const relativePath = path.slice(2); + this._propertiesPane.selectProperty(relativePath); + } + } + this.highlightActiveElement(); + } + } + + private highlightActiveElement(): void { + const bgColor = this._themeService.getColorTheme().getColor(listActiveSelectionBackground); + const color = this._themeService.getColorTheme().getColor(listActiveSelectionForeground); + const currentElement = document.activeElement as HTMLElement; + if (currentElement) { + const originalBGColor = currentElement.style.backgroundColor; + const originalColor = currentElement.style.color; + currentElement.style.backgroundColor = bgColor.toString(); + currentElement.style.color = color.toString(); + setTimeout(() => { + currentElement.style.color = originalColor; + currentElement.style.backgroundColor = originalBGColor; + }, 500); + } + } + private handleEdit(edit: DesignerEdit): void { if (this._supressEditProcessing) { return; @@ -553,7 +632,7 @@ export class Designer extends Disposable implements IThemable { components: DesignerDataPropertyInfo[], componentMap: Map, groupHeaders: HTMLElement[], - parentPath: DesignerEditPath, + parentPath: DesignerPropertyPath, area: DesignerUIArea): DesignerUIComponent[] { const uiComponents = []; const groupNames = []; @@ -589,7 +668,7 @@ export class Designer extends Disposable implements IThemable { private createComponent(container: HTMLElement, componentDefinition: DesignerDataPropertyInfo, - parentPath: DesignerEditPath, + parentPath: DesignerPropertyPath, componentMap: Map, view: DesignerUIArea): DesignerUIComponent { const propertyPath = [...parentPath, componentDefinition.propertyName]; @@ -625,6 +704,7 @@ export class Designer extends Disposable implements IThemable { const dropdownContainer = container.appendChild(DOM.$('')); const dropdownProperties = componentDefinition.componentProperties as DropDownProperties; const dropdown = new SelectBox(dropdownProperties.values as string[] || [], undefined, this._contextViewProvider, undefined); + dropdown.setAriaLabel(componentDefinition.componentProperties?.title); dropdown.render(dropdownContainer); dropdown.selectElem.style.height = '25px'; dropdown.onDidSelect((e) => { @@ -679,6 +759,7 @@ export class Designer extends Disposable implements IThemable { addRowButton.icon = { id: `add-row-button new codicon` }; + addRowButton.ariaLabel = localize('designer.newRowButtonAriaLabel', "Add new row to '{0}' table", tableProperties.ariaLabel); this._buttons.push(addRowButton); } const tableContainer = container.appendChild(DOM.$('.full-row')); @@ -695,7 +776,8 @@ export class Designer extends Disposable implements IThemable { } }, rowHeight: TableRowHeight, - headerRowHeight: TableHeaderRowHeight + headerRowHeight: TableHeaderRowHeight, + editorLock: new Slick.EditorLock() }); table.ariaLabel = tableProperties.ariaLabel; const columns = tableProperties.columns.map(propName => { @@ -819,13 +901,15 @@ export class Designer extends Disposable implements IThemable { private getUIState(): DesignerUIState { return { - activeTabId: this._contentTabbedPanel.activeTabId + activeContentTabId: this._contentTabbedPanel.activeTabId, + activeScriptTabId: this._scriptTabbedPannel.activeTabId }; } private restoreUIState(): void { if (this._input.designerUIState) { - this._contentTabbedPanel.showTab(this._input.designerUIState.activeTabId); + this._contentTabbedPanel.showTab(this._input.designerUIState.activeContentTabId); + this._scriptTabbedPannel.showTab(this._input.designerUIState.activeScriptTabId); } } } diff --git a/src/sql/workbench/browser/designer/designerMessagesTabPanelView.ts b/src/sql/workbench/browser/designer/designerMessagesTabPanelView.ts index f7d0a4b938..27909c4566 100644 --- a/src/sql/workbench/browser/designer/designerMessagesTabPanelView.ts +++ b/src/sql/workbench/browser/designer/designerMessagesTabPanelView.ts @@ -6,25 +6,118 @@ import { IPanelView } from 'sql/base/browser/ui/panel/panel'; import { Disposable } from 'vs/base/common/lifecycle'; import * as DOM from 'vs/base/browser/dom'; -import { DesignerValidationError } from 'sql/workbench/browser/designer/interfaces'; +import { DesignerPropertyPath, DesignerValidationError } from 'sql/workbench/browser/designer/interfaces'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IListAccessibilityProvider, List } from 'vs/base/browser/ui/list/listWidget'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { localize } from 'vs/nls'; +import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { attachListStyler } from 'vs/platform/theme/common/styler'; +import { problemsErrorIconForeground } from 'vs/platform/theme/common/colorRegistry'; +import { Codicon } from 'vs/base/common/codicons'; export class DesignerMessagesTabPanelView extends Disposable implements IPanelView { private _container: HTMLElement; + private _onMessageSelected = new Emitter(); + private _messageList: List; + + public readonly onMessageSelected: Event = this._onMessageSelected.event; + + constructor(@IThemeService private _themeService: IThemeService) { + super(); + } render(container: HTMLElement): void { this._container = container.appendChild(DOM.$('.messages-container')); + this._messageList = new List('designerMessageList', this._container, new DesignerMessageListDelegate(), [new TableFilterListRenderer()], { + multipleSelectionSupport: false, + keyboardSupport: true, + mouseSupport: true, + accessibilityProvider: new DesignerMessagesListAccessibilityProvider() + }); + this._register(this._messageList.onDidChangeSelection((e) => { + if (e.elements && e.elements.length === 1) { + this._onMessageSelected.fire(e.elements[0].propertyPath); + } + })); + this._register(attachListStyler(this._messageList, this._themeService)); } layout(dimension: DOM.Dimension): void { + this._messageList.layout(dimension.height, dimension.width); } updateMessages(errors: DesignerValidationError[]) { - if (this._container) { - DOM.clearNode(this._container); - errors?.forEach(error => { - const messageItem = this._container.appendChild(DOM.$('.message-item.codicon.error')); - messageItem.innerText = error.message; - }); + if (this._messageList) { + this._messageList.splice(0, this._messageList.length, errors); } } } + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const errorForegroundColor = theme.getColor(problemsErrorIconForeground); + if (errorForegroundColor) { + collector.addRule(` + .designer-component .messages-container .message-item .message-icon { + color: ${errorForegroundColor}; + } + `); + } +}); + +const DesignerMessageListTemplateId = 'DesignerMessageListTemplate'; +class DesignerMessageListDelegate implements IListVirtualDelegate { + getHeight(element: DesignerValidationError): number { + return 25; + } + + getTemplateId(element: DesignerValidationError): string { + return DesignerMessageListTemplateId; + } +} + +interface DesignerMessageListItemTemplate { + messageText: HTMLDivElement; +} + +class TableFilterListRenderer implements IListRenderer { + renderTemplate(container: HTMLElement): DesignerMessageListItemTemplate { + const data: DesignerMessageListItemTemplate = Object.create(null); + const messageItem = container.appendChild(DOM.$('.message-item')); + messageItem.appendChild(DOM.$(`.message-icon${Codicon.error.cssSelector}`)); + data.messageText = messageItem.appendChild(DOM.$('.message-text')); + return data; + } + + renderElement(element: DesignerValidationError, index: number, templateData: DesignerMessageListItemTemplate, height: number): void { + templateData.messageText.innerText = element.message; + } + + disposeElement?(element: DesignerValidationError, index: number, templateData: DesignerMessageListItemTemplate, height: number): void { + } + + public disposeTemplate(templateData: DesignerMessageListItemTemplate): void { + } + + public get templateId(): string { + return DesignerMessageListTemplateId; + } +} + +class DesignerMessagesListAccessibilityProvider implements IListAccessibilityProvider { + getAriaLabel(element: DesignerValidationError): string { + return element.message; + } + + getWidgetAriaLabel(): string { + return localize('designer.MessageListAriaLabel', "Errors"); + } + + getWidgetRole() { + return 'listbox'; + } + + getRole(element: DesignerValidationError): string { + return 'option'; + } +} diff --git a/src/sql/workbench/browser/designer/designerPropertiesPane.ts b/src/sql/workbench/browser/designer/designerPropertiesPane.ts index 29bfe2acb5..ddcb52c1ab 100644 --- a/src/sql/workbench/browser/designer/designerPropertiesPane.ts +++ b/src/sql/workbench/browser/designer/designerPropertiesPane.ts @@ -3,14 +3,15 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Table } from 'sql/base/browser/ui/table/table'; import { CreateComponentsFunc, DesignerUIComponent, SetComponentValueFunc } from 'sql/workbench/browser/designer/designer'; -import { DesignerViewModel, DesignerDataPropertyInfo, DesignerEditPath } from 'sql/workbench/browser/designer/interfaces'; +import { DesignerViewModel, DesignerDataPropertyInfo, DesignerPropertyPath } from 'sql/workbench/browser/designer/interfaces'; import * as DOM from 'vs/base/browser/dom'; import { equals } from 'vs/base/common/objects'; import { localize } from 'vs/nls'; export interface ObjectInfo { - path: DesignerEditPath; + path: DesignerPropertyPath; type: string; components: DesignerDataPropertyInfo[]; viewModel: DesignerViewModel; @@ -19,7 +20,7 @@ export interface ObjectInfo { export class DesignerPropertiesPane { private _titleElement: HTMLElement; private _contentElement: HTMLElement; - private _objectPath: DesignerEditPath; + private _objectPath: DesignerPropertyPath; private _componentMap = new Map(); private _groupHeaders: HTMLElement[] = []; @@ -48,7 +49,7 @@ export class DesignerPropertiesPane { return this._componentMap; } - public get objectPath(): DesignerEditPath { + public get objectPath(): DesignerPropertyPath { return this._objectPath; } @@ -94,4 +95,22 @@ export class DesignerPropertiesPane { }); this._descriptionContainer.style.display = 'none'; } + + public selectProperty(path: DesignerPropertyPath): void { + const componentInfo = this.componentMap.get(path[0]); + if (componentInfo.defintion.componentType !== 'table') { + componentInfo.component.focus(); + return; + } + + const table = componentInfo.component as Table; + const row = path[1] as number; + let cell = 0; + if (path.length === 3) { + const colName = path[2] as string; + cell = table.columns.findIndex(c => c.field === colName); + } + table.setActiveCell(row, cell); + table.grid.scrollCellIntoView(row, cell, false); + } } diff --git a/src/sql/workbench/browser/designer/designerPropertyPathValidator.ts b/src/sql/workbench/browser/designer/designerPropertyPathValidator.ts new file mode 100644 index 0000000000..c2b11779f9 --- /dev/null +++ b/src/sql/workbench/browser/designer/designerPropertyPathValidator.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DesignerPropertyPath, DesignerTableProperties, DesignerViewModel } from 'sql/workbench/browser/designer/interfaces'; + +export class DesignerPropertyPathValidator { + + /** + * Validate the path property, detail of the path can be found in the azdata typings file. + * @param path path of the property. + * @param viewModel the view model. + * @returns Whether the path is valid. + */ + static validate(path: DesignerPropertyPath, viewModel: DesignerViewModel): boolean { + /** + * Path specification for all supported scenarios: + * 1. 'Add' scenario + * a. ['propertyName1']. Example: add a column to the columns property: ['columns']. + * b. ['propertyName1',index-1,'propertyName2']. Example: add a column mapping to the first foreign key: ['foreignKeys',0,'mappings']. + * 2. 'Update' scenario + * a. ['propertyName1']. Example: update the name of the table: ['name']. + * b. ['propertyName1',index-1,'propertyName2']. Example: update the name of a column: ['columns',0,'name']. + * c. ['propertyName1',index-1,'propertyName2',index-2,'propertyName3']. Example: update the source column of an entry in a foreign key's column mapping table: ['foreignKeys',0,'mappings',0,'source']. + * 3. 'Remove' scenario + * a. ['propertyName1',index-1]. Example: remove a column from the columns property: ['columns',0']. + * b. ['propertyName1',index-1,'propertyName2',index-2]. Example: remove a column mapping from a foreign key's column mapping table: ['foreignKeys',0,'mappings',0]. + */ + if (!path || path.length === 0 || path.length > 5) { + return false; + } + + for (let index = 0; index < path.length; index++) { + const expectingNumber = (index % 2) !== 0; + if (expectingNumber && typeof path[index] !== 'number') { + return false; + } + + if (!expectingNumber && typeof path[index] !== 'string') { + return false; + } + } + let currentObject = viewModel; + for (let index = 0; index < path.length;) { + const propertyName = path[index]; + if (Object.keys(currentObject).indexOf(propertyName) === -1) { + return false; + } + if (index === path.length - 1) { + break; + } + index++; + const tableData = currentObject[propertyName]; + const objectIndex = path[index]; + if (!tableData.data || tableData.data.length - 1 < objectIndex) { + return false; + } + currentObject = tableData.data[objectIndex]; + index++; + } + return true; + } +} diff --git a/src/sql/workbench/browser/designer/interfaces.ts b/src/sql/workbench/browser/designer/interfaces.ts index aec40c127d..65b3eccf39 100644 --- a/src/sql/workbench/browser/designer/interfaces.ts +++ b/src/sql/workbench/browser/designer/interfaces.ts @@ -81,7 +81,8 @@ export interface DesignerComponentInput { } export interface DesignerUIState { - activeTabId: PanelTabIdentifier; + activeContentTabId: PanelTabIdentifier; + activeScriptTabId: PanelTabIdentifier; } export type DesignerAction = 'publish' | 'initialize' | 'processEdit' | 'generateScript' | 'generateReport'; @@ -180,7 +181,6 @@ export interface DesignerTableProperties extends ComponentProperties { * Whether user can add new rows to the table. The default value is true. */ canAddRows?: boolean; - /** * Whether user can remove rows from the table. The default value is true. */ @@ -208,14 +208,14 @@ export enum DesignerEditType { export interface DesignerEdit { type: DesignerEditType; - path: DesignerEditPath; + path: DesignerPropertyPath; value?: any; } -export type DesignerEditPath = (string | number)[]; -export const DesignerRootObjectPath: DesignerEditPath = []; +export type DesignerPropertyPath = (string | number)[]; +export const DesignerRootObjectPath: DesignerPropertyPath = []; -export type DesignerValidationError = { message: string, property?: DesignerEditPath }; +export type DesignerValidationError = { message: string, propertyPath?: DesignerPropertyPath }; export interface DesignerEditResult { isValid: boolean; diff --git a/src/sql/workbench/browser/designer/media/designer.css b/src/sql/workbench/browser/designer/media/designer.css index 2eac8241b4..8c32ddc58b 100644 --- a/src/sql/workbench/browser/designer/media/designer.css +++ b/src/sql/workbench/browser/designer/media/designer.css @@ -30,19 +30,25 @@ } .designer-component .messages-container { - overflow: scroll; + overflow: hidden; height: 100%; width: 100%; } .designer-component .messages-container .message-item { - padding: 0px 5px 0px 25px; - background-position: 5px center; - background-size: 16px 16px; - user-select: text; + display: flex; +} + +.designer-component .messages-container .message-item .message-icon { + margin: 0px 6px; + flex: 0 0 auto; line-height: 25px; } +.designer-component .messages-container .message-item .message-text { + flex: 1 1 auto; +} + .designer-component .tabbed-panel-container { flex: 1 1 auto; overflow: hidden; diff --git a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts index 23fbf7ed3f..f7040fa8e6 100644 --- a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts +++ b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; -import { DesignerViewModel, DesignerEdit, DesignerComponentInput, DesignerView, DesignerTab, DesignerDataPropertyInfo, DropDownProperties, DesignerTableProperties, DesignerEditProcessedEventArgs, DesignerAction, DesignerStateChangedEventArgs, DesignerEditPath, DesignerValidationError } from 'sql/workbench/browser/designer/interfaces'; +import { DesignerViewModel, DesignerEdit, DesignerComponentInput, DesignerView, DesignerTab, DesignerDataPropertyInfo, DropDownProperties, DesignerTableProperties, DesignerEditProcessedEventArgs, DesignerAction, DesignerStateChangedEventArgs, DesignerPropertyPath, DesignerValidationError } from 'sql/workbench/browser/designer/interfaces'; import { TableDesignerProvider } from 'sql/workbench/services/tableDesigner/common/interface'; import { localize } from 'vs/nls'; import { designers } from 'sql/workbench/api/common/sqlExtHostTypes'; @@ -658,7 +658,7 @@ export class TableDesignerComponentInput implements DesignerComponentInput { b. ['propertyName1',index-1,'proper The return values would be the propertyNames followed by slashes in level order. Eg.: propertyName1/propertyName2/... */ - private getObjectTypeFromPath(path: DesignerEditPath): string { + private getObjectTypeFromPath(path: DesignerPropertyPath): string { let typeArray = []; for (let i = 0; i < path.length; i++) { if (i % 2 === 0) {