From 784d8e9e9631a5e0829a5d9e462c9e32ee474581 Mon Sep 17 00:00:00 2001 From: Aditya Bist Date: Thu, 12 May 2022 10:49:45 -0700 Subject: [PATCH] Table Designer: Move columns UI (#19154) * format * added buttons and initial drag plugin * initial drag and drop working * add actions and taskbar * drag and drop bugfix and other changes * fix few issues * more changes * fix all move and insertion issues * PRcomments * fit and finish comments * remove dead code * bump sts * add style for object being dragged * add plugin to copyright filter * Add to eslintrc * fix drag contrast ratio * generalize logic for cell focus * demo feedback * feedback * add action state * feedback * remove unncecessary check * add move actions to context menu * change to const * fix bug with tables and fix drop color Co-authored-by: chgagnon --- .eslintrc.json | 1 + build/filters.js | 1 + src/sql/azdata.proposed.d.ts | 14 +- .../ui/table/plugins/rowMoveManager.plugin.ts | 175 ++++++++++++ .../browser/ui/table/plugins/tableColumn.ts | 4 +- src/sql/base/browser/ui/table/table.ts | 4 + .../workbench/api/common/sqlExtHostTypes.ts | 3 +- .../workbench/browser/designer/designer.ts | 264 ++++++++++++++---- .../workbench/browser/designer/interfaces.ts | 11 +- .../browser/designer/media/designer.css | 9 +- .../browser/designer/tableActions.ts | 194 +++++++++++++ .../browser/tableDesignerComponentInput.ts | 2 + 12 files changed, 621 insertions(+), 61 deletions(-) create mode 100644 src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts create mode 100644 src/sql/workbench/browser/designer/tableActions.ts diff --git a/.eslintrc.json b/.eslintrc.json index 08c503d03f..2681ae4bbc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1151,6 +1151,7 @@ "src/sql/base/browser/ui/table/plugins/checkboxSelectColumn.plugin.ts", "src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts", "src/sql/base/browser/ui/table/plugins/rowDetailView.ts", + "src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts", "src/sql/base/browser/ui/table/plugins/rowSelectionModel.plugin.ts", "src/sql/workbench/services/notebook/browser/outputs/factories.ts", "src/sql/workbench/services/notebook/browser/outputs/mimemodel.ts", diff --git a/build/filters.js b/build/filters.js index ca10c5a052..be1990667b 100644 --- a/build/filters.js +++ b/build/filters.js @@ -183,6 +183,7 @@ module.exports.copyrightFilter = [ '!src/sql/base/browser/ui/table/plugins/checkboxSelectColumn.plugin.ts', '!src/sql/base/browser/ui/table/plugins/cellSelectionModel.plugin.ts', '!src/sql/base/browser/ui/table/plugins/autoSizeColumns.plugin.ts', + '!src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts', '!src/sql/workbench/services/notebook/browser/outputs/sanitizer.ts', '!src/sql/workbench/contrib/notebook/browser/outputs/renderers.ts', '!src/sql/workbench/services/notebook/browser/outputs/tableRenderers.ts', diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 1f4cc82ca7..5992975869 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -1015,6 +1015,14 @@ declare module 'azdata' { * Whether user can remove rows from the table. The default value is true. */ canRemoveRows?: boolean; + /** + * Whether user can move rows from one index to another. The default value is true. + */ + canMoveRows?: boolean; + /** + * Whether user can insert rows at a given index to the table. The default value is true. + */ + canInsertRows?: boolean; /** * Whether to show confirmation when user removes a row. The default value is false. */ @@ -1081,7 +1089,11 @@ declare module 'azdata' { /** * Update a property. */ - Update = 2 + Update = 2, + /** + * Change the position of an item in the collection. + */ + Move = 3 } /** diff --git a/src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts b/src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts new file mode 100644 index 0000000000..0813b22d27 --- /dev/null +++ b/src/sql/base/browser/ui/table/plugins/rowMoveManager.plugin.ts @@ -0,0 +1,175 @@ +// Adopted and converted to typescript from https://github.com/mleibman/SlickGrid/blob/gh-pages/plugins/slick.rowmovemanager.js +// heavily modified + +import { RowSelectionModel } from 'sql/base/browser/ui/table/plugins/rowSelectionModel.plugin'; +import { BaseClickableColumn, ClickableColumnOptions, IconColumnOptions } from 'sql/base/browser/ui/table/plugins/tableColumn'; +import { mixin } from 'vs/base/common/objects'; + +const defaultOptions: IRowMoveManagerOptions = { + cancelEditOnDrag: false +}; + +export interface IRowMoveManagerOptions extends IconColumnOptions, ClickableColumnOptions, Slick.Column { + cancelEditOnDrag?: boolean; +} + +export interface RowMoveOnDragEventArgs { + selectionProxy?: JQuery; + guide?: JQuery; + selectedRows?: number[]; + insertBefore?: number; + canMove?: boolean; +} + +export interface RowMoveOnDragEventData { + rows?: number[]; + insertBefore: number; +} + +// Wrapper interfaces for drag arguments to support selection +export interface OnRowMoveDragInitEventArgs extends Slick.OnDragInitEventArgs, RowMoveOnDragEventArgs { } +export interface OnRowMoveDragStartEventArgs extends Slick.OnDragStartEventArgs, RowMoveOnDragEventArgs { } +export interface OnRowMoveDragEventArgs extends Slick.OnDragEventArgs, RowMoveOnDragEventArgs { } +export interface OnRowMoveDragEndEventArgs extends Slick.OnDragEndEventArgs, RowMoveOnDragEventArgs { } + +export class RowMoveManager extends BaseClickableColumn { + + private _canvas: HTMLCanvasElement; + private _dragging: boolean; + + public onBeforeMoveRows: Slick.Event = new Slick.Event(); + public onMoveRows: Slick.Event = new Slick.Event(); + + constructor(private options: IRowMoveManagerOptions) { + super(options); + this.options = mixin(options, defaultOptions, false); + } + + public get definition(): Slick.Column { + return { + id: this.options.id || this.options.title || this.options.field, + width: this.options.width ?? 26, + name: this.options.name, + resizable: this.options.resizable, + selectable: false, + behavior: this.options.behavior, + cssClass: this.options.iconCssClass + }; + } + + + public override init(grid: Slick.Grid) { + this._grid = grid; + this._grid.setSelectionModel(new RowSelectionModel()); + this._canvas = this._grid.getCanvasNode(); + this._handler + .subscribe(this._grid.onDragInit, (e: DOMEvent, data: OnRowMoveDragInitEventArgs) => this.onDragInit(e as MouseEvent, data)) + .subscribe(this._grid.onDragStart, (e: DOMEvent, data: OnRowMoveDragStartEventArgs) => this.onDragStart(e as MouseEvent, data)) + .subscribe(this._grid.onDrag, (e: DOMEvent, data: OnRowMoveDragEventArgs) => this.onDrag(e as MouseEvent, data)) + .subscribe(this._grid.onDragEnd, (e: DOMEvent, data: OnRowMoveDragEndEventArgs) => this.onDragEnd(e as MouseEvent, data)); + } + + private onDragInit(e: MouseEvent, data: OnRowMoveDragInitEventArgs) { + e.stopImmediatePropagation(); + } + + private onDragStart(e: MouseEvent, data: OnRowMoveDragStartEventArgs) { + const cell = this._grid.getCellFromEvent(e); + const highlightStyle = {}; + const columns = this._grid.getColumns(); + highlightStyle[cell.row] = {}; + columns.forEach((c) => { + highlightStyle[cell.row][c.id] = 'isDragging'; + }); + this._grid.setCellCssStyles('isDragging', highlightStyle); + + if (this.options.cancelEditOnDrag && this._grid.getEditorLock().isActive()) { + this._grid.getEditorLock().cancelCurrentEdit(); + } + + if (this._grid.getEditorLock().isActive() || !/move|selectAndMove/.test(this._grid.getColumns()[cell.cell].behavior)) { + return; + } + + this._dragging = true; + e.stopImmediatePropagation(); + + let selectedRows = this._grid.getSelectedRows(); + + if (selectedRows.length === 0 || jQuery.inArray(cell.row, selectedRows) === -1) { + selectedRows = [cell.row]; + this._grid.setSelectedRows(selectedRows); + } + + const rowHeight = this._grid.getOptions().rowHeight; + + data.selectedRows = selectedRows; + + data.selectionProxy = jQuery('
') + .css('position', 'absolute') + .css('zIndex', '99999') + .css('width', jQuery(this._canvas).innerWidth()) + .css('height', rowHeight * selectedRows.length) + .appendTo(this._canvas); + + data.guide = jQuery('
') + .css('position', 'absolute') + .css('zIndex', '99998') + .css('width', jQuery(this._canvas).innerWidth()) + .css('top', -1000) + .appendTo(this._canvas); + + data.insertBefore = -1; + } + + private onDrag(e: MouseEvent, data: OnRowMoveDragEventArgs) { + if (!this._dragging) { + return; + } + + e.stopImmediatePropagation(); + + const top = e.pageY - jQuery(this._canvas).offset().top; + data.selectionProxy.css('top', top - 5); + + const insertBefore = Math.max(0, Math.min(Math.round(top / this._grid.getOptions().rowHeight), this._grid.getDataLength())); + if (insertBefore !== data.insertBefore) { + const eventData: RowMoveOnDragEventData = { + 'rows': data.selectedRows, + 'insertBefore': insertBefore + }; + + if (this.onBeforeMoveRows.notify(eventData) === false) { + data.guide.css('top', -1000); + data.canMove = false; + } else { + data.guide.css('top', insertBefore * this._grid.getOptions().rowHeight); + data.canMove = true; + } + + data.insertBefore = insertBefore; + } + + } + + private onDragEnd(e: MouseEvent, data: OnRowMoveDragEndEventArgs) { + if (!this._dragging) { + return; + } + this._dragging = false; + this._grid.removeCellCssStyles('isDragging'); + e.stopImmediatePropagation(); + + data.guide.remove(); + data.selectionProxy.remove(); + + if (data.canMove) { + const eventData: RowMoveOnDragEventData = { + 'rows': data.selectedRows, + 'insertBefore': data.insertBefore + }; + this.onMoveRows.notify(eventData); + } + } + +} diff --git a/src/sql/base/browser/ui/table/plugins/tableColumn.ts b/src/sql/base/browser/ui/table/plugins/tableColumn.ts index 876e5fd8b7..b393d202e2 100644 --- a/src/sql/base/browser/ui/table/plugins/tableColumn.ts +++ b/src/sql/base/browser/ui/table/plugins/tableColumn.ts @@ -25,8 +25,8 @@ export interface ClickableColumnOptions { } export abstract class BaseClickableColumn implements Slick.Plugin, TableColumn { - private _handler = new Slick.EventHandler(); - private _grid!: Slick.Grid; + protected _handler = new Slick.EventHandler(); + protected _grid!: Slick.Grid; private _onClick = new Emitter>(); public onClick = this._onClick.event; diff --git a/src/sql/base/browser/ui/table/table.ts b/src/sql/base/browser/ui/table/table.ts index 3ae7fc8286..fc587bed6e 100644 --- a/src/sql/base/browser/ui/table/table.ts +++ b/src/sql/base/browser/ui/table/table.ts @@ -59,6 +59,9 @@ export class Table extends Widget implements IDisposa private _onColumnResize = new Emitter(); public readonly onColumnResize = this._onColumnResize.event; + private _onBlur = new Emitter(); + public readonly onBlur = this._onBlur.event; + constructor(parent: HTMLElement, configuration?: ITableConfiguration, options?: Slick.GridOptions) { super(); if (!configuration || !configuration.dataProvider || isArray(configuration.dataProvider)) { @@ -84,6 +87,7 @@ export class Table extends Widget implements IDisposa clearTimeout(this._classChangeTimeout); this._classChangeTimeout = setTimeout(() => { this._container.classList.remove('focused'); + this._onBlur.fire(); }, 100); }, true)); diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 7e476029d8..17b6d6654d 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -1022,7 +1022,8 @@ export namespace designers { export enum DesignerEditType { Add = 0, Remove = 1, - Update = 2 + Update = 2, + Move = 3 } export enum TableIcon { diff --git a/src/sql/workbench/browser/designer/designer.ts b/src/sql/workbench/browser/designer/designer.ts index 81a0bb48ea..06f3f8c4e6 100644 --- a/src/sql/workbench/browser/designer/designer.ts +++ b/src/sql/workbench/browser/designer/designer.ts @@ -16,7 +16,7 @@ import { Orientation, Sizing, SplitView } from 'vs/base/browser/ui/splitview/spl import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IInputBoxStyles, InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; import 'vs/css!./media/designer'; -import { ITableStyles } from 'sql/base/browser/ui/table/interfaces'; +import { ITableMouseEvent, ITableStyles } from 'sql/base/browser/ui/table/interfaces'; import { IThemable } from 'vs/base/common/styler'; import { Checkbox, ICheckboxStyles } from 'sql/base/browser/ui/checkbox/checkbox'; import { Table } from 'sql/base/browser/ui/table/table'; @@ -33,18 +33,24 @@ import { Codicon } from 'vs/base/common/codicons'; import { Color } from 'vs/base/common/color'; import { LoadingSpinner } from 'sql/base/browser/ui/loadingSpinner/loadingSpinner'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { DesignerIssuesTabPanelView } from 'sql/workbench/browser/designer/designerIssuesTabPanelView'; 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 { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { listActiveSelectionBackground, listActiveSelectionForeground, listHoverBackground } from 'vs/platform/theme/common/colorRegistry'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { layoutDesignerTable, TableHeaderRowHeight, TableRowHeight } from 'sql/workbench/browser/designer/designerTableUtil'; import { Dropdown, IDropdownStyles } from 'sql/base/browser/ui/editableDropdown/browser/dropdown'; import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; +import { IAction } from 'vs/base/common/actions'; +import { InsertAfterSelectedRowAction, InsertBeforeSelectedRowAction, AddRowAction, DesignerTableActionContext, MoveRowDownAction, MoveRowUpAction, DesignerTableAction } from 'sql/workbench/browser/designer/tableActions'; +import { RowMoveManager, RowMoveOnDragEventData } from 'sql/base/browser/ui/table/plugins/rowMoveManager.plugin'; +import { ITaskbarContent, Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { RowSelectionModel } from 'sql/base/browser/ui/table/plugins/rowSelectionModel.plugin'; +import { listFocusAndSelectionBackground } from 'sql/platform/theme/common/colors'; export interface IDesignerStyle { tabbedPanelStyles?: ITabbedPanelStyles; @@ -90,12 +96,13 @@ export class Designer extends Disposable implements IThemable { private _input: DesignerComponentInput; private _tableCellEditorFactory: TableCellEditorFactory; private _propertiesPane: DesignerPropertiesPane; - private _buttons: Button[] = []; private _inputDisposable: DisposableStore; private _loadingTimeoutHandle: any; private _groupHeaders: HTMLElement[] = []; private _issuesView: DesignerIssuesTabPanelView; private _scriptEditorView: DesignerScriptEditorTabPanelView; + private _taskbars: Taskbar[] = []; + private _actionsMap: Map = new Map(); private _onStyleChangeEventEmitter = new Emitter(); constructor(private readonly _container: HTMLElement, @@ -103,7 +110,8 @@ export class Designer extends Disposable implements IThemable { @IContextViewService private readonly _contextViewProvider: IContextViewService, @INotificationService private readonly _notificationService: INotificationService, @IDialogService private readonly _dialogService: IDialogService, - @IThemeService private readonly _themeService: IThemeService) { + @IThemeService private readonly _themeService: IThemeService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService,) { super(); this._tableCellEditorFactory = new TableCellEditorFactory( { @@ -209,6 +217,7 @@ export class Designer extends Disposable implements IThemable { } else if (component instanceof TabbedPanel) { component.style(this._styles.tabbedPanelStyles); } else if (component instanceof Table) { + this.removeTableSelectionStyles(); component.style(this._styles.tableStyles); } else if (component instanceof Button) { component.style(this._styles.buttonStyles); @@ -219,6 +228,17 @@ export class Designer extends Disposable implements IThemable { } } + private removeTableSelectionStyles(): void { + this._styles.tableStyles.listActiveSelectionBackground = undefined; + this._styles.tableStyles.listActiveSelectionForeground = undefined; + this._styles.tableStyles.listFocusAndSelectionBackground = undefined; + this._styles.tableStyles.listFocusAndSelectionForeground = undefined; + this._styles.tableStyles.listInactiveFocusBackground = undefined; + this._styles.tableStyles.listInactiveFocusForeground = undefined; + this._styles.tableStyles.listInactiveSelectionBackground = undefined; + this._styles.tableStyles.listInactiveSelectionForeground = undefined; + } + private styleGroupHeader(header: HTMLElement): void { if (this._styles.groupHeaderBackground) { header.style.backgroundColor = this._styles.groupHeaderBackground.toString(); @@ -243,10 +263,6 @@ export class Designer extends Disposable implements IThemable { separatorBorder: styles.paneSeparator }); - this._buttons.forEach((button) => { - this.styleComponent(button); - }); - this._groupHeaders.forEach((header) => { this.styleGroupHeader(header); }); @@ -304,14 +320,13 @@ export class Designer extends Disposable implements IThemable { } private clearUI(): void { - this._buttons.forEach(button => button.dispose()); - this._buttons = []; this._componentMap.forEach(item => item.component.dispose()); this._componentMap.clear(); DOM.clearNode(this._topContentContainer); this._contentTabbedPanel.clearTabs(); this._propertiesPane.clear(); this._groupHeaders = []; + this._taskbars.map(t => t.dispose()); } private initializeDesigner(): void { @@ -328,6 +343,27 @@ export class Designer extends Disposable implements IThemable { this.restoreUIState(); } + private handleCellFocusAfterAddOrMove(edit: DesignerEdit): void { + if (edit.path.length === 2) { + const propertyName = edit.path[0] as string; + const index = edit.type === DesignerEditType.Add ? edit.path[1] as number : edit.value as number; + const table = this._componentMap.get(propertyName).component as Table; + const tableProperties = this._componentMap.get(propertyName).defintion.componentProperties as DesignerTableProperties; + let selectedCellIndex = tableProperties.itemProperties.findIndex(p => p.componentType === 'input'); + selectedCellIndex = tableProperties.canMoveRows ? selectedCellIndex + 1 : selectedCellIndex; + try { + table.grid.resetActiveCell(); + table.setActiveCell(index, selectedCellIndex); + table.setSelectedRows([index]); + } + catch { + // Ignore the slick grid error when setting active cell. + } + } else { + this.updatePropertiesPane(this._propertiesPane.objectPath); + } + } + private handleEditProcessedEvent(args: DesignerEditProcessedEventArgs): void { const edit = args.edit; this._supressEditProcessing = true; @@ -344,21 +380,8 @@ export class Designer extends Disposable implements IThemable { this.updateComponentValues(); this.layoutTabbedPanel(); } - if (edit.type === DesignerEditType.Add) { - // For tables in the main view, move focus to the first cell of the newly added row, and the properties pane will be showing the new object. - if (edit.path.length === 1) { - const propertyName = edit.path[0] as string; - const tableData = this._input.viewModel[propertyName] as DesignerTableProperties; - const table = this._componentMap.get(propertyName).component as Table; - try { - table.setActiveCell(tableData.data.length - 1, 0); - } - catch { - // Ignore the slick grid error when setting active cell. - } - } else { - this.updatePropertiesPane(this._propertiesPane.objectPath); - } + if (edit.type === DesignerEditType.Add || edit.type === DesignerEditType.Move) { + this.handleCellFocusAfterAddOrMove(edit); } else if (edit.type === DesignerEditType.Update) { // for edit, update the properties pane with new values of current object. this.updatePropertiesPane(this._propertiesPane.objectPath); @@ -574,7 +597,7 @@ export class Designer extends Disposable implements IThemable { } } - private handleEdit(edit: DesignerEdit): void { + public handleEdit(edit: DesignerEdit): void { if (this._supressEditProcessing) { return; } @@ -797,28 +820,7 @@ export class Designer extends Disposable implements IThemable { container.appendChild(DOM.$('.full-row')).appendChild(DOM.$('span.component-label')).innerText = componentDefinition.componentProperties?.title ?? ''; } const tableProperties = componentDefinition.componentProperties as DesignerTableProperties; - if (tableProperties.canAddRows) { - const buttonContainer = container.appendChild(DOM.$('.full-row')).appendChild(DOM.$('.add-row-button-container')); - const addNewText = tableProperties.labelForAddNewButton ?? localize('designer.newRowText', "Add New"); - const addRowButton = new Button(buttonContainer, { - title: addNewText, - secondary: true - }); - addRowButton.onDidClick(() => { - this.handleEdit({ - type: DesignerEditType.Add, - path: propertyPath, - source: view - }); - }); - this.styleComponent(addRowButton); - addRowButton.label = addNewText; - 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 taskbar = this.addTableTaskbar(container, tableProperties); const tableContainer = container.appendChild(DOM.$('.full-row')); const table = new Table(tableContainer, { dataProvider: new TableDataView() @@ -836,12 +838,55 @@ export class Designer extends Disposable implements IThemable { headerRowHeight: TableHeaderRowHeight, editorLock: new Slick.EditorLock() }); + table.grid.setSelectionModel(new RowSelectionModel()); + if (taskbar) { + taskbar.context = { table: table, path: propertyPath, source: view }; + this._actionsMap.get(taskbar).map(a => a.table = table); + } + const columns: Slick.Column[] = []; + if (tableProperties.canInsertRows || tableProperties.canMoveRows) { + // Add move context menu actions + this._register(table.onContextMenu((e) => { + this.openContextMenu(table, e, propertyPath, view, tableProperties); + })); + } + if (tableProperties.canMoveRows) { + // Add row move drag and drop + const moveRowsPlugin = new RowMoveManager({ + cancelEditOnDrag: true, + id: 'moveRow', + iconCssClass: Codicon.grabber.classNames, + title: localize('designer.moveRowText', 'Move Row'), + width: 20, + resizable: false, + isFontIcon: true, + behavior: 'selectAndMove' + }); + table.registerPlugin(moveRowsPlugin); + moveRowsPlugin.onMoveRows.subscribe((e: Slick.EventData, data: RowMoveOnDragEventData) => { + const row = data.rows[0]; + // no point in moving before or after itself + if (row === data.insertBefore || row === data.insertBefore - 1) { + e.stopPropagation(); + return; + } + this.handleEdit({ + type: DesignerEditType.Move, + path: [...propertyPath, row], + source: view, + value: data.insertBefore < row ? data.insertBefore : data.insertBefore - 1 + }); + }); + table.grid.registerPlugin(moveRowsPlugin); + columns.push(moveRowsPlugin.definition); + } table.ariaLabel = tableProperties.ariaLabel; - const columns = tableProperties.columns.map(propName => { + columns.push(...tableProperties.columns.map((propName, index) => { const propertyDefinition = tableProperties.itemProperties.find(item => item.propertyName === propName); switch (propertyDefinition.componentType) { case 'checkbox': const checkboxColumn = new CheckBoxColumn({ + id: index.toString(), field: propertyDefinition.propertyName, name: propertyDefinition.componentProperties.title, width: propertyDefinition.componentProperties.width as number @@ -859,6 +904,7 @@ export class Designer extends Disposable implements IThemable { case 'dropdown': const dropdownProperties = propertyDefinition.componentProperties as DropDownProperties; return { + id: index.toString(), name: dropdownProperties.title, field: propertyDefinition.propertyName, editor: this._tableCellEditorFactory.getDropdownEditorClass({ view: view, path: propertyPath }, dropdownProperties.values as string[], dropdownProperties.isEditable), @@ -867,13 +913,14 @@ export class Designer extends Disposable implements IThemable { default: const inputProperties = propertyDefinition.componentProperties as InputBoxProperties; return { + id: index.toString(), name: inputProperties.title, field: propertyDefinition.propertyName, editor: this._tableCellEditorFactory.getTextEditorClass({ view: view, path: propertyPath }, inputProperties.inputType), width: inputProperties.width as number }; } - }); + })); if (tableProperties.canRemoveRows) { const deleteRowColumn = new ButtonColumn({ id: 'deleteRow', @@ -908,7 +955,10 @@ export class Designer extends Disposable implements IThemable { table.grid.onBeforeEditCell.subscribe((e, data): boolean => { return data.item[data.column.field].enabled !== false; }); - + let currentTableActions = []; + if (taskbar) { + currentTableActions = this._actionsMap.get(taskbar); + } table.grid.onActiveCellChanged.subscribe((e, data) => { if (view === 'TabsView' || view === 'TopContentView') { if (data.row !== undefined) { @@ -925,12 +975,20 @@ export class Designer extends Disposable implements IThemable { this._propertiesPane.updateDescription(componentDefinition); } } + if (data.row !== undefined) { + currentTableActions.forEach(a => a.updateState(data.row)); + table.grid.setSelectedRows([data.row]); + } + }); + table.onBlur((e) => { + currentTableActions.forEach(a => a.updateState()); + table.grid.setSelectedRows([]); + table.grid.resetActiveCell(); }); - component = table; break; default: - throw new Error(localize('tableDesigner.unknownComponentType', "The component type: {0} is not supported", componentDefinition.componentType)); + throw new Error(localize('designer.unknownComponentType', "The component type: {0} is not supported", componentDefinition.componentType)); } componentMap.set(componentDefinition.propertyName, { defintion: componentDefinition, @@ -941,6 +999,78 @@ export class Designer extends Disposable implements IThemable { return component; } + private addTableTaskbar(container: HTMLElement, tableProperties: DesignerTableProperties): Taskbar | undefined { + if (tableProperties.canAddRows || tableProperties.canMoveRows) { + const taskbarContainer = container.appendChild(DOM.$('.full-row')).appendChild(DOM.$('.add-row-button-container')); + const taskbar = new Taskbar(taskbarContainer); + const actions = []; + if (tableProperties.canAddRows) { + const addRowAction = this._instantiationService.createInstance(AddRowAction, this, tableProperties); + actions.push(addRowAction); + } + if (tableProperties.canMoveRows) { + const moveUpAction = this._instantiationService.createInstance(MoveRowUpAction, this); + const moveDownAction = this._instantiationService.createInstance(MoveRowDownAction, this); + actions.push(moveUpAction); + actions.push(moveDownAction); + } + const taskbarContent: ITaskbarContent[] = actions.map((a) => { return { action: a }; }); + taskbar.setContent(taskbarContent); + this._actionsMap.set(taskbar, actions); + return taskbar; + } + return undefined; + } + + private openContextMenu( + table: Table, + event: ITableMouseEvent, + propertyPath: DesignerPropertyPath, + view: DesignerUIArea, + tableProperties: DesignerTableProperties + ): void { + const rowIndex = event.cell.row; + const tableActionContext: DesignerTableActionContext = { + table: table, + path: propertyPath, + source: view, + selectedRow: rowIndex + }; + const data = table.grid.getData() as Slick.DataProvider; + if (!data || rowIndex >= data.getLength()) { + return undefined; + } + const actions = this.getTableActions(tableProperties); + actions.forEach(a => { + if (a instanceof DesignerTableAction) { + a.table = table; + a.updateState(rowIndex); + } + }); + this._contextMenuService.showContextMenu({ + getAnchor: () => event.anchor, + getActions: () => actions, + getActionsContext: () => (tableActionContext) + }); + } + + private getTableActions(tableProperties: DesignerTableProperties): IAction[] { + const actions: IAction[] = []; + if (tableProperties.canInsertRows) { + const insertRowBefore = this._instantiationService.createInstance(InsertBeforeSelectedRowAction, this); + const insertRowAfter = this._instantiationService.createInstance(InsertAfterSelectedRowAction, this); + actions.push(insertRowBefore); + actions.push(insertRowAfter); + } + if (tableProperties.canMoveRows) { + const moveRowUp = this._instantiationService.createInstance(MoveRowUpAction, this); + const moveRowDown = this._instantiationService.createInstance(MoveRowDownAction, this); + actions.push(moveRowUp); + actions.push(moveRowDown); + } + return actions; + } + private startLoading(message: string, timeout: number): void { this._loadingTimeoutHandle = setTimeout(() => { this._loadingSpinner.loadingMessage = message; @@ -979,3 +1109,27 @@ export class Designer extends Disposable implements IThemable { } } } + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const listHoverBackgroundColor = theme.getColor(listHoverBackground); + const listActiveSelectionBackgroundColor = theme.getColor(listActiveSelectionBackground); + const listFocusSelectionBackgroundColor = theme.getColor(listFocusAndSelectionBackground); + if (listHoverBackgroundColor) { + collector.addRule(` + .designer-component .slick-cell.isDragging { + background-color: ${listHoverBackgroundColor}; + } + .designer-component .slick-reorder-proxy { + background: ${listActiveSelectionBackgroundColor}; + opacity: 0.5; + } + .vs-dark .designer-component .slick-reorder-proxy { + opacity: 0.3; + } + .designer-component .slick-reorder-guide { + background: ${listFocusSelectionBackgroundColor}; + opacity: 1; + } + `); + } +}); diff --git a/src/sql/workbench/browser/designer/interfaces.ts b/src/sql/workbench/browser/designer/interfaces.ts index eb9099e79e..20a2a8207e 100644 --- a/src/sql/workbench/browser/designer/interfaces.ts +++ b/src/sql/workbench/browser/designer/interfaces.ts @@ -187,6 +187,14 @@ export interface DesignerTableProperties extends ComponentProperties { * Whether user can remove rows from the table. The default value is true. */ canRemoveRows?: boolean; + /** + * Whether user can move rows from one index to another. The default value is false. + */ + canMoveRows?: boolean; + /** + * Whether user can insert rows at a given index to the table. The default value is false. + */ + canInsertRows?: boolean; /** * Whether to show confirmation when user removes a row. The default value is false. */ @@ -214,7 +222,8 @@ export interface DesignerTableComponentRowData { export enum DesignerEditType { Add = 0, Remove = 1, - Update = 2 + Update = 2, + Move = 3 } export interface DesignerEdit { diff --git a/src/sql/workbench/browser/designer/media/designer.css b/src/sql/workbench/browser/designer/media/designer.css index 80e95f3d4d..0d2f585af6 100644 --- a/src/sql/workbench/browser/designer/media/designer.css +++ b/src/sql/workbench/browser/designer/media/designer.css @@ -114,10 +114,13 @@ width: fit-content; background-repeat: no-repeat; background-size: 13px; - padding-left: 17px; background-position: 2px center; } +.designer-component .add-row-button-container .actions-container { + padding-left: 0px !important; +} + .designer-component .top-content-container .components-grid { padding-bottom: 10px; } @@ -152,3 +155,7 @@ padding-top: 10px; padding-left: 25px; } + +.designer-component .codicon-grabber { + cursor: move; +} diff --git a/src/sql/workbench/browser/designer/tableActions.ts b/src/sql/workbench/browser/designer/tableActions.ts new file mode 100644 index 0000000000..5891c4fbfd --- /dev/null +++ b/src/sql/workbench/browser/designer/tableActions.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { Designer } from 'sql/workbench/browser/designer/designer'; +import { DesignerEditType, DesignerPropertyPath, DesignerTableProperties, DesignerUIArea } from 'sql/workbench/browser/designer/interfaces'; +import { Action } from 'vs/base/common/actions'; +import { localize } from 'vs/nls'; + +export interface DesignerTableActionContext { + table: Table; + path: DesignerPropertyPath; + source: DesignerUIArea; + selectedRow?: number; +} + +export class DesignerTableAction extends Action { + + protected _table: Table; + + constructor( + id: string, + label: string, + icon: string, + protected needsRowSelection: boolean + ) { + super(id, label, icon); + } + + + public set table(table: Table) { + this._table = table; + } + + public updateState(row?: number) { + if (row === undefined) { + if (!this.needsRowSelection) { + this.enabled = true; + } else { + this.enabled = false; + } + } + } +} + +export class AddRowAction extends DesignerTableAction { + public static ID = 'designer.addRowAction'; + public static ICON = 'add-row-button new codicon'; + public static LABEL = localize('designer.addColumnAction', 'Add New'); + + constructor( + private designer: Designer, + tableProperties: DesignerTableProperties, + ) { + super(AddRowAction.ID, tableProperties.labelForAddNewButton || AddRowAction.LABEL, AddRowAction.ICON, false); + this.designer = designer; + this._tooltip = localize('designer.newRowButtonAriaLabel', "Add new row to '{0}' table", tableProperties.ariaLabel); + } + + public override async run(context: DesignerTableActionContext): Promise { + const lastIndex = context.table.getData().getItems().length; + return new Promise((resolve) => { + this.designer.handleEdit({ + type: DesignerEditType.Add, + path: [...context.path, lastIndex], + source: context.source, + }); + resolve(); + }); + } +} + +export class MoveRowUpAction extends DesignerTableAction { + public static ID = 'designer.moveRowUpAction'; + public static ICON = 'move-row-up-button arrow-up codicon'; + public static LABEL = localize('designer.moveRowUpAction', 'Move Up'); + + constructor(private designer: Designer) { + super(MoveRowUpAction.ID, MoveRowUpAction.LABEL, MoveRowUpAction.ICON, true); + this.designer = designer; + this._tooltip = localize('designer.moveRowUpButtonAriaLabel', "Move selected row up one position"); + this.enabled = false; + } + + public override async run(context: DesignerTableActionContext): Promise { + let rowIndex = context.selectedRow ?? context.table.getSelectedRows()[0]; + if (rowIndex - 1 < 0) { + return; + } + return new Promise((resolve) => { + this.designer.handleEdit({ + type: DesignerEditType.Move, + path: [...context.path, rowIndex], + source: context.source, + value: rowIndex - 1 + }); + resolve(); + }); + } + + public override updateState(row?: number): void { + if (row === 0) { + this.enabled = false; + } else { + this.enabled = true; + } + super.updateState(row); + } +} + +export class MoveRowDownAction extends DesignerTableAction { + public static ID = 'designer.moveRowDownAction'; + public static ICON = 'move-row-down-button arrow-down codicon'; + public static LABEL = localize('designer.moveRowDownAction', 'Move Down'); + + constructor(private designer: Designer) { + super(MoveRowDownAction.ID, MoveRowDownAction.LABEL, MoveRowDownAction.ICON, true); + this.designer = designer; + this._tooltip = localize('designer.moveRowDownButtonAriaLabel', "Move selected row down one position"); + this.enabled = false; + } + + public override async run(context: DesignerTableActionContext): Promise { + let rowIndex = context.selectedRow ?? context.table.getSelectedRows()[0]; + const tableData = context.table.getData().getItems(); + if (rowIndex + 1 >= tableData.length) { + return; + } + return new Promise((resolve) => { + this.designer.handleEdit({ + type: DesignerEditType.Move, + path: [...context.path, rowIndex], + source: context.source, + value: rowIndex + 1 + }); + resolve(); + }); + } + + public override updateState(row?: number): void { + super.updateState(row); + if (row === this._table.getData().getLength() - 1) { + this.enabled = false; + } else { + this.enabled = true; + } + super.updateState(row); + } +} + +export class InsertBeforeSelectedRowAction extends Action { + public static ID = 'designer.insertBeforeSelectedRow'; + public static LABEL = localize('designer.insertBeforeSelectedRow', 'Insert before'); + + constructor(private designer: Designer) { + super(InsertBeforeSelectedRowAction.ID, InsertBeforeSelectedRowAction.LABEL, 'insertBeforeSelectedRow', true); + this.designer = designer; + } + + public override async run(context: DesignerTableActionContext): Promise { + const rowIndex = context.selectedRow; + return new Promise((resolve) => { + this.designer.handleEdit({ + type: DesignerEditType.Add, + path: [...context.path, rowIndex], + source: context.source + }); + resolve(); + }); + } +} + +export class InsertAfterSelectedRowAction extends Action { + public static ID = 'designer.insertAfterSelectedColumn'; + public static LABEL = localize('designer.insertAfterSelectedColumn', 'Insert after'); + + constructor(private designer: Designer) { + super(InsertAfterSelectedRowAction.ID, InsertAfterSelectedRowAction.LABEL, 'insertAfterSelectedColumn', true); + } + + public override async run(context: DesignerTableActionContext): Promise { + const rowIndex = context.selectedRow; + return new Promise((resolve) => { + this.designer.handleEdit({ + type: DesignerEditType.Add, + path: [...context.path, rowIndex + 1], + source: context.source + }); + resolve(); + }); + } +} diff --git a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts index 119502471c..ee33a4fb72 100644 --- a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts +++ b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts @@ -442,6 +442,8 @@ export class TableDesignerComponentInput implements DesignerComponentInput { itemProperties: this.addAdditionalTableProperties(options, columnProperties), objectTypeDisplayName: localize('tableDesigner.columnTypeName', "Column"), canAddRows: options.canAddRows, + canInsertRows: options.canInsertRows, + canMoveRows: options.canMoveRows, canRemoveRows: options.canRemoveRows, removeRowConfirmationMessage: options.removeRowConfirmationMessage, showRemoveRowConfirmation: options.showRemoveRowConfirmation,