diff --git a/src/sql/base/browser/ui/table/media/down-inverse.svg b/src/sql/base/browser/ui/table/media/down-inverse.svg new file mode 100644 index 0000000000..f914c8bff1 --- /dev/null +++ b/src/sql/base/browser/ui/table/media/down-inverse.svg @@ -0,0 +1 @@ +CollapseChevronDown_md_16x \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/media/down.svg b/src/sql/base/browser/ui/table/media/down.svg new file mode 100644 index 0000000000..dc21a6633d --- /dev/null +++ b/src/sql/base/browser/ui/table/media/down.svg @@ -0,0 +1 @@ +CollapseChevronDown_md_16x \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/media/filter.svg b/src/sql/base/browser/ui/table/media/filter.svg new file mode 100644 index 0000000000..32f914f54a --- /dev/null +++ b/src/sql/base/browser/ui/table/media/filter.svg @@ -0,0 +1 @@ +filter_16x16 \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/media/filter_inverse.svg b/src/sql/base/browser/ui/table/media/filter_inverse.svg new file mode 100644 index 0000000000..60b90abd6d --- /dev/null +++ b/src/sql/base/browser/ui/table/media/filter_inverse.svg @@ -0,0 +1 @@ +filter_inverse_16x16 \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/media/table.css b/src/sql/base/browser/ui/table/media/table.css index 99c7eeeba1..c010fd47ef 100644 --- a/src/sql/base/browser/ui/table/media/table.css +++ b/src/sql/base/browser/ui/table/media/table.css @@ -31,4 +31,117 @@ height: 5px; margin-left: 4px; margin-top: 6px; +} + +.slick-header-menubutton { + background-position: center center; + background-repeat: no-repeat; + bottom: 0; + cursor: pointer; + display: inline-block; + position: absolute; + right: 10px; + top: 0; + width: 18px; + background-image: url('down.svg'); +} + +.vs-dark .slick-header-menubutton { + background-image: url('down-inverse.svg'); +} + +.slick-header-menubutton.filtered { + background-image: url('filter.svg'); +} + +.vs-dark .slick-header-menubutton.filtered { + background-image: url('filter_inverse.svg'); +} + +.slick-header-menu { + background: none repeat scroll 0 0 white; + border: 1px solid #BFBDBD; + min-width: 175px; + padding: 4px; + z-index: 100000; + cursor: default; + display: inline-block; + margin: 0; + position: absolute; +} + +.vs-dark .slick-header-menu { + background: none repeat scroll 0 0 #333333; +} + +.slick-header-menu a.monaco-button.monaco-text-button { + width: 60px; + margin: 6px 6px 6px 6px; + padding: 4px; +} + +.slick-header-menu .filter +{ + border: 1px solid #BFBDBD; + font-size: 8pt; + height: 400px; + margin-top: 6px; + overflow: scroll; + padding: 4px; + white-space: nowrap; + width: 200px; +} + +label { + display: block; + margin-bottom: 5px; +} + +.slick-header-menuitem +{ + border: 1px solid transparent; + padding: 2px 4px; + cursor: pointer; + list-style: none outside none; + margin: 0; +} + +.slick-header-menuicon +{ + display: inline-block; + height: 16px; + margin-right: 4px; + vertical-align: middle; + width: 16px; +} + +.slick-header-menuicon.ascending { + background: url('sort-asc.gif'); + background-position: center center; + background-repeat: no-repeat; +} + +.slick-header-menuicon.descending { + background: url('sort-desc.gif'); + background-position: center center; + background-repeat: no-repeat; +} + +.slick-header-menucontent { + display: inline-block; + vertical-align: middle; +} + +.slick-header-menuitem:hover { + border-color: #BFBDBD; +} + +.header-overlay, .cell-overlay, .selection-cell-overlay { + display: block; + position: absolute; + z-index: 999; +} + +.vs-dark .slick-header-menu > input.input { + color: #4a4a4a; } \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts new file mode 100644 index 0000000000..720fc4d5bb --- /dev/null +++ b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts @@ -0,0 +1,353 @@ +// Adopted and converted to typescript from https://github.com/danny-sg/slickgrid-spreadsheet-plugins/blob/master/ext.headerfilter.js +// heavily modified +import 'vs/css!sql/base/browser/ui/table/media/table'; + +import { mixin } from 'vs/base/common/objects'; +import { SlickGrid } from 'angular2-slickgrid'; +import { Button } from '../../button/button'; +import { attachButtonStyler } from 'sql/common/theme/styler'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; + +export class HeaderFilter { + + public onFilterApplied = new Slick.Event(); + public onCommand = new Slick.Event(); + + private grid; + private handler = new Slick.EventHandler(); + private defaults = { + filterImage: 'src/sql/media/icons/filter.svg', + sortAscImage: 'sort-asc.gif', + sortDescImage: 'sort-desc.gif' + }; + + private $menu; + private options: any; + private okButton: Button; + private clearButton: Button; + private cancelButton: Button; + private workingFilters: any; + private columnDef: any; + + constructor(options: any, private _themeService: IThemeService) { + this.options = mixin(options, this.defaults, false); + } + + public init(grid: Slick.Grid): void { + this.grid = grid; + this.handler.subscribe(this.grid.onHeaderCellRendered, (e, args) => this.handleHeaderCellRendered(e , args)) + .subscribe(this.grid.onBeforeHeaderCellDestroy, (e, args) => this.handleBeforeHeaderCellDestroy(e, args)) + .subscribe(this.grid.onClick, (e) => this.handleBodyMouseDown) + .subscribe(this.grid.onColumnsResized, () => this.columnsResized()); + + this.grid.setColumns(this.grid.getColumns()); + + $(document.body).bind('mousedown', this.handleBodyMouseDown); + } + + public destroy() { + this.handler.unsubscribeAll(); + $(document.body).unbind('mousedown', this.handleBodyMouseDown); + } + + private handleBodyMouseDown = (e) => { + if (this.$menu && this.$menu[0] !== e.target && !$.contains(this.$menu[0], e.target)) { + this.hideMenu(); + e.preventDefault(); + e.stopPropagation(); + } + } + + private hideMenu() { + if (this.$menu) { + this.$menu.remove(); + this.$menu = null; + } + } + + private handleHeaderCellRendered(e, args) { + let column = args.column; + if (column.id === '_detail_selector') { + return; + } + let $el = $('
') + .addClass('slick-header-menubutton') + .data('column', column); + + $el.bind('click', (e) => this.showFilter(e)).appendTo(args.node); + } + + private handleBeforeHeaderCellDestroy(e, args) { + $(args.node) + .find('.slick-header-menubutton') + .remove(); + } + + private addMenuItem(menu, columnDef, title, command, image) { + let $item = $('
') + .data('command', command) + .data('column', columnDef) + .bind('click', (e) => this.handleMenuItemClick(e, command, columnDef)) + .appendTo(menu); + + let $icon = $('
') + .appendTo($item); + + if (title === 'Sort Ascending') { + $icon.get(0).className += ' ascending'; + } else if (title === 'Sort Descending') { + $icon.get(0).className += ' descending'; + } + + $('') + .text(title) + .appendTo($item); + } + + private addMenuInput(menu, columnDef) { + const self = this; + $('') + .data('column', columnDef) + .bind('keyup', (e) => { + let filterVals = this.getFilterValuesByInput($(e.target)); + self.updateFilterInputs(menu, columnDef, filterVals); + }) + .appendTo(menu); + } + + private updateFilterInputs(menu, columnDef, filterItems) { + let filterOptions = ''; + columnDef.filterValues = columnDef.filterValues || []; + + // WorkingFilters is a copy of the filters to enable apply/cancel behaviour + this.workingFilters = columnDef.filterValues.slice(0); + + for (let i = 0; i < filterItems.length; i++) { + let filtered = _.contains(this.workingFilters, filterItems[i]); + + filterOptions += ''; + } + let $filter = menu.find('.filter'); + $filter.empty().append($(filterOptions)); + + $(':checkbox', $filter).bind('click', (e) => { + this.workingFilters = this.changeWorkingFilter(filterItems, this.workingFilters, $(e.target)); + }); + } + + private showFilter(e) { + let $menuButton = $(e.target); + this.columnDef = $menuButton.data('column'); + + this.columnDef.filterValues = this.columnDef.filterValues || []; + + // WorkingFilters is a copy of the filters to enable apply/cancel behaviour + this.workingFilters = this.columnDef.filterValues.slice(0); + + let filterItems; + + if (this.workingFilters.length === 0) { + // Filter based all available values + filterItems = this.getFilterValues(this.grid.getData(), this.columnDef); + } + else { + // Filter based on current dataView subset + filterItems = this.getAllFilterValues(this.grid.getData().getItems(), this.columnDef); + } + + if (!this.$menu) { + this.$menu = $('
').appendTo(document.body); + } + + this.$menu.empty(); + + this.addMenuItem(this.$menu, this.columnDef, 'Sort Ascending', 'sort-asc', this.options.sortAscImage); + this.addMenuItem(this.$menu, this.columnDef, 'Sort Descending', 'sort-desc', this.options.sortDescImage); + this.addMenuInput(this.$menu, this.columnDef); + + let filterOptions = ''; + + for (let i = 0; i < filterItems.length; i++) { + let filtered = _.contains(this.workingFilters, filterItems[i]); + if (filterItems[i] && filterItems[i].indexOf('Error:') < 0) { + filterOptions += ''; + } + } + let $filter = $('
') + .append($(filterOptions)) + .appendTo(this.$menu); + + this.okButton = new Button(this.$menu.get(0)); + this.okButton.label = 'OK'; + this.okButton.title = 'OK'; + this.okButton.element.id = 'filter-ok-button'; + let okElement = $('#filter-ok-button'); + okElement.bind('click', (ev) => { + this.columnDef.filterValues = this.workingFilters.splice(0); + this.setButtonImage($menuButton, this.columnDef.filterValues.length > 0); + this.handleApply(ev, this.columnDef); + }); + + this.clearButton = new Button(this.$menu.get(0)); + this.clearButton.label = 'Clear'; + this.clearButton.title = 'Clear'; + this.clearButton.element.id = 'filter-clear-button'; + let clearElement = $('#filter-clear-button'); + clearElement.bind('click', (ev) => { + this.columnDef.filterValues.length = 0; + this.setButtonImage($menuButton, false); + this.handleApply(ev, this.columnDef); + }); + + this.cancelButton = new Button(this.$menu.get(0)); + this.cancelButton.label = 'Cancel'; + this.cancelButton.title = 'Cancel'; + this.cancelButton.element.id = 'filter-cancel-button'; + let cancelElement = $('#filter-cancel-button'); + cancelElement.bind('click', () => this.hideMenu()); + attachButtonStyler(this.okButton, this._themeService); + attachButtonStyler(this.clearButton, this._themeService); + attachButtonStyler(this.cancelButton, this._themeService); + + $(':checkbox', $filter).bind('click', (e) => { + this.workingFilters = this.changeWorkingFilter(filterItems, this.workingFilters, $(e.target)); + }); + + let offset = $(e.target).offset(); + let left = offset.left - this.$menu.width() + $(e.target).width() - 8; + + let menutop = offset.top + $(e.target).height(); + + if (menutop + offset.top > $(window).height()) { + menutop -= (this.$menu.height() + $(e.target).height() + 8); + } + this.$menu.css('top', menutop) + .css('left', (left > 0 ? left : 0)); + } + + private columnsResized() { + this.hideMenu(); + } + + private changeWorkingFilter(filterItems, workingFilters, $checkbox) { + let value = $checkbox.val(); + let $filter = $checkbox.parent().parent(); + + if ($checkbox.val() < 0) { + // Select All + if ($checkbox.prop('checked')) { + $(':checkbox', $filter).prop('checked', true); + workingFilters = filterItems.slice(0); + } else { + $(':checkbox', $filter).prop('checked', false); + workingFilters.length = 0; + } + } else { + let index = _.indexOf(workingFilters, filterItems[value]); + + if ($checkbox.prop('checked') && index < 0) { + workingFilters.push(filterItems[value]); + let nextRow = filterItems[(parseInt(value)+1).toString()]; + if (nextRow && nextRow.indexOf('Error:') >= 0) { + workingFilters.push(nextRow); + } + } + else { + if (index > -1) { + workingFilters.splice(index, 1); + } + } + } + + return workingFilters; + } + + private setButtonImage($el, filtered) { + let element: HTMLElement = $el.get(0); + if (filtered) { + element.className += ' filtered'; + } else { + let classList = element.classList; + if (classList.contains('filtered')) { + classList.remove('filtered'); + } + } + } + + private handleApply(e, columnDef) { + this.hideMenu(); + + this.onFilterApplied.notify({ 'grid': this.grid, 'column': columnDef }, e, self); + e.preventDefault(); + e.stopPropagation(); + } + + private getFilterValues(dataView, column) { + let seen = []; + for (let i = 0; i < dataView.getLength() ; i++) { + let value = dataView.getItem(i)[column.field]; + + if (!_.contains(seen, value)) { + seen.push(value); + } + } + return seen; + } + + private getFilterValuesByInput($input) { + let column = $input.data('column'), + filter = $input.val(), + dataView = this.grid.getData(), + seen = []; + + for (let i = 0; i < dataView.getLength() ; i++) { + let value = dataView.getItem(i)[column.field]; + + if (filter.length > 0) { + let itemValue = !value ? '' : value; + let lowercaseFilter = filter.toString().toLowerCase(); + let lowercaseVal = itemValue.toString().toLowerCase(); + if (!_.contains(seen, value) && lowercaseVal.indexOf(lowercaseFilter) > -1) { + seen.push(value); + } + } + else { + if (!_.contains(seen, value)) { + seen.push(value); + } + } + } + + return _.sortBy(seen, (v) => { return v; }); + } + + private getAllFilterValues(data, column) { + let seen = []; + for (let i = 0; i < data.length; i++) { + let value = data[i][column.field]; + + if (!_.contains(seen, value)) { + seen.push(value); + } + } + + return _.sortBy(seen, (v) => { return v; }); + } + + private handleMenuItemClick(e, command, columnDef) { + this.hideMenu(); + + this.onCommand.notify({ + 'grid': this.grid, + 'column': columnDef, + 'command': command + }, e, self); + + e.preventDefault(); + e.stopPropagation(); + } +} \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/plugins/rowDetailView.ts b/src/sql/base/browser/ui/table/plugins/rowDetailView.ts index 28bbde8abf..edac76f979 100644 --- a/src/sql/base/browser/ui/table/plugins/rowDetailView.ts +++ b/src/sql/base/browser/ui/table/plugins/rowDetailView.ts @@ -277,8 +277,8 @@ export class RowDetailView { item._isPadding = false; item._parent = parent; item._offset = offset; - item.jobId = parent.jobId; item.name = parent.message ? parent.message : nls.localize('rowDetailView.loadError','Loading Error...'); + parent._child = item; return item; } diff --git a/src/sql/base/browser/ui/table/tableDataView.ts b/src/sql/base/browser/ui/table/tableDataView.ts index 25682b6e1c..4f8df397fe 100644 --- a/src/sql/base/browser/ui/table/tableDataView.ts +++ b/src/sql/base/browser/ui/table/tableDataView.ts @@ -14,7 +14,7 @@ export interface IFindPosition { row: number; } -function defaultSort(args: Slick.OnSortEventArgs, data: Array): Array { +export function defaultSort(args: Slick.OnSortEventArgs, data: Array): Array { let field = args.sortCol.field; let sign = args.sortAsc ? 1 : -1; return data.sort((a, b) => (a[field] === b[field] ? 0 : (a[field] > b[field] ? 1 : -1)) * sign); diff --git a/src/sql/parts/jobManagement/common/agentJobUtilities.ts b/src/sql/parts/jobManagement/common/agentJobUtilities.ts index dd0de32533..37d8d28423 100644 --- a/src/sql/parts/jobManagement/common/agentJobUtilities.ts +++ b/src/sql/parts/jobManagement/common/agentJobUtilities.ts @@ -92,4 +92,52 @@ export class AgentJobUtilities { return; } } + + public static convertColFieldToName(colField: string) { + switch(colField) { + case('name'): + return 'Name'; + case('lastRun'): + return 'Last Run'; + case('nextRun'): + return 'Next Run'; + case('enabled'): + return 'Enabled'; + case('status'): + return 'Status'; + case('category'): + return 'Category'; + case('runnable'): + return 'Runnable'; + case('schedule'): + return 'Schedule'; + case('lastRunOutcome'): + return 'Last Run Outcome'; + } + return ''; + } + + public static convertColNameToField(columnName: string) { + switch(columnName) { + case('Name'): + return 'name'; + case('Last Run'): + return 'lastRun'; + case('Next Run'): + return 'nextRun'; + case('Enabled'): + return 'enabled'; + case('Status'): + return 'status'; + case('Category'): + return 'category'; + case('Runnable'): + return 'runnable'; + case('Schedule'): + return 'schedule'; + case('Last Run Outcome'): + return 'lastRunOutcome'; + } + return ''; + } } \ No newline at end of file diff --git a/src/sql/parts/jobManagement/common/jobManagementService.ts b/src/sql/parts/jobManagement/common/jobManagementService.ts index 9052de6ae8..622dd9b3ed 100644 --- a/src/sql/parts/jobManagement/common/jobManagementService.ts +++ b/src/sql/parts/jobManagement/common/jobManagementService.ts @@ -82,6 +82,7 @@ export class JobCacheObject { private _jobHistories: { [jobId: string]: sqlops.AgentJobHistoryInfo[]; } = {}; private _prevJobID: string; private _serverName: string; + private _dataView: Slick.Data.DataView; /* Getters */ public get jobs(): sqlops.AgentJobInfo[] { @@ -104,6 +105,10 @@ export class JobCacheObject { return this._serverName; } + public get dataView(): Slick.Data.DataView { + return this._dataView; + } + /* Setters */ public set jobs(value: sqlops.AgentJobInfo[]) { this._jobs = value; @@ -125,4 +130,7 @@ export class JobCacheObject { this._serverName = value; } -} + public set dataView(value: Slick.Data.DataView) { + this._dataView = value; + } +} \ No newline at end of file diff --git a/src/sql/parts/jobManagement/views/jobHistory.component.ts b/src/sql/parts/jobManagement/views/jobHistory.component.ts index 882dfa7968..5870fdf74f 100644 --- a/src/sql/parts/jobManagement/views/jobHistory.component.ts +++ b/src/sql/parts/jobManagement/views/jobHistory.component.ts @@ -301,7 +301,4 @@ export class JobHistoryComponent extends Disposable implements OnInit { this._showSteps = value; this._cd.detectChanges(); } - - } - diff --git a/src/sql/parts/jobManagement/views/jobsView.component.ts b/src/sql/parts/jobManagement/views/jobsView.component.ts index d5af28075e..9163981f0f 100644 --- a/src/sql/parts/jobManagement/views/jobsView.component.ts +++ b/src/sql/parts/jobManagement/views/jobsView.component.ts @@ -10,6 +10,7 @@ import 'vs/css!sql/parts/grid/media/slick.grid'; import 'vs/css!sql/parts/grid/media/slickGrid'; import 'vs/css!../common/media/jobs'; import 'vs/css!sql/media/icons/common-icons'; +import 'vs/css!sql/base/browser/ui/table/media/table'; import { Component, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, AfterContentChecked } from '@angular/core'; @@ -17,18 +18,27 @@ import * as sqlops from 'sqlops'; import * as vscode from 'vscode'; import * as nls from 'vs/nls'; -import * as dom from 'vs/base/browser/dom'; +import { IGridDataSet } from 'sql/parts/grid/common/interfaces'; import { Table } from 'sql/base/browser/ui/table/table'; -import { AgentViewComponent } from '../agent/agentView.component'; +import { attachTableStyler } from 'sql/common/theme/styler'; +import { JobHistoryComponent } from 'src/sql/parts/jobManagement/views/jobHistory.component'; +import { AgentViewComponent } from 'sql/parts/jobManagement/agent/agentView.component'; import { RowDetailView } from 'sql/base/browser/ui/table/plugins/rowdetailview'; import { JobCacheObject } from 'sql/parts/jobManagement/common/jobManagementService'; -import { AgentJobUtilities } from '../common/agentJobUtilities'; +import { AgentJobUtilities } from 'sql/parts/jobManagement/common/agentJobUtilities'; +import { HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin'; +import { BaseFocusDirectionTerminalAction } from 'vs/workbench/parts/terminal/electron-browser/terminalActions'; +import * as Utils from 'sql/parts/connection/common/utils'; +import * as dom from 'vs/base/browser/dom'; import { IJobManagementService } from '../common/interfaces'; import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; +import { DashboardPage } from 'sql/parts/dashboard/common/dashboardPage.component'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TabChild } from 'sql/base/browser/ui/panel/tab.component'; + export const JOBSVIEW_SELECTOR: string = 'jobsview-component'; @Component({ @@ -44,19 +54,28 @@ export class JobsViewComponent implements AfterContentChecked { private _disposables = new Array(); private columns: Array> = [ - { name: nls.localize('jobColumns.name', 'Name'), field: 'name', formatter: this.renderName, width: 200, id: 'name' }, - { name: nls.localize('jobColumns.lastRun', 'Last Run'), field: 'lastRun', width: 150, id: 'lastRun' }, - { name: nls.localize('jobColumns.nextRun', 'Next Run'), field: 'nextRun', width: 150, id: 'nextRun' }, - { name: nls.localize('jobColumns.enabled', 'Enabled'), field: 'enabled', width: 70, id: 'enabled' }, - { name: nls.localize('jobColumns.status', 'Status'), field: 'currentExecutionStatus', width: 60, id: 'currentExecutionStatus' }, - { name: nls.localize('jobColumns.category', 'Category'), field: 'category', width: 150, id: 'category' }, - { name: nls.localize('jobColumns.runnable', 'Runnable'), field: 'runnable', width: 50, id: 'runnable' }, - { name: nls.localize('jobColumns.schedule', 'Schedule'), field: 'hasSchedule', width: 50, id: 'hasSchedule' }, - { name: nls.localize('jobColumns.lastRunOutcome', 'Last Run Outcome'), field: 'lastRunOutcome', width: 150, id: 'lastRunOutcome' }, + { name: nls.localize('jobColumns.name','Name'), field: 'name', formatter: (row, cell, value, columnDef, dataContext) => this.renderName(row, cell, value, columnDef, dataContext), width: 200 , id: 'name' }, + { name: nls.localize('jobColumns.lastRun','Last Run'), field: 'lastRun', minWidth: 150, id: 'lastRun' }, + { name: nls.localize('jobColumns.nextRun','Next Run'), field: 'nextRun', minWidth: 150, id: 'nextRun' }, + { name: nls.localize('jobColumns.enabled','Enabled'), field: 'enabled', minWidth: 70, id: 'enabled' }, + { name: nls.localize('jobColumns.status','Status'), field: 'currentExecutionStatus', minWidth: 60, id: 'currentExecutionStatus' }, + { name: nls.localize('jobColumns.category','Category'), field: 'category', minWidth: 150, id: 'category' }, + { name: nls.localize('jobColumns.runnable','Runnable'), field: 'runnable', minWidth: 50, id: 'runnable' }, + { name: nls.localize('jobColumns.schedule','Schedule'), field: 'hasSchedule', minWidth: 50, id: 'hasSchedule' }, + { name: nls.localize('jobColumns.lastRunOutcome', 'Last Run Outcome'), field: 'lastRunOutcome', minWidth: 150, id: 'lastRunOutcome'}, ]; + private options: Slick.GridOptions = { + syncColumnCellResize: true, + enableColumnReorder: false, + rowHeight: 45, + enableCellNavigation: true, + editable: true + }; + private rowDetail: RowDetailView; - private dataView: Slick.Data.DataView; + private filterPlugin: any; + private dataView: any; @ViewChild('jobsgrid') _gridEl: ElementRef; private isVisible: boolean = false; @@ -68,13 +87,18 @@ export class JobsViewComponent implements AfterContentChecked { private _isCloud: boolean; private _showProgressWheel: boolean; private _tabHeight: number; + private filterStylingMap: { [columnName: string]: [any] ;} = {}; + private filterStack = ['start']; + private filterValueMap: { [columnName: string]: string[] ;} = {}; + private sortingStylingMap: { [columnName: string]: any; } = {}; constructor( @Inject(forwardRef(() => CommonServiceInterface)) private _dashboardService: CommonServiceInterface, @Inject(forwardRef(() => ChangeDetectorRef)) private _cd: ChangeDetectorRef, @Inject(forwardRef(() => ElementRef)) private _el: ElementRef, @Inject(forwardRef(() => AgentViewComponent)) private _agentViewComponent: AgentViewComponent, - @Inject(IJobManagementService) private _jobManagementService: IJobManagementService + @Inject(IJobManagementService) private _jobManagementService: IJobManagementService, + @Inject(IThemeService) private _themeService: IThemeService ) { let jobCacheObjectMap = this._jobManagementService.jobCacheObjectMap; this._serverName = _dashboardService.connectionManagementService.connectionInfo.connectionProfile.serverName; @@ -126,16 +150,8 @@ export class JobsViewComponent implements AfterContentChecked { column.rerenderOnResize = true; return column; }); - let options = >{ - syncColumnCellResize: true, - enableColumnReorder: false, - rowHeight: 45, - enableCellNavigation: true, - forceFitColumns: true - }; - - this.dataView = new Slick.Data.DataView({ inlineFilters: false }); - + // create the table + this.dataView = new Slick.Data.DataView(); let rowDetail = new RowDetailView({ cssClass: '_detail_selector', process: (job) => { @@ -147,9 +163,13 @@ export class JobsViewComponent implements AfterContentChecked { panelRows: 1 }); this.rowDetail = rowDetail; - columns.unshift(this.rowDetail.getColumnDefinition()); - this._table = new Table(this._gridEl.nativeElement, undefined, columns, options); + let filterPlugin = new HeaderFilter({}, this._themeService); + this.filterPlugin = filterPlugin; + this._table = new Table(this._gridEl.nativeElement, undefined, columns, this.options); + + + this._table.grid.setData(this.dataView, true); this._table.grid.onClick.subscribe((e, args) => { let job = self.getJob(args); @@ -158,7 +178,7 @@ export class JobsViewComponent implements AfterContentChecked { self._agentViewComponent.showHistory = true; }); if (cached && this._agentViewComponent.refresh !== true) { - this.onJobsAvailable(this._jobCacheObject.jobs); + this.onJobsAvailable(null); } else { let ownerUri: string = this._dashboardService.connectionManagementService.connectionInfo.ownerUri; this._jobManagementService.getJobs(ownerUri).then((result) => { @@ -172,50 +192,135 @@ export class JobsViewComponent implements AfterContentChecked { } private onJobsAvailable(jobs: sqlops.AgentJobInfo[]) { - let jobViews = jobs.map((job) => { - return { - id: job.jobId, - jobId: job.jobId, - name: job.name, - lastRun: AgentJobUtilities.convertToLastRun(job.lastRun), - nextRun: AgentJobUtilities.convertToNextRun(job.nextRun), - enabled: AgentJobUtilities.convertToResponse(job.enabled), - currentExecutionStatus: AgentJobUtilities.convertToExecutionStatusString(job.currentExecutionStatus), - category: job.category, - runnable: AgentJobUtilities.convertToResponse(job.runnable), - hasSchedule: AgentJobUtilities.convertToResponse(job.hasSchedule), - lastRunOutcome: AgentJobUtilities.convertToStatusString(job.lastRunOutcome) - }; - }); + let jobViews: any; + if (!jobs) { + let dataView = this._jobCacheObject.dataView; + jobViews = dataView.getItems(); + } else { + jobViews = jobs.map((job) => { + return { + id: job.jobId, + jobId: job.jobId, + name: job.name, + lastRun: AgentJobUtilities.convertToLastRun(job.lastRun), + nextRun: AgentJobUtilities.convertToNextRun(job.nextRun), + enabled: AgentJobUtilities.convertToResponse(job.enabled), + currentExecutionStatus: AgentJobUtilities.convertToExecutionStatusString(job.currentExecutionStatus), + category: job.category, + runnable: AgentJobUtilities.convertToResponse(job.runnable), + hasSchedule: AgentJobUtilities.convertToResponse(job.hasSchedule), + lastRunOutcome: AgentJobUtilities.convertToStatusString(job.lastRunOutcome) + }; + }); + } this._table.registerPlugin(this.rowDetail); + this.filterPlugin.onFilterApplied.subscribe((e, args) => { + this.dataView.refresh(); + this._table.grid.resetActiveCell(); + let filterValues = args.column.filterValues; + if (filterValues) { + if (filterValues.length === 0) { + // if an associated styling exists with the current filters + if (this.filterStylingMap[args.column.name]) { + let filterLength = this.filterStylingMap[args.column.name].length; + // then remove the filtered styling + for (let i = 0; i < filterLength; i++) { + let lastAppliedStyle = this.filterStylingMap[args.column.name].pop(); + this._table.grid.removeCellCssStyles(lastAppliedStyle[0]); + } + delete this.filterStylingMap[args.column.name]; + let index = this.filterStack.indexOf(args.column.name, 0); + if (index > -1) { + this.filterStack.splice(index, 1); + delete this.filterValueMap[args.column.name]; + } + // apply the previous filter styling + let currentItems = this.dataView.getFilteredItems(); + let styledItems = this.filterValueMap[this.filterStack[this.filterStack.length-1]][1]; + if (styledItems === currentItems) { + let lastColStyle = this.filterStylingMap[this.filterStack[this.filterStack.length-1]]; + for (let i = 0; i < lastColStyle.length; i++) { + this._table.grid.setCellCssStyles(lastColStyle[i][0], lastColStyle[i][1]); + } + } else { + // style it all over again + let seenJobs = 0; + for (let i = 0; i < currentItems.length; i++) { + this._table.grid.removeCellCssStyles('error-row'+i.toString()); + let item = this.dataView.getFilteredItems()[i]; + if (item.lastRunOutcome === 'Failed') { + this.addToStyleHash(seenJobs, false, this.filterStylingMap, args.column.name); + if (this.filterStack.indexOf(args.column.name) < 0) { + this.filterStack.push(args.column.name); + this.filterValueMap[args.column.name] = [filterValues]; + } + // one expansion for the row and one for + // the error detail + seenJobs ++; + i++; + } + seenJobs++; + } + this.dataView.refresh(); + this.filterValueMap[args.column.name].push(this.dataView.getFilteredItems()); + this._table.grid.resetActiveCell(); + } + if (this.filterStack.length === 0) { + this.filterStack = ['start']; + } + } + } else { + let seenJobs = 0; + for (let i = 0; i < this.jobs.length; i++) { + this._table.grid.removeCellCssStyles('error-row'+i.toString()); + let item = this.dataView.getItemByIdx(i); + // current filter + if (_.contains(filterValues, item[args.column.field])) { + // check all previous filters + if (this.checkPreviousFilters(item)) { + if (item.lastRunOutcome === 'Failed') { + this.addToStyleHash(seenJobs, false, this.filterStylingMap, args.column.name); + if (this.filterStack.indexOf(args.column.name) < 0) { + this.filterStack.push(args.column.name); + this.filterValueMap[args.column.name] = [filterValues]; + } + // one expansion for the row and one for + // the error detail + seenJobs ++; + i++; + } + seenJobs++; + } + } + } + this.dataView.refresh(); + if (this.filterValueMap[args.column.name]) { + this.filterValueMap[args.column.name].push(this.dataView.getFilteredItems()); + } else { + this.filterValueMap[args.column.name] = this.dataView.getFilteredItems(); + } - this.rowDetail.onBeforeRowDetailToggle.subscribe(function (e, args) { + this._table.grid.resetActiveCell(); + } + } else { + this.expandJobs(false); + } }); - this.rowDetail.onAfterRowDetailToggle.subscribe(function (e, args) { - }); - this.rowDetail.onAsyncEndUpdate.subscribe(function (e, args) { + this.filterPlugin.onCommand.subscribe((e, args: any) => { + this.columnSort(args.column.name, args.command === 'sort-asc'); }); + this._table.registerPlugin(this.filterPlugin); this.dataView.beginUpdate(); this.dataView.setItems(jobViews); + this.dataView.setFilter((item) => this.filter(item)); + this.dataView.endUpdate(); this._table.autosizeColumns(); this._table.resizeCanvas(); - let expandedJobs = this._agentViewComponent.expanded; - let expansions = 0; - for (let i = 0; i < jobs.length; i++) { - let job = jobs[i]; - if (job.lastRunOutcome === 0 && !expandedJobs.get(job.jobId)) { - this.expandJobRowDetails(i + expandedJobs.size); - this.addToStyleHash(i + expandedJobs.size); - this._agentViewComponent.setExpanded(job.jobId, 'Loading Error...'); - } else if (job.lastRunOutcome === 0 && expandedJobs.get(job.jobId)) { - this.expandJobRowDetails(i + expansions); - this.addToStyleHash(i + expansions); - expansions++; - } - } + this.expandJobs(true); + // tooltip for job name $('.jobview-jobnamerow').hover(e => { let currentTarget = e.currentTarget; currentTarget.title = currentTarget.innerText; @@ -245,6 +350,9 @@ export class JobsViewComponent implements AfterContentChecked { // adjust error message when resized $('#jobsDiv .jobview-grid .slick-cell.l1.r1.error-row .jobview-jobnametext').css('width', '100%'); }); + // cache the dataview for future use + this._jobCacheObject.dataView = this.dataView; + this.filterValueMap['start'] = [[], this.dataView.getItems()]; this.loadJobHistories(); } @@ -266,15 +374,28 @@ export class JobsViewComponent implements AfterContentChecked { return hash; } - private addToStyleHash(row: number) { - let hash: { + private addToStyleHash(row: number, start: boolean, map: any, columnName: string) { + let hash : { [index: number]: { [id: string]: string; } } = {}; hash = this.setRowWithErrorClass(hash, row, 'job-with-error'); - hash = this.setRowWithErrorClass(hash, row + 1, 'error-row'); - this._table.grid.setCellCssStyles('error-row' + row.toString(), hash); + hash = this.setRowWithErrorClass(hash, row+1, 'error-row'); + if (start) { + if (map['start']) { + map['start'].push(['error-row'+row.toString(), hash]); + } else { + map['start'] = [['error-row'+row.toString(), hash]]; + } + } else { + if (map[columnName]) { + map[columnName].push(['error-row'+row.toString(), hash]); + } else { + map[columnName] = [['error-row'+row.toString(), hash]]; + } + } + this._table.grid.setCellCssStyles('error-row'+row.toString(), hash); } private renderName(row, cell, value, columnDef, dataContext) { @@ -336,6 +457,17 @@ export class JobsViewComponent implements AfterContentChecked { return [failing, nonFailing]; } + private checkPreviousFilters(item): boolean { + for (let column in this.filterValueMap) { + if (column !== 'start' && this.filterValueMap[column][0].length > 0) { + if (!_.contains(this.filterValueMap[column][0], item[AgentJobUtilities.convertColNameToField(column)])) { + return false; + } + } + } + return true; + } + private isErrorRow(cell: HTMLElement) { return cell.classList.contains('error-row'); } @@ -374,4 +506,167 @@ export class JobsViewComponent implements AfterContentChecked { }); } } + + private expandJobs(start: boolean): void { + let expandedJobs = this._agentViewComponent.expanded; + let expansions = 0; + for (let i = 0; i < this.jobs.length; i++){ + let job = this.jobs[i]; + if (job.lastRunOutcome === 0 && !expandedJobs.get(job.jobId)) { + this.expandJobRowDetails(i+expandedJobs.size); + this.addToStyleHash(i+expandedJobs.size, start, this.filterStylingMap, undefined); + this._agentViewComponent.setExpanded(job.jobId, 'Loading Error...'); + } else if (job.lastRunOutcome === 0 && expandedJobs.get(job.jobId)) { + this.addToStyleHash(i+expansions, start, this.filterStylingMap, undefined); + expansions++; + } + } + } + + private filter(item: any) { + let columns = this._table.grid.getColumns(); + let value = true; + for (let i = 0; i < columns.length; i++) { + let col: any = columns[i]; + let filterValues = col.filterValues; + if (filterValues && filterValues.length > 0) { + if (item._parent) { + value = value && _.contains(filterValues, item._parent[col.field]); + } else { + value = value && _.contains(filterValues, item[col.field]); + } + } + } + return value; + } + + private columnSort(column: string, isAscending: boolean) { + let items = this.dataView.getItems(); + // get error items here and remove them + let jobItems = items.filter(x => x._parent === undefined); + let errorItems = items.filter(x => x._parent !== undefined); + this.sortingStylingMap[column] = items; + switch(column) { + case('Name'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.name.localeCompare(item2.name); + }, isAscending); + break; + } + case('Last Run'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => this.dateCompare(item1, item2, true), isAscending); + break; + } + case ('Next Run') : { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => this.dateCompare(item1, item2, false), isAscending); + break; + } + case ('Enabled'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.enabled.localeCompare(item2.enabled); + }, isAscending); + break; + } + case ('Status'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.currentExecutionStatus.localeCompare(item2.currentExecutionStatus); + }, isAscending); + break; + } + case ('Category'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.category.localeCompare(item2.category); + }, isAscending); + break; + } + case ('Runnable'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.runnable.localeCompare(item2.runnable); + }, isAscending); + break; + } + case ('Schedule'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.hasSchedule.localeCompare(item2.hasSchedule); + }, isAscending); + break; + } + case ('Last Run Outcome'): { + this.dataView.setItems(jobItems); + // sort the actual jobs + this.dataView.sort((item1, item2) => { + return item1.lastRunOutcome.localeCompare(item2.lastRunOutcome); + }, isAscending); + break; + } + } + // insert the errors back again + let jobItemsLength = jobItems.length; + for (let i = 0; i < jobItemsLength; i++) { + let item = jobItems[i]; + if (item._child) { + let child = errorItems.find(error => error === item._child); + jobItems.splice(i+1, 0, child); + jobItemsLength++; + } + } + this.dataView.setItems(jobItems); + // remove old style + if (this.filterStylingMap[column]) { + let filterLength = this.filterStylingMap[column].length; + for (let i = 0; i < filterLength; i++) { + let lastAppliedStyle = this.filterStylingMap[column].pop(); + this._table.grid.removeCellCssStyles(lastAppliedStyle[0]); + } + } else { + for (let i = 0; i < this.jobs.length; i++) { + this._table.grid.removeCellCssStyles('error-row'+i.toString()); + } + } + // add new style to the items back again + items = this.filterStack.length > 1 ? this.dataView.getFilteredItems() : this.dataView.getItems(); + for (let i = 0; i < items.length; i ++) { + let item = items[i]; + if (item.lastRunOutcome === 'Failed') { + this.addToStyleHash(i, false, this.sortingStylingMap, column); + } + } + } + + private dateCompare(item1: any, item2: any, lastRun: boolean): number { + let exceptionString = lastRun ? 'Never Run' : 'Not Scheduled'; + if (item2.lastRun === exceptionString && item1.lastRun !== exceptionString) { + return -1; + } else if (item1.lastRun === exceptionString && item2.lastRun !== exceptionString) { + return 1; + } else if (item1.lastRun === exceptionString && item2.lastRun === exceptionString) { + return 0; + } else { + let date1 = new Date(item1.lastRun); + let date2 = new Date(item2.lastRun); + if (date1 > date2) { + return 1; + } else if (date1 === date2) { + return 0; + } else { + return -1; + } + } + } } \ No newline at end of file