mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 17:30:46 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
26
src/Explorer/Tables/DataTable/CacheBase.ts
Normal file
26
src/Explorer/Tables/DataTable/CacheBase.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
abstract class CacheBase<T> {
|
||||
public data: T[];
|
||||
public sortOrder: any;
|
||||
public serverCallInProgress: boolean;
|
||||
|
||||
constructor() {
|
||||
this.data = null;
|
||||
this.sortOrder = null;
|
||||
this.serverCallInProgress = false;
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.data ? this.data.length : 0;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.preClear();
|
||||
this.data = null;
|
||||
this.sortOrder = null;
|
||||
this.serverCallInProgress = false;
|
||||
}
|
||||
|
||||
protected abstract preClear(): void;
|
||||
}
|
||||
|
||||
export default CacheBase;
|
||||
396
src/Explorer/Tables/DataTable/DataTableBindingManager.ts
Normal file
396
src/Explorer/Tables/DataTable/DataTableBindingManager.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
|
||||
import * as Constants from "../Constants";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as DataTableBuilder from "./DataTableBuilder";
|
||||
import DataTableOperationManager from "./DataTableOperationManager";
|
||||
import * as DataTableOperations from "./DataTableOperations";
|
||||
import QueryTablesTab from "../../Tabs/QueryTablesTab";
|
||||
import TableEntityListViewModel from "./TableEntityListViewModel";
|
||||
import * as Utilities from "../Utilities";
|
||||
import * as Entities from "../Entities";
|
||||
|
||||
/**
|
||||
* Custom binding manager of datatable
|
||||
*/
|
||||
var tableEntityListViewModelMap: {
|
||||
[key: string]: {
|
||||
tableViewModel: TableEntityListViewModel;
|
||||
operationManager: DataTableOperationManager;
|
||||
$dataTable: JQuery;
|
||||
};
|
||||
} = {};
|
||||
|
||||
function bindDataTable(element: any, valueAccessor: any, allBindings: any, viewModel: any, bindingContext: any) {
|
||||
var tableEntityListViewModel = bindingContext.$data;
|
||||
tableEntityListViewModel.notifyColumnChanges = onTableColumnChange;
|
||||
var $dataTable = $(element);
|
||||
var queryTablesTab = bindingContext.$parent;
|
||||
var operationManager = new DataTableOperationManager(
|
||||
$dataTable,
|
||||
tableEntityListViewModel,
|
||||
queryTablesTab.tableCommands
|
||||
);
|
||||
|
||||
tableEntityListViewModelMap[queryTablesTab.tabId] = {
|
||||
tableViewModel: tableEntityListViewModel,
|
||||
operationManager: operationManager,
|
||||
$dataTable: $dataTable
|
||||
};
|
||||
|
||||
createDataTable(0, tableEntityListViewModel, queryTablesTab); // Fake a DataTable to start.
|
||||
$(window).resize(updateTableScrollableRegionMetrics);
|
||||
operationManager.focusTable(); // Also selects the first row if needed.
|
||||
}
|
||||
|
||||
function onTableColumnChange(enablePrompt: boolean = true, queryTablesTab: QueryTablesTab) {
|
||||
var columnsFilter: boolean[] = null;
|
||||
var tableEntityListViewModel = tableEntityListViewModelMap[queryTablesTab.tabId].tableViewModel;
|
||||
if (queryTablesTab.queryViewModel()) {
|
||||
queryTablesTab
|
||||
.queryViewModel()
|
||||
.queryBuilderViewModel()
|
||||
.updateColumnOptions();
|
||||
}
|
||||
createDataTable(
|
||||
tableEntityListViewModel.tablePageStartIndex,
|
||||
tableEntityListViewModel,
|
||||
queryTablesTab,
|
||||
true,
|
||||
columnsFilter
|
||||
);
|
||||
}
|
||||
|
||||
function createDataTable(
|
||||
startIndex: number,
|
||||
tableEntityListViewModel: TableEntityListViewModel,
|
||||
queryTablesTab: QueryTablesTab,
|
||||
destroy: boolean = false,
|
||||
columnsFilter: boolean[] = null
|
||||
): void {
|
||||
var $dataTable = tableEntityListViewModelMap[queryTablesTab.tabId].$dataTable;
|
||||
if (destroy) {
|
||||
// Find currently displayed columns.
|
||||
var currentColumns: string[] = tableEntityListViewModel.headers;
|
||||
|
||||
// Calculate how many more columns need to added to the current table.
|
||||
var columnsToAdd: number = _.difference(tableEntityListViewModel.headers, currentColumns).length;
|
||||
|
||||
// This is needed as current solution of adding column is more like a workaround
|
||||
// The official support for dynamically add column is not yet there
|
||||
// Please track github issue https://github.com/DataTables/DataTables/issues/273 for its offical support
|
||||
for (var i = 0; i < columnsToAdd; i++) {
|
||||
$(".dataTables_scrollHead table thead tr th")
|
||||
.eq(0)
|
||||
.after("<th></th>");
|
||||
}
|
||||
tableEntityListViewModel.table.destroy();
|
||||
$dataTable.empty();
|
||||
}
|
||||
|
||||
var jsonColTable = [];
|
||||
|
||||
for (var i = 0; i < tableEntityListViewModel.headers.length; i++) {
|
||||
jsonColTable.push({
|
||||
sTitle: tableEntityListViewModel.headers[i],
|
||||
data: tableEntityListViewModel.headers[i],
|
||||
aTargets: [i],
|
||||
mRender: bindColumn,
|
||||
visible: !!columnsFilter ? columnsFilter[i] : true
|
||||
});
|
||||
}
|
||||
|
||||
tableEntityListViewModel.table = DataTableBuilder.createDataTable($dataTable, <DataTables.Settings>{
|
||||
// WARNING!!! SECURITY: If you add new columns, make sure you encode them if they are user strings from Azure (see encodeText)
|
||||
// so that they don't get interpreted as HTML in our page.
|
||||
colReorder: true,
|
||||
aoColumnDefs: jsonColTable,
|
||||
stateSave: false,
|
||||
dom: "RZlfrtip",
|
||||
oColReorder: {
|
||||
iFixedColumns: 1
|
||||
},
|
||||
displayStart: startIndex,
|
||||
bPaginate: true,
|
||||
pagingType: "full_numbers",
|
||||
bProcessing: true,
|
||||
oLanguage: {
|
||||
sInfo: "Results _START_ - _END_ of _TOTAL_",
|
||||
oPaginate: {
|
||||
sFirst: "<<",
|
||||
sNext: ">",
|
||||
sPrevious: "<",
|
||||
sLast: ">>"
|
||||
},
|
||||
sProcessing: '<img style="width: 28px; height: 6px; " src="images/LoadingIndicator_3Squares.gif">',
|
||||
oAria: {
|
||||
sSortAscending: "",
|
||||
sSortDescending: ""
|
||||
}
|
||||
},
|
||||
destroy: destroy,
|
||||
bInfo: true,
|
||||
bLength: false,
|
||||
bLengthChange: false,
|
||||
scrollX: true,
|
||||
scrollCollapse: true,
|
||||
iDisplayLength: 100,
|
||||
serverSide: true,
|
||||
ajax: queryTablesTab.tabId, // Using this settings to make sure for getServerData we update the table based on the appropriate tab
|
||||
fnServerData: getServerData,
|
||||
fnRowCallback: bindClientId,
|
||||
fnInitComplete: initializeTable,
|
||||
fnDrawCallback: updateSelectionStatus
|
||||
});
|
||||
}
|
||||
|
||||
function bindColumn(data: any, type: string, full: any) {
|
||||
var displayedValue: any = null;
|
||||
if (data) {
|
||||
displayedValue = data._;
|
||||
|
||||
// SECURITY: Make sure we don't allow cross-site scripting by interpreting the values as HTML
|
||||
displayedValue = Utilities.htmlEncode(displayedValue);
|
||||
|
||||
// Css' empty psuedo class can only tell the difference of whether a cell has values.
|
||||
// A cell has no values no matter it's empty or it has no such a property.
|
||||
// To distinguish between an empty cell and a non-existing property cell,
|
||||
// we add a whitespace to the empty cell so that css will treat it as a cell with values.
|
||||
if (displayedValue === "" && data.$ === Constants.TableType.String) {
|
||||
displayedValue = " ";
|
||||
}
|
||||
}
|
||||
return displayedValue;
|
||||
}
|
||||
|
||||
function getServerData(sSource: any, aoData: any, fnCallback: any, oSettings: any) {
|
||||
tableEntityListViewModelMap[oSettings.ajax].tableViewModel.renderNextPageAndupdateCache(
|
||||
sSource,
|
||||
aoData,
|
||||
fnCallback,
|
||||
oSettings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind table data information to row element so that we can track back to the table data
|
||||
* from UI elements.
|
||||
*/
|
||||
function bindClientId(nRow: Node, aData: Entities.ITableEntity) {
|
||||
$(nRow).attr(Constants.htmlAttributeNames.dataTableRowKeyAttr, aData.RowKey._);
|
||||
return nRow;
|
||||
}
|
||||
|
||||
function selectionChanged(element: any, valueAccessor: any, allBindings: any, viewModel: any, bindingContext: any) {
|
||||
$(".dataTable tr.selected")
|
||||
.attr("tabindex", "-1")
|
||||
.removeClass("selected");
|
||||
|
||||
const selected =
|
||||
bindingContext && bindingContext.$data && bindingContext.$data.selected && bindingContext.$data.selected();
|
||||
selected &&
|
||||
selected.forEach((b: Entities.ITableEntity) => {
|
||||
var sel = DataTableOperations.getRowSelector([
|
||||
{
|
||||
key: Constants.htmlAttributeNames.dataTableRowKeyAttr,
|
||||
value: b.RowKey && b.RowKey._ && b.RowKey._.toString()
|
||||
}
|
||||
]);
|
||||
|
||||
$(sel)
|
||||
.attr("tabindex", "0")
|
||||
.focus()
|
||||
.addClass("selected");
|
||||
});
|
||||
//selected = bindingContext.$data.selected();
|
||||
}
|
||||
|
||||
function dataChanged(element: any, valueAccessor: any, allBindings: any, viewModel: any, bindingContext: any) {
|
||||
// do nothing for now
|
||||
}
|
||||
|
||||
function initializeTable(): void {
|
||||
updateTableScrollableRegionMetrics();
|
||||
initializeEventHandlers();
|
||||
}
|
||||
|
||||
function updateTableScrollableRegionMetrics(): void {
|
||||
updateTableScrollableRegionHeight();
|
||||
updateTableScrollableRegionWidth();
|
||||
}
|
||||
|
||||
/*
|
||||
* Update the table's scrollable region height. So the pagination control is always shown at the bottom of the page.
|
||||
*/
|
||||
function updateTableScrollableRegionHeight(): void {
|
||||
$(".tab-pane").each(function(index, tabElement) {
|
||||
if (!$(tabElement).hasClass("tableContainer")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add some padding to the table so it doesn't get too close to the container border.
|
||||
var dataTablePaddingBottom = 10;
|
||||
var bodyHeight = $(window).height();
|
||||
var dataTablesScrollBodyPosY = $(tabElement)
|
||||
.find(Constants.htmlSelectors.dataTableScrollBodySelector)
|
||||
.offset().top;
|
||||
var dataTablesInfoElem = $(tabElement).find(".dataTables_info");
|
||||
var dataTablesPaginateElem = $(tabElement).find(".dataTables_paginate");
|
||||
const explorer = window.dataExplorer as ViewModels.Explorer;
|
||||
const notificationConsoleHeight = explorer.isNotificationConsoleExpanded()
|
||||
? 252 /** 32px(header) + 220px(content height) **/
|
||||
: 32 /** Header height **/;
|
||||
|
||||
var scrollHeight =
|
||||
bodyHeight -
|
||||
dataTablesScrollBodyPosY -
|
||||
dataTablesPaginateElem.outerHeight(true) -
|
||||
dataTablePaddingBottom -
|
||||
notificationConsoleHeight;
|
||||
|
||||
//info and paginate control are stacked
|
||||
if (dataTablesInfoElem.offset().top < dataTablesPaginateElem.offset().top) {
|
||||
scrollHeight -= dataTablesInfoElem.outerHeight(true);
|
||||
}
|
||||
|
||||
// TODO This is a work around for setting the outerheight since we don't have access to the JQuery.outerheight(numberValue)
|
||||
// in the current version of JQuery we are using. Ideally, we would upgrade JQuery and use this line instead:
|
||||
// $(Constants.htmlSelectors.dataTableScrollBodySelector).outerHeight(scrollHeight);
|
||||
var element = $(tabElement).find(Constants.htmlSelectors.dataTableScrollBodySelector)[0];
|
||||
var style = getComputedStyle(element);
|
||||
var actualHeight = parseInt(style.height);
|
||||
var change = element.offsetHeight - scrollHeight;
|
||||
$(tabElement)
|
||||
.find(Constants.htmlSelectors.dataTableScrollBodySelector)
|
||||
.height(actualHeight - change);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Update the table's scrollable region width to make efficient use of the remaining space.
|
||||
*/
|
||||
function updateTableScrollableRegionWidth(): void {
|
||||
$(".tab-pane").each(function(index, tabElement) {
|
||||
if (!$(tabElement).hasClass("tableContainer")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var bodyWidth = $(window).width();
|
||||
var dataTablesScrollBodyPosLeft = $(tabElement)
|
||||
.find(Constants.htmlSelectors.dataTableScrollBodySelector)
|
||||
.offset().left;
|
||||
var scrollWidth = bodyWidth - dataTablesScrollBodyPosLeft;
|
||||
|
||||
// jquery datatables automatically sets width:100% to both the header and the body when we use it's column autoWidth feature.
|
||||
// We work around that by setting the height for it's container instead.
|
||||
$(tabElement)
|
||||
.find(Constants.htmlSelectors.dataTableScrollContainerSelector)
|
||||
.width(scrollWidth);
|
||||
});
|
||||
}
|
||||
|
||||
function initializeEventHandlers(): void {
|
||||
var $headers: JQuery = $(Constants.htmlSelectors.dataTableHeaderTypeSelector);
|
||||
var $firstHeader: JQuery = $headers.first();
|
||||
var firstIndex: string = $firstHeader.attr(Constants.htmlAttributeNames.dataTableHeaderIndex);
|
||||
|
||||
$headers
|
||||
.on("keydown", (event: JQueryEventObject) => {
|
||||
Utilities.onEnter(event, ($sourceElement: JQuery) => {
|
||||
$sourceElement.css("background-color", Constants.cssColors.commonControlsButtonActive);
|
||||
});
|
||||
|
||||
// Bind shift+tab from first header back to search input field
|
||||
Utilities.onTab(
|
||||
event,
|
||||
($sourceElement: JQuery) => {
|
||||
var sourceIndex: string = $sourceElement.attr(Constants.htmlAttributeNames.dataTableHeaderIndex);
|
||||
|
||||
if (sourceIndex === firstIndex) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
/* metaKey */ null,
|
||||
/* shiftKey */ true,
|
||||
/* altKey */ null
|
||||
);
|
||||
|
||||
// Also reset color if [shift-] tabbing away from button while holding down 'enter'
|
||||
Utilities.onTab(event, ($sourceElement: JQuery) => {
|
||||
$sourceElement.css("background-color", "");
|
||||
});
|
||||
})
|
||||
.on("keyup", (event: JQueryEventObject) => {
|
||||
Utilities.onEnter(event, ($sourceElement: JQuery) => {
|
||||
$sourceElement.css("background-color", "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectionStatus(oSettings: any): void {
|
||||
var $dataTableRows: JQuery = $(Constants.htmlSelectors.dataTableAllRowsSelector);
|
||||
if ($dataTableRows) {
|
||||
for (var i = 0; i < $dataTableRows.length; i++) {
|
||||
var $row: JQuery = $dataTableRows.eq(i);
|
||||
var rowKey: string = $row.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr);
|
||||
var table = tableEntityListViewModelMap[oSettings.ajax].tableViewModel;
|
||||
if (table.isItemSelected(table.getTableEntityKeys(rowKey))) {
|
||||
$row.attr("tabindex", "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateDataTableFocus(oSettings.ajax);
|
||||
|
||||
DataTableOperations.setPaginationButtonEventHandlers();
|
||||
}
|
||||
|
||||
// TODO consider centralizing this "post-command" logic into some sort of Command Manager entity.
|
||||
// See VSO:166520: "[Storage Explorer] Consider adding a 'command manager' to track command post-effects."
|
||||
function updateDataTableFocus(queryTablesTabId: string): void {
|
||||
var $activeElement: JQuery = $(document.activeElement);
|
||||
var isFocusLost: boolean = $activeElement.is("body"); // When focus is lost, "body" becomes the active element.
|
||||
var storageExplorerFrameHasFocus: boolean = document.hasFocus();
|
||||
var operationManager = tableEntityListViewModelMap[queryTablesTabId].operationManager;
|
||||
if (operationManager) {
|
||||
if (isFocusLost && storageExplorerFrameHasFocus) {
|
||||
// We get here when no control is active, meaning that the table update was triggered
|
||||
// from a dialog, the context menu or by clicking on a toolbar control or header.
|
||||
// Note that giving focus to the table also selects the first row if needed.
|
||||
// The document.hasFocus() ensures that the table will only get focus when the
|
||||
// focus was lost (i.e. "body has the focus") within the Storage Explorer frame
|
||||
// i.e. not when the focus is lost because it is in another frame
|
||||
// e.g. a daytona dialog or in the Activity Log.
|
||||
operationManager.focusTable();
|
||||
}
|
||||
if ($activeElement.is(".sorting_asc") || $activeElement.is(".sorting_desc")) {
|
||||
// If table header is selected, focus is shifted to the selected element as part of accessibility
|
||||
$activeElement && $activeElement.focus();
|
||||
} else {
|
||||
// If some control is active, we don't give focus back to the table,
|
||||
// just select the first row if needed (empty selection).
|
||||
operationManager.selectFirstIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(<any>ko.bindingHandlers).tableSource = {
|
||||
init: bindDataTable,
|
||||
update: dataChanged
|
||||
};
|
||||
|
||||
(<any>ko.bindingHandlers).tableSelection = {
|
||||
update: selectionChanged
|
||||
};
|
||||
|
||||
(<any>ko.bindingHandlers).readOnly = {
|
||||
update: function(element: any, valueAccessor: any) {
|
||||
var value = ko.utils.unwrapObservable(valueAccessor());
|
||||
if (value) {
|
||||
element.setAttribute("readOnly", true);
|
||||
} else {
|
||||
element.removeAttribute("readOnly");
|
||||
}
|
||||
}
|
||||
};
|
||||
52
src/Explorer/Tables/DataTable/DataTableBuilder.ts
Normal file
52
src/Explorer/Tables/DataTable/DataTableBuilder.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as Utilities from "../Utilities";
|
||||
|
||||
/**
|
||||
* Wrapper function for creating data tables. Call this method, not the
|
||||
* data tables constructor when you want to create a data table. This
|
||||
* function makes sure that content without a render function is properly
|
||||
* encoded to prevent XSS.
|
||||
* @param{$dataTableElem} JQuery data table element
|
||||
* @param{$settings} Settings to use when creating the data table
|
||||
*/
|
||||
export function createDataTable($dataTableElem: JQuery, settings: any): DataTables.DataTable {
|
||||
return $dataTableElem.DataTable(applyDefaultRendering(settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Go through the settings for a data table and apply a simple HTML encode to any column
|
||||
* without a render function to prevent XSS.
|
||||
* @param{settings} The settings to check
|
||||
* @return The given settings with all columns having a rendering function
|
||||
*/
|
||||
function applyDefaultRendering(settings: any): DataTables.SettingsLegacy {
|
||||
var tableColumns: DataTables.ColumnLegacy[] = null;
|
||||
|
||||
if (settings.aoColumns) {
|
||||
tableColumns = settings.aoColumns;
|
||||
} else if (settings.aoColumnDefs) {
|
||||
// for tables we use aoColumnDefs instead of aoColumns
|
||||
tableColumns = settings.aoColumnDefs;
|
||||
}
|
||||
|
||||
// either the settings had no columns defined, or they were called
|
||||
// by a property name which we have not used before
|
||||
if (!tableColumns) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
for (var i = 0; i < tableColumns.length; i++) {
|
||||
// the column does not have a render function
|
||||
if (!tableColumns[i].mRender) {
|
||||
tableColumns[i].mRender = defaultDataRender;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default data render function, whatever is done to data in here
|
||||
* will be done to any data which we do not specify a render for.
|
||||
*/
|
||||
function defaultDataRender(data: any, type: string, full: any) {
|
||||
return Utilities.htmlEncode(data);
|
||||
}
|
||||
150
src/Explorer/Tables/DataTable/DataTableContextMenu.ts
Normal file
150
src/Explorer/Tables/DataTable/DataTableContextMenu.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import Q from "q";
|
||||
|
||||
import * as Constants from "../Constants";
|
||||
import TableCommands from "./TableCommands";
|
||||
import TableEntityListViewModel from "./TableEntityListViewModel";
|
||||
|
||||
/*
|
||||
* ContextMenu view representation
|
||||
*/
|
||||
export default class DataTableContextMenu {
|
||||
public viewModel: TableEntityListViewModel;
|
||||
|
||||
// There is one context menu for each selector on each tab and they should all be registered here.
|
||||
// Once the context menus are registered, we should access them through this instance.
|
||||
public static Instance: { [key: string]: { contextMenu: DataTableContextMenu } } = {};
|
||||
|
||||
private _tableCommands: TableCommands;
|
||||
|
||||
constructor(viewModel: TableEntityListViewModel, tableCommands: TableCommands) {
|
||||
this.viewModel = viewModel;
|
||||
this._tableCommands = tableCommands;
|
||||
|
||||
this.registerTableBodyContextMenu();
|
||||
this.registerTableHeaderContextMenu();
|
||||
|
||||
DataTableContextMenu.Instance[viewModel.queryTablesTab.tabId] = { contextMenu: this };
|
||||
}
|
||||
|
||||
public unregisterContextMenu(selector: string): void {
|
||||
$.contextMenu("destroy", "div#" + this.viewModel.queryTablesTab.tabId + ".tab-pane " + selector);
|
||||
}
|
||||
|
||||
public registerTableBodyContextMenu(): void {
|
||||
// Localize
|
||||
$.contextMenu({
|
||||
selector:
|
||||
"div#" + this.viewModel.queryTablesTab.tabId + ".tab-pane " + Constants.htmlSelectors.dataTableBodyRowSelector,
|
||||
callback: this.bodyContextMenuSelect,
|
||||
items: {
|
||||
edit: {
|
||||
name: "Edit",
|
||||
cmd: TableCommands.editEntityCommand,
|
||||
icon: "edit-entity",
|
||||
disabled: () => !this.isEnabled(TableCommands.editEntityCommand)
|
||||
},
|
||||
delete: {
|
||||
name: "Delete",
|
||||
cmd: TableCommands.deleteEntitiesCommand,
|
||||
icon: "delete-entity",
|
||||
disabled: () => !this.isEnabled(TableCommands.deleteEntitiesCommand)
|
||||
},
|
||||
reorder: {
|
||||
name: "Reorder Columns Based on Schema",
|
||||
cmd: TableCommands.reorderColumnsCommand,
|
||||
icon: "shift-non-empty-columns-left",
|
||||
disabled: () => !this.isEnabled(TableCommands.reorderColumnsCommand)
|
||||
},
|
||||
reset: {
|
||||
name: "Reset Columns",
|
||||
cmd: TableCommands.resetColumnsCommand,
|
||||
icon: "reset-column-order"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public registerTableHeaderContextMenu(): void {
|
||||
// Localize
|
||||
$.contextMenu({
|
||||
selector:
|
||||
"div#" + this.viewModel.queryTablesTab.tabId + ".tab-pane " + Constants.htmlSelectors.dataTableHeadRowSelector,
|
||||
callback: this.headerContextMenuSelect,
|
||||
items: {
|
||||
customizeColumns: {
|
||||
name: "Column Options",
|
||||
cmd: TableCommands.customizeColumnsCommand,
|
||||
icon: "customize-columns"
|
||||
},
|
||||
reset: {
|
||||
name: "Reset Columns",
|
||||
cmd: TableCommands.resetColumnsCommand,
|
||||
icon: "reset-column-order"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isEnabled(commandName: string): boolean {
|
||||
return this._tableCommands.isEnabled(commandName, this.viewModel.selected());
|
||||
}
|
||||
|
||||
private headerContextMenuSelect = (key: any, options: any): void => {
|
||||
var promise: Q.Promise<any> = null;
|
||||
|
||||
switch (key) {
|
||||
case TableCommands.customizeColumnsCommand:
|
||||
promise = this._tableCommands.customizeColumnsCommand(this.viewModel);
|
||||
break;
|
||||
case TableCommands.resetColumnsCommand:
|
||||
promise = Q.resolve(this._tableCommands.resetColumns(this.viewModel));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
promise.then(() => {
|
||||
this.viewModel.focusDataTable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private bodyContextMenuSelect = (key: any, options: any): void => {
|
||||
var promise: Q.Promise<any> = null;
|
||||
|
||||
switch (key) {
|
||||
case TableCommands.editEntityCommand:
|
||||
promise = this._tableCommands.editEntityCommand(this.viewModel);
|
||||
break;
|
||||
case TableCommands.deleteEntitiesCommand:
|
||||
promise = this._tableCommands.deleteEntitiesCommand(this.viewModel);
|
||||
break;
|
||||
case TableCommands.reorderColumnsCommand:
|
||||
promise = this._tableCommands.reorderColumnsBasedOnSelectedEntities(this.viewModel);
|
||||
break;
|
||||
case TableCommands.resetColumnsCommand:
|
||||
promise = Q.resolve(this._tableCommands.resetColumns(this.viewModel));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
promise.then(() => {
|
||||
this.viewModel.focusDataTable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A context menu factory to construct the one context menu for each tab/table view model.
|
||||
*/
|
||||
public static contextMenuFactory(viewModel: TableEntityListViewModel, tableCommands: TableCommands) {
|
||||
if (!DataTableContextMenu.Instance[viewModel.queryTablesTab.tabId]) {
|
||||
DataTableContextMenu.Instance[viewModel.queryTablesTab.tabId] = {
|
||||
contextMenu: new DataTableContextMenu(viewModel, tableCommands)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
313
src/Explorer/Tables/DataTable/DataTableOperationManager.ts
Normal file
313
src/Explorer/Tables/DataTable/DataTableOperationManager.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import ko from "knockout";
|
||||
|
||||
import * as DataTableOperations from "./DataTableOperations";
|
||||
import * as Constants from "../Constants";
|
||||
import TableCommands from "./TableCommands";
|
||||
import TableEntityListViewModel from "./TableEntityListViewModel";
|
||||
import * as Utilities from "../Utilities";
|
||||
import * as Entities from "../Entities";
|
||||
|
||||
/*
|
||||
* Base class for data table row selection.
|
||||
*/
|
||||
export default class DataTableOperationManager {
|
||||
private _tableEntityListViewModel: TableEntityListViewModel;
|
||||
private _tableCommands: TableCommands;
|
||||
private dataTable: JQuery;
|
||||
|
||||
constructor(table: JQuery, viewModel: TableEntityListViewModel, tableCommands: TableCommands) {
|
||||
this.dataTable = table;
|
||||
this._tableEntityListViewModel = viewModel;
|
||||
this._tableCommands = tableCommands;
|
||||
|
||||
this.bind();
|
||||
this._tableEntityListViewModel.bind(this);
|
||||
}
|
||||
|
||||
private click = (event: JQueryEventObject) => {
|
||||
var elem: JQuery = $(event.currentTarget);
|
||||
this.updateLastSelectedItem(elem, event.shiftKey);
|
||||
|
||||
if (Utilities.isEnvironmentCtrlPressed(event)) {
|
||||
this.applyCtrlSelection(elem);
|
||||
} else if (event.shiftKey) {
|
||||
this.applyShiftSelection(elem);
|
||||
} else {
|
||||
this.applySingleSelection(elem);
|
||||
}
|
||||
};
|
||||
|
||||
private doubleClick = (event: JQueryEventObject) => {
|
||||
this.tryOpenEditor();
|
||||
};
|
||||
|
||||
private contextMenu = (event: JQueryEventObject) => {
|
||||
var elem: JQuery = $(event.currentTarget);
|
||||
this.updateLastSelectedItem(elem, event.shiftKey);
|
||||
|
||||
this.applyContextMenuSelection(elem);
|
||||
setTimeout(function() {
|
||||
$(".context-menu-list")
|
||||
.attr("tabindex", -1)
|
||||
.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
private keyDown = (event: JQueryEventObject): boolean => {
|
||||
var isUpArrowKey: boolean = event.keyCode === Constants.keyCodes.UpArrow,
|
||||
isDownArrowKey: boolean = event.keyCode === Constants.keyCodes.DownArrow,
|
||||
handled: boolean = false;
|
||||
|
||||
if (isUpArrowKey || isDownArrowKey) {
|
||||
var lastSelectedItem: Entities.ITableEntity = this._tableEntityListViewModel.lastSelectedItem;
|
||||
var dataTableRows: JQuery = $(Constants.htmlSelectors.dataTableAllRowsSelector);
|
||||
var maximumIndex = dataTableRows.length - 1;
|
||||
|
||||
// If can't find an index for lastSelectedItem, then either no item is previously selected or it goes across page.
|
||||
// Simply select the first item in this case.
|
||||
var lastSelectedItemIndex = lastSelectedItem
|
||||
? this._tableEntityListViewModel.getItemIndexFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(lastSelectedItem.RowKey._)
|
||||
)
|
||||
: -1;
|
||||
var nextIndex: number = isUpArrowKey ? lastSelectedItemIndex - 1 : lastSelectedItemIndex + 1;
|
||||
var safeIndex: number = Utilities.ensureBetweenBounds(nextIndex, 0, maximumIndex);
|
||||
var selectedRowElement: JQuery = dataTableRows.eq(safeIndex);
|
||||
|
||||
if (selectedRowElement) {
|
||||
if (event.shiftKey) {
|
||||
this.applyShiftSelection(selectedRowElement);
|
||||
} else {
|
||||
this.applySingleSelection(selectedRowElement);
|
||||
}
|
||||
|
||||
this.updateLastSelectedItem(selectedRowElement, event.shiftKey);
|
||||
handled = true;
|
||||
DataTableOperations.scrollToRowIfNeeded(dataTableRows, safeIndex, isUpArrowKey);
|
||||
}
|
||||
} else if (
|
||||
Utilities.isEnvironmentCtrlPressed(event) &&
|
||||
!Utilities.isEnvironmentShiftPressed(event) &&
|
||||
!Utilities.isEnvironmentAltPressed(event) &&
|
||||
event.keyCode === Constants.keyCodes.A
|
||||
) {
|
||||
this.applySelectAll();
|
||||
handled = true;
|
||||
}
|
||||
|
||||
return !handled;
|
||||
};
|
||||
|
||||
// Note: There is one key up event each time a key is pressed;
|
||||
// in contrast, there may be more than one key down and key
|
||||
// pressed events.
|
||||
private keyUp = (event: JQueryEventObject): boolean => {
|
||||
var handled: boolean = false;
|
||||
|
||||
switch (event.keyCode) {
|
||||
case Constants.keyCodes.Enter:
|
||||
handled = this.tryOpenEditor();
|
||||
break;
|
||||
case Constants.keyCodes.Delete:
|
||||
handled = this.tryHandleDeleteSelected();
|
||||
break;
|
||||
}
|
||||
|
||||
return !handled;
|
||||
};
|
||||
|
||||
private itemDropped = (event: JQueryEventObject): boolean => {
|
||||
var handled: boolean = false;
|
||||
var items = (<any>event.originalEvent).dataTransfer.items;
|
||||
|
||||
if (!items) {
|
||||
// On browsers outside of Chromium
|
||||
// we can't discern between dirs and files
|
||||
// so we will disable drag & drop for now
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var entry = item.webkitGetAsEntry();
|
||||
|
||||
if (entry.isFile) {
|
||||
// TODO: parse the file and insert content as entities
|
||||
}
|
||||
}
|
||||
|
||||
return !handled;
|
||||
};
|
||||
|
||||
private tryOpenEditor(): boolean {
|
||||
return this._tableCommands.tryOpenEntityEditor(this._tableEntityListViewModel);
|
||||
}
|
||||
|
||||
private tryHandleDeleteSelected(): boolean {
|
||||
var selectedEntities: Entities.ITableEntity[] = this._tableEntityListViewModel.selected();
|
||||
var handled: boolean = false;
|
||||
|
||||
if (selectedEntities && selectedEntities.length) {
|
||||
this._tableCommands.deleteEntitiesCommand(this._tableEntityListViewModel);
|
||||
handled = true;
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
private getEntityIdentity($elem: JQuery): Entities.ITableEntityIdentity {
|
||||
return {
|
||||
RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr)
|
||||
};
|
||||
}
|
||||
|
||||
private updateLastSelectedItem($elem: JQuery, isShiftSelect: boolean) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var entity = this._tableEntityListViewModel.getItemFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey)
|
||||
);
|
||||
|
||||
this._tableEntityListViewModel.lastSelectedItem = entity;
|
||||
|
||||
if (!isShiftSelect) {
|
||||
this._tableEntityListViewModel.lastSelectedAnchorItem = entity;
|
||||
}
|
||||
}
|
||||
|
||||
private applySingleSelection($elem: JQuery) {
|
||||
if ($elem) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applySelectAll() {
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
ko.utils.arrayPushAll<Entities.ITableEntity>(
|
||||
this._tableEntityListViewModel.selected,
|
||||
this._tableEntityListViewModel.getAllItemsInCurrentPage()
|
||||
);
|
||||
}
|
||||
|
||||
private applyCtrlSelection($elem: JQuery): void {
|
||||
var koSelected: ko.ObservableArray<Entities.ITableEntity> = this._tableEntityListViewModel
|
||||
? this._tableEntityListViewModel.selected
|
||||
: null;
|
||||
|
||||
if (koSelected) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey)
|
||||
)
|
||||
) {
|
||||
// Adding item not previously in selection
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
} else {
|
||||
koSelected.remove((item: Entities.ITableEntity) => item.RowKey._ === entityIdentity.RowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyShiftSelection($elem: JQuery): void {
|
||||
var anchorItem = this._tableEntityListViewModel.lastSelectedAnchorItem;
|
||||
|
||||
// If anchor item doesn't exist, use the first available item of current page instead
|
||||
if (!anchorItem && this._tableEntityListViewModel.items().length > 0) {
|
||||
anchorItem = this._tableEntityListViewModel.items()[0];
|
||||
}
|
||||
|
||||
if (anchorItem) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var elementIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey)
|
||||
);
|
||||
var anchorIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.RowKey._)
|
||||
);
|
||||
|
||||
var startIndex = Math.min(elementIndex, anchorIndex);
|
||||
var endIndex = Math.max(elementIndex, anchorIndex);
|
||||
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
ko.utils.arrayPushAll<Entities.ITableEntity>(
|
||||
this._tableEntityListViewModel.selected,
|
||||
this._tableEntityListViewModel.getItemsFromAllPagesWithinRange(startIndex, endIndex + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private applyContextMenuSelection($elem: JQuery) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey)
|
||||
)
|
||||
) {
|
||||
if (this._tableEntityListViewModel.selected().length) {
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
}
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
private addToSelection(rowKey: string) {
|
||||
var selectedEntity: Entities.ITableEntity = this._tableEntityListViewModel.getItemFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(rowKey)
|
||||
);
|
||||
|
||||
if (selectedEntity != null) {
|
||||
this._tableEntityListViewModel.selected.push(selectedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// Selecting first row if the selection is empty.
|
||||
public selectFirstIfNeeded(): void {
|
||||
var koSelected: ko.ObservableArray<Entities.ITableEntity> = this._tableEntityListViewModel
|
||||
? this._tableEntityListViewModel.selected
|
||||
: null;
|
||||
var koEntities: ko.ObservableArray<Entities.ITableEntity> = this._tableEntityListViewModel
|
||||
? this._tableEntityListViewModel.items
|
||||
: null;
|
||||
|
||||
if (!koSelected().length && koEntities().length) {
|
||||
var firstEntity: Entities.ITableEntity = koEntities()[0];
|
||||
|
||||
// Clear last selection: lastSelectedItem and lastSelectedAnchorItem
|
||||
this._tableEntityListViewModel.clearLastSelected();
|
||||
|
||||
this.addToSelection(firstEntity.RowKey._);
|
||||
|
||||
// Update last selection
|
||||
this._tableEntityListViewModel.lastSelectedItem = firstEntity;
|
||||
|
||||
// Finally, make sure first row is visible
|
||||
DataTableOperations.scrollToTopIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
public bind() {
|
||||
this.dataTable.on("click", "tr", this.click);
|
||||
this.dataTable.on("dblclick", "tr", this.doubleClick);
|
||||
this.dataTable.on("contextmenu", "tr", this.contextMenu);
|
||||
this.dataTable.on("keydown", "td", this.keyDown);
|
||||
this.dataTable.on("keyup", "td", this.keyUp);
|
||||
|
||||
// Keyboard navigation - selecting first row if the selection is empty when the table gains focus.
|
||||
this.dataTable.on("focus", () => {
|
||||
this.selectFirstIfNeeded();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Bind drag & drop behavior
|
||||
$("body").on("drop", this.itemDropped);
|
||||
}
|
||||
|
||||
public focusTable(): void {
|
||||
this.dataTable.focus();
|
||||
}
|
||||
}
|
||||
192
src/Explorer/Tables/DataTable/DataTableOperations.ts
Normal file
192
src/Explorer/Tables/DataTable/DataTableOperations.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import _ from "underscore";
|
||||
import Q from "q";
|
||||
|
||||
import * as Entities from "../Entities";
|
||||
import * as QueryBuilderConstants from "../Constants";
|
||||
import * as Utilities from "../Utilities";
|
||||
|
||||
export function getRowSelector(selectorSchema: Entities.IProperty[]): string {
|
||||
var selector: string = "";
|
||||
selectorSchema &&
|
||||
selectorSchema.forEach((p: Entities.IProperty) => {
|
||||
selector += "[" + p.key + '="' + Utilities.jQuerySelectorEscape(p.value) + '"]';
|
||||
});
|
||||
return QueryBuilderConstants.htmlSelectors.dataTableAllRowsSelector + selector;
|
||||
}
|
||||
|
||||
export function isRowVisible(dataTableScrollBodyQuery: JQuery, element: HTMLElement): boolean {
|
||||
var isVisible = false;
|
||||
|
||||
if (dataTableScrollBodyQuery.length && element) {
|
||||
var elementRect: ClientRect = element.getBoundingClientRect(),
|
||||
dataTableScrollBodyRect: ClientRect = dataTableScrollBodyQuery.get(0).getBoundingClientRect();
|
||||
|
||||
isVisible = elementRect.bottom <= dataTableScrollBodyRect.bottom && dataTableScrollBodyRect.top <= elementRect.top;
|
||||
}
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
export function scrollToRowIfNeeded(dataTableRows: JQuery, currentIndex: number, isScrollUp: boolean): void {
|
||||
if (dataTableRows.length) {
|
||||
var dataTableScrollBodyQuery: JQuery = $(QueryBuilderConstants.htmlSelectors.dataTableScrollBodySelector),
|
||||
selectedRowElement: HTMLElement = dataTableRows.get(currentIndex);
|
||||
|
||||
if (dataTableScrollBodyQuery.length && selectedRowElement) {
|
||||
var isVisible: boolean = isRowVisible(dataTableScrollBodyQuery, selectedRowElement);
|
||||
|
||||
if (!isVisible) {
|
||||
var selectedRowQuery: JQuery = $(selectedRowElement),
|
||||
scrollPosition: number = dataTableScrollBodyQuery.scrollTop(),
|
||||
selectedElementPosition: number = selectedRowQuery.position().top,
|
||||
newScrollPosition: number = 0;
|
||||
|
||||
if (isScrollUp) {
|
||||
newScrollPosition = scrollPosition + selectedElementPosition;
|
||||
} else {
|
||||
newScrollPosition =
|
||||
scrollPosition + (selectedElementPosition + selectedRowQuery.height() - dataTableScrollBodyQuery.height());
|
||||
}
|
||||
|
||||
dataTableScrollBodyQuery.scrollTop(newScrollPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToTopIfNeeded(): void {
|
||||
var $dataTableRows: JQuery = $(QueryBuilderConstants.htmlSelectors.dataTableAllRowsSelector),
|
||||
$dataTableScrollBody: JQuery = $(QueryBuilderConstants.htmlSelectors.dataTableScrollBodySelector);
|
||||
|
||||
if ($dataTableRows.length && $dataTableScrollBody.length) {
|
||||
$dataTableScrollBody.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function setPaginationButtonEventHandlers(): void {
|
||||
$(QueryBuilderConstants.htmlSelectors.dataTablePaginationButtonSelector)
|
||||
.on("mousedown", (event: JQueryEventObject) => {
|
||||
// Prevents the table contents from briefly jumping when clicking on "Load more"
|
||||
event.preventDefault();
|
||||
})
|
||||
.attr("role", "button");
|
||||
}
|
||||
|
||||
export function filterColumns(table: DataTables.DataTable, settings: boolean[]): void {
|
||||
settings &&
|
||||
settings.forEach((value: boolean, index: number) => {
|
||||
table.column(index).visible(value, false);
|
||||
});
|
||||
table.columns.adjust().draw(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder columns based on current order.
|
||||
* If no current order is specified, reorder the columns based on intial order.
|
||||
*/
|
||||
export function reorderColumns(
|
||||
table: DataTables.DataTable,
|
||||
targetOrder: number[],
|
||||
currentOrder?: number[]
|
||||
): Q.Promise<any> {
|
||||
var columnsCount: number = targetOrder.length;
|
||||
var isCurrentOrderPassedIn: boolean = !!currentOrder;
|
||||
if (!isCurrentOrderPassedIn) {
|
||||
currentOrder = getInitialOrder(columnsCount);
|
||||
}
|
||||
var isSameOrder: boolean = Utilities.isEqual(currentOrder, targetOrder);
|
||||
|
||||
// if the targetOrder is the same as current order, do nothing.
|
||||
if (!isSameOrder) {
|
||||
// Otherwise, calculate the transformation order.
|
||||
// If current order not specified, then it'll be set to initial order,
|
||||
// i.e., either no reorder happened before or reordering to its initial order,
|
||||
// Then the transformation order will be the same as target order.
|
||||
// If current order is specified, then a transformation order is calculated.
|
||||
// Refer to calculateTransformationOrder for details about transformation order.
|
||||
var transformationOrder: number[] = isCurrentOrderPassedIn
|
||||
? calculateTransformationOrder(currentOrder, targetOrder)
|
||||
: targetOrder;
|
||||
try {
|
||||
$.fn.dataTable.ColReorder(table).fnOrder(transformationOrder);
|
||||
} catch (err) {
|
||||
return Q.reject(err);
|
||||
}
|
||||
}
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
export function resetColumns(table: DataTables.DataTable): void {
|
||||
$.fn.dataTable.ColReorder(table).fnReset();
|
||||
}
|
||||
|
||||
/**
|
||||
* A table's initial order is described in the form of a natural ascending order.
|
||||
* E.g., for a table with 9 columns, the initial order will be: [0, 1, 2, 3, 4, 5, 6, 7, 8]
|
||||
*/
|
||||
export function getInitialOrder(columnsCount: number): number[] {
|
||||
return _.range(columnsCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current table's column order which is described based on initial table. E.g.,
|
||||
* Initial order: I = [0, 1, 2, 3, 4, 5, 6, 7, 8] <----> {prop0, prop1, prop2, prop3, prop4, prop5, prop6, prop7, prop8}
|
||||
* Current order: C = [0, 1, 2, 6, 7, 3, 4, 5, 8] <----> {prop0, prop1, prop2, prop6, prop7, prop3, prop4, prop5, prop8}
|
||||
*/
|
||||
export function getCurrentOrder(table: DataTables.DataTable): number[] {
|
||||
return $.fn.dataTable.ColReorder(table).fnOrder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the index and value for each element of an array. e.g.,
|
||||
* InputArray: [0, 1, 2, 6, 7, 3, 4, 5, 8]
|
||||
* Result: [0, 1, 2, 5, 6, 7, 3, 4, 8]
|
||||
*/
|
||||
export function invertIndexValues(inputArray: number[]): number[] {
|
||||
var invertedArray: number[] = [];
|
||||
if (inputArray) {
|
||||
inputArray.forEach((value: number, index: number) => {
|
||||
invertedArray[inputArray[index]] = index;
|
||||
});
|
||||
}
|
||||
|
||||
return invertedArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTable fnOrder API is based on the current table. So we need to map the order targeting original table to targeting current table.
|
||||
* An detailed example for this. Assume the table has 9 columns.
|
||||
* Initial order (order of the initial table): I = [0, 1, 2, 3, 4, 5, 6, 7, 8] <----> {prop0, prop1, prop2, prop3, prop4, prop5, prop6, prop7, prop8}
|
||||
* Current order (order of the current table): C = [0, 1, 2, 6, 7, 3, 4, 5, 8] <----> {prop0, prop1, prop2, prop6, prop7, prop3, prop4, prop5, prop8}
|
||||
* Target order (order of the targeting table): T = [0, 1, 2, 5, 6, 7, 8, 3, 4] <----> {prop0, prop1, prop2, prop5, prop6, prop7, prop8, prop3, prop4}
|
||||
* Transformation order: an order passed to fnOrder API that transforms table from current order to target order.
|
||||
* When the table is constructed, it has the intial order. After an reordering with current order array, now the table is shown in current order, e.g.,
|
||||
* column 3 in the current table is actually column C[3]=6 in the intial table, both indicate the column with header prop6.
|
||||
* Now we want to continue to do another reorder to make the target table in the target order. Directly invoking API with the new order won't work as
|
||||
* the API only do reorder based on the current table like the first time we invoke the API. So an order based on the current table needs to be calulated.
|
||||
* Here is an example of how to calculate the transformation order:
|
||||
* In target table, column 3 should be column T[3]=5 in the intial table with header prop5, while in current table, column with header prop5 is column 7 as C[7]=5.
|
||||
* As a result, in transformation order, column 3 in the target table should be column 7 in the current table, Trans[3] = 7. In the same manner, we can get the
|
||||
* transformation order: Trans = [0, 1, 2, 7, 3, 4, 8, 5, 6]
|
||||
*/
|
||||
export function calculateTransformationOrder(currentOrder: number[], targetOrder: number[]): number[] {
|
||||
var transformationOrder: number[] = [];
|
||||
if (currentOrder && targetOrder && currentOrder.length === targetOrder.length) {
|
||||
var invertedCurrentOrder: number[] = invertIndexValues(currentOrder);
|
||||
transformationOrder = targetOrder.map((value: number) => invertedCurrentOrder[value]);
|
||||
}
|
||||
return transformationOrder;
|
||||
}
|
||||
|
||||
export function getDataTableHeaders(table: DataTables.DataTable): string[] {
|
||||
var columns: DataTables.ColumnsMethods = table.columns();
|
||||
var headers: string[] = [];
|
||||
if (columns) {
|
||||
// table.columns() return ColumnsMethods which is an array of arrays
|
||||
var columnIndexes: number[] = (<any>columns)[0];
|
||||
if (columnIndexes) {
|
||||
headers = columnIndexes.map((value: number) => $(table.columns(value).header()).html());
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
148
src/Explorer/Tables/DataTable/DataTableUtilities.ts
Normal file
148
src/Explorer/Tables/DataTable/DataTableUtilities.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as _ from "underscore";
|
||||
import * as Constants from "../Constants";
|
||||
import * as Entities from "../Entities";
|
||||
import * as TableEntityProcessor from "../TableEntityProcessor";
|
||||
|
||||
export enum IconState {
|
||||
default,
|
||||
hoverState,
|
||||
toggleOn
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an html input element shown in context menu.
|
||||
* name: the input name
|
||||
* type: the input type, e.g., "text", "checkbox", "radio", etc.
|
||||
* selected: optional. Used when the input type is checkbox. True means checkbox is selected. Otherwise, unselected.
|
||||
*/
|
||||
export interface IContextMenuInputItem {
|
||||
name: string;
|
||||
type: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface IContextMenuOption {
|
||||
[key: string]: IContextMenuInputItem;
|
||||
}
|
||||
|
||||
export function containMultipleItems<T>(items: T[]): boolean {
|
||||
return items && items.length > 1;
|
||||
}
|
||||
|
||||
export function containSingleItem<T>(items: T[]): boolean {
|
||||
return items && items.length === 1;
|
||||
}
|
||||
|
||||
export function containItems<T>(items: T[]): boolean {
|
||||
return items && items.length > 0;
|
||||
}
|
||||
|
||||
// export function setTargetIcon(idToIconHandlerMap: CloudHub.Common.IToolbarElementIdIconMap, $sourceElement: JQuery, toIconState: IconState): void {
|
||||
// if (idToIconHandlerMap) {
|
||||
// var iconId: string = $sourceElement.attr("id");
|
||||
// var iconHandler = idToIconHandlerMap[iconId];
|
||||
// switch (toIconState) {
|
||||
// case IconState.default:
|
||||
// iconHandler.observable(iconHandler.default);
|
||||
// break;
|
||||
// case IconState.hoverState:
|
||||
// iconHandler.observable(iconHandler.hoverState);
|
||||
// break;
|
||||
// default:
|
||||
// window.console.log("error");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export function addCssClass($sourceElement: JQuery, cssClassName: string): void {
|
||||
if (!$sourceElement.hasClass(cssClassName)) {
|
||||
$sourceElement.addClass(cssClassName);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCssClass($sourceElement: JQuery, cssClassName: string): void {
|
||||
if ($sourceElement.hasClass(cssClassName)) {
|
||||
$sourceElement.removeClass(cssClassName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the property union of input entities.
|
||||
* Example:
|
||||
* Input:
|
||||
* Entities: [{ PrimaryKey, id, Prop1, Prop2 }, { PrimaryKey, id, Prop2, Prop3, Prop4 }]
|
||||
* Return:
|
||||
* Union: [PrimaryKey, id, Prop1, Prop2, Prop3, Prop4]
|
||||
*/
|
||||
export function getPropertyIntersectionFromTableEntities(
|
||||
entities: Entities.ITableEntity[],
|
||||
isCassandraApi: boolean
|
||||
): string[] {
|
||||
var headerUnion: string[] = [];
|
||||
entities &&
|
||||
entities.forEach((row: any) => {
|
||||
const keys = Object.keys(row);
|
||||
keys &&
|
||||
keys.forEach((key: string) => {
|
||||
if (
|
||||
key !== ".metadata" &&
|
||||
!_.contains(headerUnion, key) &&
|
||||
key !== TableEntityProcessor.keyProperties.attachments &&
|
||||
key !== TableEntityProcessor.keyProperties.etag &&
|
||||
key !== TableEntityProcessor.keyProperties.resourceId &&
|
||||
key !== TableEntityProcessor.keyProperties.self &&
|
||||
(!isCassandraApi || key !== Constants.EntityKeyNames.RowKey)
|
||||
) {
|
||||
headerUnion.push(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
return headerUnion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the names of two Azure table columns and returns a number indicating which comes before the other.
|
||||
* System-defined properties come before custom properties. Otherwise they are compared using string comparison.
|
||||
*/
|
||||
export function compareTableColumns(a: string, b: string): number {
|
||||
if (a === "PartitionKey") {
|
||||
if (b !== "PartitionKey") {
|
||||
return -1;
|
||||
}
|
||||
} else if (a === "RowKey") {
|
||||
if (b === "PartitionKey") {
|
||||
return 1;
|
||||
} else if (b !== "RowKey") {
|
||||
return -1;
|
||||
}
|
||||
} else if (a === "Timestamp") {
|
||||
if (b === "PartitionKey" || b === "RowKey") {
|
||||
return 1;
|
||||
} else if (b !== "Timestamp") {
|
||||
return -1;
|
||||
}
|
||||
} else if (b === "PartitionKey" || b === "RowKey" || b === "Timestamp") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export function checkForDefaultHeader(headers: string[]): boolean {
|
||||
return headers[0] === Constants.defaultHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableBindingManager registers an event handler of body.resize and recalculates the data table size.
|
||||
* This method forces the event to happen.
|
||||
*/
|
||||
export function forceRecalculateTableSize(): void {
|
||||
$("body").trigger("resize");
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off the spinning progress indicator on the data table.
|
||||
*/
|
||||
export function turnOffProgressIndicator(): void {
|
||||
$("div.dataTables_processing").hide();
|
||||
}
|
||||
270
src/Explorer/Tables/DataTable/DataTableViewModel.ts
Normal file
270
src/Explorer/Tables/DataTable/DataTableViewModel.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import CacheBase from "./CacheBase";
|
||||
import * as CommonConstants from "../../../Common/Constants";
|
||||
import * as Constants from "../Constants";
|
||||
import * as Entities from "../Entities";
|
||||
import QueryTablesTab from "../../Tabs/QueryTablesTab";
|
||||
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { QueryIterator, ItemDefinition, Resource } from "@azure/cosmos";
|
||||
|
||||
// This is the format of the data we will have to pass to Datatable render callback,
|
||||
// and property names are defined by Datatable as well.
|
||||
export interface IDataTableRenderData {
|
||||
draw: number;
|
||||
aaData: any;
|
||||
recordsTotal: number;
|
||||
recordsFiltered: number;
|
||||
}
|
||||
|
||||
abstract class DataTableViewModel {
|
||||
private static lastPageLabel = ">>"; // Localize
|
||||
private static loadMoreLabel = "Load more"; // Localize
|
||||
|
||||
/* Observables */
|
||||
public items = ko.observableArray<Entities.ITableEntity>();
|
||||
public selected = ko.observableArray<Entities.ITableEntity>();
|
||||
|
||||
public table: DataTables.DataTable;
|
||||
|
||||
// The anchor item is for shift selection. i.e., select all items between anchor item and a give item.
|
||||
public lastSelectedAnchorItem: Entities.ITableEntity;
|
||||
public lastSelectedItem: Entities.ITableEntity;
|
||||
|
||||
public cache: CacheBase<Entities.ITableEntity>;
|
||||
|
||||
protected continuationToken: any;
|
||||
protected allDownloaded: boolean;
|
||||
protected lastPrefetchTime: number;
|
||||
protected downloadSize = 300;
|
||||
protected _documentIterator: QueryIterator<ItemDefinition & Resource>;
|
||||
|
||||
// Used by table redraw throttling
|
||||
protected pollingInterval = 1000;
|
||||
private redrawInterval = 500;
|
||||
private pendingRedraw = false;
|
||||
private lastRedrawTime = new Date().getTime();
|
||||
|
||||
private dataTableOperationManager: IDataTableOperation;
|
||||
|
||||
public queryTablesTab: QueryTablesTab;
|
||||
|
||||
constructor() {
|
||||
this.items([]);
|
||||
this.selected([]);
|
||||
// Late bound
|
||||
this.dataTableOperationManager = null;
|
||||
}
|
||||
|
||||
public bind(dataTableOperationManager: IDataTableOperation): void {
|
||||
this.dataTableOperationManager = dataTableOperationManager;
|
||||
}
|
||||
|
||||
public clearLastSelected(): void {
|
||||
this.lastSelectedItem = null;
|
||||
this.lastSelectedAnchorItem = null;
|
||||
}
|
||||
|
||||
public clearCache(): void {
|
||||
this.cache.clear();
|
||||
this._documentIterator = null;
|
||||
this.continuationToken = null;
|
||||
this.allDownloaded = false;
|
||||
}
|
||||
|
||||
public clearSelection(): void {
|
||||
this.selected.removeAll();
|
||||
}
|
||||
|
||||
// Redraws the table, but guarantees that multiple sequential calls will not incur
|
||||
// another redraw until a certain time interval has passed.
|
||||
public redrawTableThrottled() {
|
||||
if (!this.pendingRedraw) {
|
||||
this.pendingRedraw = true;
|
||||
|
||||
var current = new Date().getTime();
|
||||
var timeSinceLastRedraw = current - this.lastRedrawTime;
|
||||
var redraw = () => {
|
||||
this.table.draw(false /*reset*/);
|
||||
this.lastRedrawTime = new Date().getTime();
|
||||
this.pendingRedraw = false;
|
||||
};
|
||||
|
||||
if (timeSinceLastRedraw > this.redrawInterval) {
|
||||
redraw();
|
||||
} else {
|
||||
var timeUntilNextRedraw = this.redrawInterval - timeSinceLastRedraw;
|
||||
setTimeout(() => redraw(), timeUntilNextRedraw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public focusDataTable(): void {
|
||||
this.dataTableOperationManager.focusTable();
|
||||
}
|
||||
|
||||
public getItemFromSelectedItems(itemKeys: Entities.IProperty[]): Entities.ITableEntity {
|
||||
return _.find(this.selected(), (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public getItemFromCurrentPage(itemKeys: Entities.IProperty[]): Entities.ITableEntity {
|
||||
return _.find(this.items(), (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public getItemIndexFromCurrentPage(itemKeys: Entities.IProperty[]): number {
|
||||
return _.findIndex(this.items(), (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public getItemIndexFromAllPages(itemKeys: Entities.IProperty[]): number {
|
||||
return _.findIndex(this.cache.data, (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public getItemsFromAllPagesWithinRange(start: number, end: number): Entities.ITableEntity[] {
|
||||
return this.cache.data.slice(start, end);
|
||||
}
|
||||
|
||||
public isItemSelected(itemKeys: Entities.IProperty[]): boolean {
|
||||
return _.some(this.selected(), (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public isItemCached(itemKeys: Entities.IProperty[]): boolean {
|
||||
return _.some(this.cache.data, (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
public getAllItemsInCurrentPage(): Entities.ITableEntity[] {
|
||||
return this.items();
|
||||
}
|
||||
|
||||
public getAllItemsInCache(): Entities.ITableEntity[] {
|
||||
return this.cache.data;
|
||||
}
|
||||
|
||||
protected abstract dataComparer(
|
||||
item1: Entities.ITableEntity,
|
||||
item2: Entities.ITableEntity,
|
||||
sortOrder: any,
|
||||
oSettings: any
|
||||
): number;
|
||||
protected abstract isCacheValid(validator: any): boolean;
|
||||
|
||||
protected sortColumns(sortOrder: any, oSettings: any) {
|
||||
var self = this;
|
||||
this.clearSelection();
|
||||
this.cache.data.sort(function(a: any, b: any) {
|
||||
return self.dataComparer(a, b, sortOrder, oSettings);
|
||||
});
|
||||
this.cache.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
protected renderPage(
|
||||
renderCallBack: any,
|
||||
draw: number,
|
||||
startIndex: number,
|
||||
pageSize: number,
|
||||
oSettings: any,
|
||||
postRenderTasks: (startIndex: number, pageSize: number) => Promise<void> = null
|
||||
) {
|
||||
this.updatePaginationControls(oSettings);
|
||||
|
||||
// pageSize < 0 means to show all data
|
||||
var endIndex = pageSize < 0 ? this.cache.length : startIndex + pageSize;
|
||||
var renderData = this.cache.data.slice(startIndex, endIndex);
|
||||
|
||||
this.items(renderData);
|
||||
|
||||
var render: IDataTableRenderData = {
|
||||
draw: draw,
|
||||
aaData: renderData,
|
||||
recordsTotal: this.cache.length,
|
||||
recordsFiltered: this.cache.length
|
||||
};
|
||||
|
||||
if (!!postRenderTasks) {
|
||||
postRenderTasks(startIndex, pageSize).then(() => {
|
||||
this.table.rows().invalidate();
|
||||
});
|
||||
}
|
||||
renderCallBack(render);
|
||||
if (this.queryTablesTab.onLoadStartKey != null && this.queryTablesTab.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.queryTablesTab.collection.container.databaseAccount().name,
|
||||
databaseName: this.queryTablesTab.collection.databaseId,
|
||||
collectionName: this.queryTablesTab.collection.id(),
|
||||
defaultExperience: this.queryTablesTab.collection.container.defaultExperience(),
|
||||
dataExplorerArea: CommonConstants.Areas.Tab,
|
||||
tabTitle: this.queryTablesTab.tabTitle()
|
||||
},
|
||||
this.queryTablesTab.onLoadStartKey
|
||||
);
|
||||
this.queryTablesTab.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected matchesKeys(item: Entities.ITableEntity, itemKeys: Entities.IProperty[]): boolean {
|
||||
return itemKeys.every((property: Entities.IProperty) => {
|
||||
var itemValue = item[property.key];
|
||||
|
||||
// if (itemValue && property.subkey) {
|
||||
// itemValue = itemValue._[property.subkey];
|
||||
// if (!itemValue) {
|
||||
// itemValue = "";
|
||||
// }
|
||||
// } else if (property.subkey) {
|
||||
// itemValue = "";
|
||||
// }
|
||||
|
||||
return this.stringCompare(itemValue._, property.value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default string comparison is case sensitive as most Azure resources' names are case sensitive.
|
||||
* Override this if a name, i.e., Azure File/Directory name, is case insensitive.
|
||||
*/
|
||||
protected stringCompare(s1: string, s2: string): boolean {
|
||||
return s1 === s2;
|
||||
}
|
||||
|
||||
private updatePaginationControls(oSettings: any) {
|
||||
var pageInfo = this.table.page.info();
|
||||
var pageSize = pageInfo.length;
|
||||
var paginateElement = $(oSettings.nTableWrapper).find(Constants.htmlSelectors.paginateSelector);
|
||||
|
||||
if (this.allDownloaded) {
|
||||
if (this.cache.length <= pageSize) {
|
||||
// Hide pagination controls if everything fits in one page!.
|
||||
paginateElement.hide();
|
||||
} else {
|
||||
// Enable pagination controls.
|
||||
paginateElement.show();
|
||||
oSettings.oLanguage.oPaginate.sLast = DataTableViewModel.lastPageLabel;
|
||||
}
|
||||
} else {
|
||||
// Enable pagination controls and show load more button.
|
||||
paginateElement.show();
|
||||
oSettings.oLanguage.oPaginate.sLast = DataTableViewModel.loadMoreLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IDataTableOperation {
|
||||
focusTable(): void;
|
||||
}
|
||||
|
||||
export default DataTableViewModel;
|
||||
155
src/Explorer/Tables/DataTable/TableCommands.ts
Normal file
155
src/Explorer/Tables/DataTable/TableCommands.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import _ from "underscore";
|
||||
import Q from "q";
|
||||
import * as DataTableUtilities from "./DataTableUtilities";
|
||||
import * as DataTableOperations from "./DataTableOperations";
|
||||
import TableEntityListViewModel from "./TableEntityListViewModel";
|
||||
import * as Entities from "../Entities";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as TableColumnOptionsPane from "../../Panes/Tables/TableColumnOptionsPane";
|
||||
|
||||
export default class TableCommands {
|
||||
// Command Ids
|
||||
public static editEntityCommand: string = "edit";
|
||||
public static deleteEntitiesCommand: string = "delete";
|
||||
public static reorderColumnsCommand: string = "reorder";
|
||||
public static resetColumnsCommand: string = "reset";
|
||||
public static customizeColumnsCommand: string = "customizeColumns";
|
||||
|
||||
private _container: ViewModels.Explorer;
|
||||
|
||||
constructor(container: ViewModels.Explorer) {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
public isEnabled(commandName: string, selectedEntites: Entities.ITableEntity[]): boolean {
|
||||
var singleItemSelected: boolean = DataTableUtilities.containSingleItem(selectedEntites);
|
||||
var atLeastOneItemSelected: boolean = DataTableUtilities.containItems(selectedEntites);
|
||||
switch (commandName) {
|
||||
case TableCommands.editEntityCommand:
|
||||
return singleItemSelected;
|
||||
case TableCommands.deleteEntitiesCommand:
|
||||
case TableCommands.reorderColumnsCommand:
|
||||
return atLeastOneItemSelected;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public tryOpenEntityEditor(viewModel: TableEntityListViewModel): boolean {
|
||||
if (this.isEnabled(TableCommands.editEntityCommand, viewModel.selected())) {
|
||||
this.editEntityCommand(viewModel);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit entity
|
||||
*/
|
||||
public editEntityCommand(viewModel: TableEntityListViewModel): Q.Promise<any> {
|
||||
if (!viewModel) {
|
||||
return null; // Error
|
||||
}
|
||||
|
||||
if (!DataTableUtilities.containSingleItem(viewModel.selected())) {
|
||||
return null; // Erorr
|
||||
}
|
||||
|
||||
var entityToUpdate: Entities.ITableEntity = viewModel.selected()[0];
|
||||
var originalNumberOfProperties = entityToUpdate ? 0 : Object.keys(entityToUpdate).length - 1; // .metadata is always a property for etag
|
||||
|
||||
this._container.editTableEntityPane.originEntity = entityToUpdate;
|
||||
this._container.editTableEntityPane.tableViewModel = viewModel;
|
||||
this._container.editTableEntityPane.originalNumberOfProperties = originalNumberOfProperties;
|
||||
this._container.editTableEntityPane.open();
|
||||
return null;
|
||||
}
|
||||
|
||||
public deleteEntitiesCommand(viewModel: TableEntityListViewModel): Q.Promise<any> {
|
||||
if (!viewModel) {
|
||||
return null; // Error
|
||||
}
|
||||
if (!DataTableUtilities.containItems(viewModel.selected())) {
|
||||
return null; // Error
|
||||
}
|
||||
var entitiesToDelete: Entities.ITableEntity[] = viewModel.selected();
|
||||
let deleteMessage: string = "Are you sure you want to delete the selected entities?";
|
||||
if (viewModel.queryTablesTab.container.isPreferredApiCassandra()) {
|
||||
deleteMessage = "Are you sure you want to delete the selected rows?";
|
||||
}
|
||||
if (window.confirm(deleteMessage)) {
|
||||
viewModel.queryTablesTab.container.tableDataClient
|
||||
.deleteDocuments(viewModel.queryTablesTab.collection, entitiesToDelete)
|
||||
.then((results: any) => {
|
||||
return viewModel.removeEntitiesFromCache(entitiesToDelete).then(() => {
|
||||
viewModel.redrawTableThrottled();
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public customizeColumnsCommand(viewModel: TableEntityListViewModel): Q.Promise<any> {
|
||||
var table: DataTables.DataTable = viewModel.table;
|
||||
var displayedColumnNames: string[] = DataTableOperations.getDataTableHeaders(table);
|
||||
var columnsCount: number = displayedColumnNames.length;
|
||||
var currentOrder: number[] = DataTableOperations.getInitialOrder(columnsCount);
|
||||
//Debug.assert(!!table && !!currentOrder && displayedColumnNames.length === currentOrder.length);
|
||||
|
||||
var currentSettings: boolean[];
|
||||
try {
|
||||
currentSettings = currentOrder.map((value: number, index: number) => {
|
||||
return table.column(index).visible();
|
||||
});
|
||||
} catch (err) {
|
||||
// Error
|
||||
}
|
||||
|
||||
let parameters: TableColumnOptionsPane.IColumnSetting = <TableColumnOptionsPane.IColumnSetting>{
|
||||
columnNames: displayedColumnNames,
|
||||
order: currentOrder,
|
||||
visible: currentSettings
|
||||
};
|
||||
|
||||
this._container.tableColumnOptionsPane.tableViewModel = viewModel;
|
||||
this._container.tableColumnOptionsPane.parameters = parameters;
|
||||
this._container.tableColumnOptionsPane.open();
|
||||
return null;
|
||||
}
|
||||
|
||||
public reorderColumnsBasedOnSelectedEntities(viewModel: TableEntityListViewModel): Q.Promise<boolean> {
|
||||
var selected = viewModel.selected();
|
||||
if (!selected || !selected.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var table = viewModel.table;
|
||||
var currentColumnNames: string[] = DataTableOperations.getDataTableHeaders(table);
|
||||
var headersCount: number = currentColumnNames.length;
|
||||
|
||||
var headersUnion: string[] = DataTableUtilities.getPropertyIntersectionFromTableEntities(
|
||||
selected,
|
||||
viewModel.queryTablesTab.container.isPreferredApiCassandra()
|
||||
);
|
||||
|
||||
// An array with elements representing indexes of selected entities' header union out of initial headers.
|
||||
var orderOfLeftHeaders: number[] = headersUnion.map((item: string) => currentColumnNames.indexOf(item));
|
||||
|
||||
// An array with elements representing initial order of the table.
|
||||
var initialOrder: number[] = DataTableOperations.getInitialOrder(headersCount);
|
||||
|
||||
// An array with elements representing indexes of headers not present in selected entities' header union.
|
||||
var orderOfRightHeaders: number[] = _.difference(initialOrder, orderOfLeftHeaders);
|
||||
|
||||
// This will be the target order, with headers in selected entities on the left while others on the right, both in the initial order, respectively.
|
||||
var targetOrder: number[] = orderOfLeftHeaders.concat(orderOfRightHeaders);
|
||||
|
||||
return DataTableOperations.reorderColumns(table, targetOrder);
|
||||
}
|
||||
|
||||
public resetColumns(viewModel: TableEntityListViewModel): void {
|
||||
viewModel.reloadTable();
|
||||
}
|
||||
}
|
||||
27
src/Explorer/Tables/DataTable/TableEntityCache.ts
Normal file
27
src/Explorer/Tables/DataTable/TableEntityCache.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as Utilities from "../Utilities";
|
||||
import * as Entities from "../Entities";
|
||||
import CacheBase from "./CacheBase";
|
||||
|
||||
export default class TableEntityCache extends CacheBase<Entities.ITableEntity> {
|
||||
private _tableQuery: Entities.ITableQuery;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.data = null;
|
||||
this._tableQuery = null;
|
||||
this.serverCallInProgress = false;
|
||||
this.sortOrder = null;
|
||||
}
|
||||
|
||||
public get tableQuery(): Entities.ITableQuery {
|
||||
return Utilities.copyTableQuery(this._tableQuery);
|
||||
}
|
||||
|
||||
public set tableQuery(tableQuery: Entities.ITableQuery) {
|
||||
this._tableQuery = Utilities.copyTableQuery(tableQuery);
|
||||
}
|
||||
|
||||
public preClear() {
|
||||
this.tableQuery = null;
|
||||
}
|
||||
}
|
||||
541
src/Explorer/Tables/DataTable/TableEntityListViewModel.ts
Normal file
541
src/Explorer/Tables/DataTable/TableEntityListViewModel.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
import Q from "q";
|
||||
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import { CassandraTableKey, CassandraAPIDataClient } from "../TableDataClient";
|
||||
import DataTableViewModel from "./DataTableViewModel";
|
||||
import DataTableContextMenu from "./DataTableContextMenu";
|
||||
import * as DataTableUtilities from "./DataTableUtilities";
|
||||
import TableCommands from "./TableCommands";
|
||||
import TableEntityCache from "./TableEntityCache";
|
||||
import * as Constants from "../Constants";
|
||||
import { Areas } from "../../../Common/Constants";
|
||||
import * as Utilities from "../Utilities";
|
||||
import * as Entities from "../Entities";
|
||||
import QueryTablesTab from "../../Tabs/QueryTablesTab";
|
||||
import * as TableEntityProcessor from "../TableEntityProcessor";
|
||||
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as ErrorParserUtility from "../../../Common/ErrorParserUtility";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
interface IListTableEntitiesSegmentedResult extends Entities.IListTableEntitiesResult {
|
||||
ExceedMaximumRetries?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage Table Entity List ViewModel
|
||||
*/
|
||||
export default class TableEntityListViewModel extends DataTableViewModel {
|
||||
// This is the number of retry attempts to fetch entities when the Azure Table service returns no results with a continuation token.
|
||||
// This number should ideally accommodate the service default timeout for queries of 30s, where each individual query execution can
|
||||
// take *up* to 5s (see https://msdn.microsoft.com/en-us/library/azure/dd135718.aspx).
|
||||
// To be on the safe side, we are setting the total number of attempts to 120, assuming up to 4 queries per second (120q = 30s * 4q/s).
|
||||
// Experimentation also validates this "safe number": queries against a 10 million entity table took up to 13 fetch attempts.
|
||||
private static _maximumNumberOfPrefetchRetries = 120 - 1;
|
||||
|
||||
/* Observables */
|
||||
public headers: string[] = [Constants.defaultHeader];
|
||||
public useSetting: boolean = true;
|
||||
|
||||
//public tableExplorerContext: TableExplorerContext;
|
||||
public notifyColumnChanges: (enablePrompt: boolean, queryTablesTab: QueryTablesTab) => void;
|
||||
public tablePageStartIndex: number;
|
||||
public tableQuery: Entities.ITableQuery = {};
|
||||
public cqlQuery: ko.Observable<string>;
|
||||
public oDataQuery: ko.Observable<string>;
|
||||
public sqlQuery: ko.Observable<string>;
|
||||
public cache: TableEntityCache;
|
||||
public isCancelled: boolean = false;
|
||||
public queryErrorMessage: ko.Observable<string>;
|
||||
public id: string;
|
||||
|
||||
constructor(tableCommands: TableCommands, queryTablesTab: QueryTablesTab) {
|
||||
super();
|
||||
this.cache = new TableEntityCache();
|
||||
this.queryErrorMessage = ko.observable<string>();
|
||||
this.queryTablesTab = queryTablesTab;
|
||||
// Enable Context menu for the data table.
|
||||
DataTableContextMenu.contextMenuFactory(this, tableCommands);
|
||||
this.id = `tableEntityListViewModel${this.queryTablesTab.tabId}`;
|
||||
this.cqlQuery = ko.observable<string>(
|
||||
`SELECT * FROM ${this.queryTablesTab.collection.databaseId}.${this.queryTablesTab.collection.id()}`
|
||||
);
|
||||
this.oDataQuery = ko.observable<string>();
|
||||
this.sqlQuery = ko.observable<string>("SELECT * FROM c");
|
||||
}
|
||||
|
||||
public getTableEntityKeys(rowKey: string): Entities.IProperty[] {
|
||||
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
|
||||
}
|
||||
|
||||
public reloadTable(useSetting: boolean = true, resetHeaders: boolean = true): DataTables.DataTable {
|
||||
this.clearCache();
|
||||
this.clearSelection();
|
||||
this.isCancelled = false;
|
||||
|
||||
this.useSetting = useSetting;
|
||||
if (resetHeaders) {
|
||||
this.updateHeaders([Constants.defaultHeader]);
|
||||
}
|
||||
return this.table.ajax.reload();
|
||||
}
|
||||
|
||||
public updateHeaders(newHeaders: string[], notifyColumnChanges: boolean = false, enablePrompt: boolean = true): void {
|
||||
this.headers = newHeaders;
|
||||
if (notifyColumnChanges) {
|
||||
this.clearSelection();
|
||||
this.notifyColumnChanges(enablePrompt, this.queryTablesTab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This callback function called by datatable to fetch the next page of data and render.
|
||||
* sSource - ajax URL of data source, ignored in our case as we are not using ajax.
|
||||
* aoData - details about the next page of data datatable expected to render.
|
||||
* fnCallback - is the render callback with data to render.
|
||||
* oSetting: current settings used for table initialization.
|
||||
*/
|
||||
public renderNextPageAndupdateCache(sSource: any, aoData: any, fnCallback: any, oSettings: any) {
|
||||
var tablePageSize: number;
|
||||
var draw: number;
|
||||
var prefetchNeeded = true;
|
||||
var columnSortOrder: any;
|
||||
// Threshold(pages) for triggering cache prefetch.
|
||||
// If number remaining pages in cache falls below prefetchThreshold prefetch will be triggered.
|
||||
var prefetchThreshold = 10;
|
||||
var tableQuery = this.tableQuery;
|
||||
|
||||
for (var index in aoData) {
|
||||
var data = aoData[index];
|
||||
if (data.name === "length") {
|
||||
tablePageSize = data.value;
|
||||
}
|
||||
if (data.name === "start") {
|
||||
this.tablePageStartIndex = data.value;
|
||||
}
|
||||
if (data.name === "draw") {
|
||||
draw = data.value;
|
||||
}
|
||||
if (data.name === "order") {
|
||||
columnSortOrder = data.value;
|
||||
}
|
||||
}
|
||||
// Try cache if valid.
|
||||
if (this.isCacheValid(tableQuery)) {
|
||||
// Check if prefetch needed.
|
||||
if (this.tablePageStartIndex + tablePageSize <= this.cache.length || this.allDownloaded) {
|
||||
prefetchNeeded = false;
|
||||
if (columnSortOrder && (!this.cache.sortOrder || !_.isEqual(this.cache.sortOrder, columnSortOrder))) {
|
||||
this.sortColumns(columnSortOrder, oSettings);
|
||||
}
|
||||
this.renderPage(fnCallback, draw, this.tablePageStartIndex, tablePageSize, oSettings);
|
||||
if (
|
||||
!this.allDownloaded &&
|
||||
this.tablePageStartIndex > 0 && // This is a case now that we can hit this as we re-construct table when we update column
|
||||
this.cache.length - this.tablePageStartIndex + tablePageSize < prefetchThreshold * tablePageSize
|
||||
) {
|
||||
prefetchNeeded = true;
|
||||
}
|
||||
} else {
|
||||
prefetchNeeded = true;
|
||||
}
|
||||
} else {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
if (prefetchNeeded) {
|
||||
var downloadSize = tableQuery.top || this.downloadSize;
|
||||
this.prefetchAndRender(
|
||||
tableQuery,
|
||||
this.tablePageStartIndex,
|
||||
tablePageSize,
|
||||
downloadSize,
|
||||
draw,
|
||||
fnCallback,
|
||||
oSettings,
|
||||
columnSortOrder
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public addEntityToCache(entity: Entities.ITableEntity): Q.Promise<any> {
|
||||
// Delay the add operation if we are fetching data from server, so as to avoid race condition.
|
||||
if (this.cache.serverCallInProgress) {
|
||||
return Utilities.delay(this.pollingInterval).then(() => {
|
||||
return this.updateCachedEntity(entity);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first item which is greater than the added entity.
|
||||
var oSettings: any = (<any>this.table).context[0];
|
||||
var index: number = _.findIndex(this.cache.data, (data: any) => {
|
||||
return this.dataComparer(data, entity, this.cache.sortOrder, oSettings) > 0;
|
||||
});
|
||||
|
||||
// If no such item, then insert at last.
|
||||
var insertIndex: number = Utilities.ensureBetweenBounds(
|
||||
index < 0 ? this.cache.length : index,
|
||||
0,
|
||||
this.cache.length
|
||||
);
|
||||
|
||||
this.cache.data.splice(insertIndex, 0, entity);
|
||||
|
||||
// Finally, select newly added entity
|
||||
this.clearSelection();
|
||||
this.selected.push(entity);
|
||||
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
public updateCachedEntity(entity: Entities.ITableEntity): Q.Promise<any> {
|
||||
// Delay the add operation if we are fetching data from server, so as to avoid race condition.
|
||||
if (this.cache.serverCallInProgress) {
|
||||
return Utilities.delay(this.pollingInterval).then(() => {
|
||||
return this.updateCachedEntity(entity);
|
||||
});
|
||||
}
|
||||
var oldEntityIndex: number = _.findIndex(
|
||||
this.cache.data,
|
||||
(data: Entities.ITableEntity) => data.RowKey._ === entity.RowKey._
|
||||
);
|
||||
|
||||
this.cache.data.splice(oldEntityIndex, 1, entity);
|
||||
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
public removeEntitiesFromCache(entities: Entities.ITableEntity[]): Q.Promise<any> {
|
||||
if (!entities) {
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
// Delay the remove operation if we are fetching data from server, so as to avoid race condition.
|
||||
if (this.cache.serverCallInProgress) {
|
||||
return Utilities.delay(this.pollingInterval).then(() => {
|
||||
return this.removeEntitiesFromCache(entities);
|
||||
});
|
||||
}
|
||||
|
||||
entities &&
|
||||
entities.forEach((entity: Entities.ITableEntity) => {
|
||||
var cachedIndex: number = _.findIndex(
|
||||
this.cache.data,
|
||||
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._
|
||||
);
|
||||
if (cachedIndex >= 0) {
|
||||
this.cache.data.splice(cachedIndex, 1);
|
||||
}
|
||||
});
|
||||
this.clearSelection();
|
||||
|
||||
// Show last available page if there is not enough data
|
||||
var pageInfo = this.table.page.info();
|
||||
if (this.cache.length <= pageInfo.start) {
|
||||
var availablePages = Math.ceil(this.cache.length / pageInfo.length);
|
||||
var pageToShow = availablePages > 0 ? availablePages - 1 : 0;
|
||||
this.table.page(pageToShow);
|
||||
}
|
||||
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
protected dataComparer(
|
||||
item1: Entities.ITableEntity,
|
||||
item2: Entities.ITableEntity,
|
||||
sortOrder: any[],
|
||||
oSettings: any
|
||||
): number {
|
||||
var sort: any;
|
||||
var itemA: any;
|
||||
var itemB: any;
|
||||
var length: number = $.isArray(sortOrder) ? sortOrder.length : 0; // sortOrder can be null
|
||||
var rowA: Entities.ITableEntity = item1;
|
||||
var rowB: Entities.ITableEntity = item2;
|
||||
|
||||
for (var k = 0; k < length; k++) {
|
||||
sort = sortOrder[k];
|
||||
var col = oSettings.aoColumns[sort.column].mData;
|
||||
|
||||
// If the value is null or undefined, show them at last
|
||||
var isItem1NullOrUndefined = _.isNull(rowA[col]) || _.isUndefined(rowA[col]);
|
||||
var isItem2NullOrUndefined = _.isNull(rowB[col]) || _.isUndefined(rowB[col]);
|
||||
|
||||
if (isItem1NullOrUndefined || isItem2NullOrUndefined) {
|
||||
if (isItem1NullOrUndefined && isItem2NullOrUndefined) {
|
||||
return 0;
|
||||
}
|
||||
return isItem1NullOrUndefined ? 1 : -1;
|
||||
}
|
||||
|
||||
switch ((<any>rowA[col]).$) {
|
||||
case Constants.TableType.Int32:
|
||||
case Constants.TableType.Int64:
|
||||
case Constants.CassandraType.Int:
|
||||
case Constants.CassandraType.Bigint:
|
||||
case Constants.CassandraType.Smallint:
|
||||
case Constants.CassandraType.Varint:
|
||||
case Constants.CassandraType.Tinyint:
|
||||
itemA = parseInt(<string>(<any>rowA[col])._, 0);
|
||||
itemB = parseInt(<string>(<any>rowB[col])._, 0);
|
||||
break;
|
||||
case Constants.TableType.Double:
|
||||
case Constants.CassandraType.Double:
|
||||
case Constants.CassandraType.Float:
|
||||
case Constants.CassandraType.Decimal:
|
||||
itemA = parseFloat(<string>(<any>rowA[col])._);
|
||||
itemB = parseFloat(<string>(<any>rowB[col])._);
|
||||
break;
|
||||
case Constants.TableType.DateTime:
|
||||
itemA = new Date(<string>(<any>rowA[col])._);
|
||||
itemB = new Date(<string>(<any>rowB[col])._);
|
||||
break;
|
||||
default:
|
||||
itemA = <string>(<any>rowA[col])._.toLowerCase();
|
||||
itemB = <string>(<any>rowB[col])._.toLowerCase();
|
||||
}
|
||||
var compareResult: number = itemA < itemB ? -1 : itemA > itemB ? 1 : 0;
|
||||
if (compareResult !== 0) {
|
||||
return sort.dir === "asc" ? compareResult : -compareResult;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected isCacheValid(tableQuery: Entities.ITableQuery): boolean {
|
||||
// Return false if either cache has no data or the search criteria don't match!
|
||||
if (!this.cache || !this.cache.data || this.cache.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tableQuery && !this.cache.tableQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare by value using JSON representation
|
||||
if (JSON.stringify(this.cache.tableQuery) !== JSON.stringify(tableQuery)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Override as table entity has special keys for a Data Table row.
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
protected matchesKeys(item: Entities.ITableEntity, itemKeys: Entities.IProperty[]): boolean {
|
||||
return itemKeys.every((property: Entities.IProperty) => {
|
||||
return this.stringCompare(item[property.key]._, property.value);
|
||||
});
|
||||
}
|
||||
|
||||
private prefetchAndRender(
|
||||
tableQuery: Entities.ITableQuery,
|
||||
tablePageStartIndex: number,
|
||||
tablePageSize: number,
|
||||
downloadSize: number,
|
||||
draw: number,
|
||||
renderCallBack: Function,
|
||||
oSettings: any,
|
||||
columnSortOrder: any
|
||||
): void {
|
||||
this.queryErrorMessage(null);
|
||||
if (this.cache.serverCallInProgress) {
|
||||
return;
|
||||
}
|
||||
this.prefetchData(tableQuery, downloadSize, /* currentRetry */ 0)
|
||||
.then((result: IListTableEntitiesSegmentedResult) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
var entities = this.cache.data;
|
||||
if (
|
||||
this.queryTablesTab.container.isPreferredApiCassandra() &&
|
||||
DataTableUtilities.checkForDefaultHeader(this.headers)
|
||||
) {
|
||||
(<CassandraAPIDataClient>this.queryTablesTab.container.tableDataClient)
|
||||
.getTableSchema(this.queryTablesTab.collection)
|
||||
.then((headers: CassandraTableKey[]) => {
|
||||
this.updateHeaders(
|
||||
headers.map(header => header.property),
|
||||
true
|
||||
);
|
||||
});
|
||||
} else {
|
||||
var selectedHeadersUnion: string[] = DataTableUtilities.getPropertyIntersectionFromTableEntities(
|
||||
entities,
|
||||
this.queryTablesTab.container.isPreferredApiCassandra()
|
||||
);
|
||||
var newHeaders: string[] = _.difference(selectedHeadersUnion, this.headers);
|
||||
if (newHeaders.length > 0) {
|
||||
// Any new columns found will be added into headers array, which will trigger a re-render of the DataTable.
|
||||
// So there is no need to call it here.
|
||||
this.updateHeaders(newHeaders, /* notifyColumnChanges */ true);
|
||||
} else {
|
||||
if (columnSortOrder) {
|
||||
this.sortColumns(columnSortOrder, oSettings);
|
||||
}
|
||||
this.renderPage(renderCallBack, draw, tablePageStartIndex, tablePageSize, oSettings);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ExceedMaximumRetries) {
|
||||
var message: string = "We are having trouble getting your data. Please try again."; // localize
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
const parsedErrors = ErrorParserUtility.parse(error);
|
||||
var errors = parsedErrors.map((error: DataModels.ErrorDataModel) => {
|
||||
return <ViewModels.QueryError>{
|
||||
message: error.message,
|
||||
start: error.location ? error.location.start : undefined,
|
||||
end: error.location ? error.location.end : undefined,
|
||||
code: error.code,
|
||||
severity: error.severity
|
||||
};
|
||||
});
|
||||
this.queryErrorMessage(errors[0].message);
|
||||
if (this.queryTablesTab.onLoadStartKey != null && this.queryTablesTab.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.queryTablesTab.collection.container.databaseAccount().name,
|
||||
databaseName: this.queryTablesTab.collection.databaseId,
|
||||
collectionName: this.queryTablesTab.collection.id(),
|
||||
defaultExperience: this.queryTablesTab.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.Tab,
|
||||
tabTitle: this.queryTablesTab.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.queryTablesTab.onLoadStartKey
|
||||
);
|
||||
this.queryTablesTab.onLoadStartKey = null;
|
||||
}
|
||||
DataTableUtilities.turnOffProgressIndicator();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep recursively prefetching items if:
|
||||
* 1. Continuation token is not null
|
||||
* 2. And prefetched items hasn't reach predefined cache size.
|
||||
* 3. And retry times hasn't reach the predefined maximum retry number.
|
||||
*
|
||||
* It is possible for a query to return no results but still return a continuation header (e.g. if the query takes too long).
|
||||
* If this is the case, we try to fetch entities again.
|
||||
* Note that this also means that we can get less entities than the requested download size in a successful call.
|
||||
* See Microsoft Azure API Documentation at: https://msdn.microsoft.com/en-us/library/azure/dd135718.aspx
|
||||
*/
|
||||
private prefetchData(
|
||||
tableQuery: Entities.ITableQuery,
|
||||
downloadSize: number,
|
||||
currentRetry: number = 0
|
||||
): Q.Promise<any> {
|
||||
if (!this.cache.serverCallInProgress) {
|
||||
this.cache.serverCallInProgress = true;
|
||||
this.allDownloaded = false;
|
||||
this.lastPrefetchTime = new Date().getTime();
|
||||
var time = this.lastPrefetchTime;
|
||||
|
||||
var promise: Q.Promise<IListTableEntitiesSegmentedResult>;
|
||||
if (this._documentIterator && this.continuationToken) {
|
||||
// TODO handle Cassandra case
|
||||
|
||||
promise = Q(this._documentIterator.fetchNext().then(response => response.resources)).then(
|
||||
(documents: any[]) => {
|
||||
let entities: Entities.ITableEntity[] = TableEntityProcessor.convertDocumentsToEntities(documents);
|
||||
let finalEntities: IListTableEntitiesSegmentedResult = <IListTableEntitiesSegmentedResult>{
|
||||
Results: entities,
|
||||
ContinuationToken: this._documentIterator.hasMoreResults()
|
||||
};
|
||||
return Q.resolve(finalEntities);
|
||||
}
|
||||
);
|
||||
} else if (this.continuationToken && this.queryTablesTab.container.isPreferredApiCassandra()) {
|
||||
promise = this.queryTablesTab.container.tableDataClient.queryDocuments(
|
||||
this.queryTablesTab.collection,
|
||||
this.cqlQuery(),
|
||||
true,
|
||||
this.continuationToken
|
||||
);
|
||||
} else {
|
||||
let query = this.sqlQuery();
|
||||
if (this.queryTablesTab.container.isPreferredApiCassandra()) {
|
||||
query = this.cqlQuery();
|
||||
}
|
||||
promise = this.queryTablesTab.container.tableDataClient.queryDocuments(
|
||||
this.queryTablesTab.collection,
|
||||
query,
|
||||
true
|
||||
);
|
||||
}
|
||||
return promise
|
||||
.then((result: IListTableEntitiesSegmentedResult) => {
|
||||
if (!this._documentIterator) {
|
||||
this._documentIterator = result.iterator;
|
||||
}
|
||||
var actualDownloadSize: number = 0;
|
||||
|
||||
// If we hit this, it means another service call is triggered. We only handle the latest call.
|
||||
// And as another service call is during process, we don't set serverCallInProgress to false here.
|
||||
// Thus, end the prefetch.
|
||||
if (this.lastPrefetchTime !== time) {
|
||||
return Q.resolve(null);
|
||||
}
|
||||
|
||||
var entities = result.Results;
|
||||
actualDownloadSize = entities.length;
|
||||
|
||||
// Queries can fetch no results and still return a continuation header. See prefetchAndRender() method.
|
||||
this.continuationToken = this.isCancelled ? null : result.ContinuationToken;
|
||||
|
||||
if (!this.continuationToken) {
|
||||
this.allDownloaded = true;
|
||||
}
|
||||
|
||||
if (this.isCacheValid(tableQuery)) {
|
||||
// Append to cache.
|
||||
this.cache.data = this.cache.data.concat(entities.slice(0));
|
||||
} else {
|
||||
// Create cache.
|
||||
this.cache.data = entities;
|
||||
}
|
||||
|
||||
this.cache.tableQuery = tableQuery;
|
||||
this.cache.serverCallInProgress = false;
|
||||
|
||||
var nextDownloadSize: number = downloadSize - actualDownloadSize;
|
||||
if (nextDownloadSize === 0 && tableQuery.top) {
|
||||
this.allDownloaded = true;
|
||||
}
|
||||
|
||||
// There are three possible results for a prefetch:
|
||||
// 1. Continuation token is null or fetched items' size reaches predefined.
|
||||
// 2. Continuation token is not null and fetched items' size hasn't reach predefined.
|
||||
// 2.1 Retry times has reached predefined maximum.
|
||||
// 2.2 Retry times hasn't reached predefined maximum.
|
||||
// Correspondingly,
|
||||
// For #1, end prefetch.
|
||||
// For #2.1, set prefetch exceeds maximum retry number and end prefetch.
|
||||
// For #2.2, go to next round prefetch.
|
||||
if (this.allDownloaded || nextDownloadSize === 0) {
|
||||
return Q.resolve(result);
|
||||
}
|
||||
|
||||
if (currentRetry >= TableEntityListViewModel._maximumNumberOfPrefetchRetries) {
|
||||
result.ExceedMaximumRetries = true;
|
||||
return Q.resolve(result);
|
||||
}
|
||||
return this.prefetchData(tableQuery, nextDownloadSize, currentRetry + 1);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
this.cache.serverCallInProgress = false;
|
||||
return Q.reject(error);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user